diff --git a/src/internal/api/v1alpha1/validate.go b/src/internal/api/v1alpha1/validate.go index 911008e2a8..574aa18236 100644 --- a/src/internal/api/v1alpha1/validate.go +++ b/src/internal/api/v1alpha1/validate.go @@ -84,12 +84,17 @@ func ValidatePackage(pkg v1alpha1.ZarfPackage) error { } } } + // A skeleton retains one component per architecture/flavor, so names need only be unique per variant there. + isSkeleton := pkg.Metadata.Architecture == v1alpha1.SkeletonArch for _, component := range pkg.Components { - // ensure component name is unique - if _, ok := uniqueComponentNames[component.Name]; ok { + nameKey := component.Name + if isSkeleton { + nameKey = strings.Join([]string{component.Name, component.Only.Cluster.Architecture, component.Only.Flavor}, "\x00") + } + if _, ok := uniqueComponentNames[nameKey]; ok { err = errors.Join(err, fmt.Errorf(PkgValidateErrComponentNameNotUnique, component.Name)) } - uniqueComponentNames[component.Name] = true + uniqueComponentNames[nameKey] = true if component.IsRequired() { if component.Default { err = errors.Join(err, fmt.Errorf(PkgValidateErrComponentReqDefault, component.Name)) diff --git a/src/pkg/packager/load/import.go b/src/pkg/packager/load/import.go index 2adc61a560..f2339a1664 100644 --- a/src/pkg/packager/load/import.go +++ b/src/pkg/packager/load/import.go @@ -152,17 +152,18 @@ func resolveImports(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath, name := getComponentToImportName(component) found := []v1alpha1.ZarfComponent{} - for _, component := range importedPkg.Components { - if component.Name == name && compatibleComponent(component, arch, flavor) { - found = append(found, component) + for _, importedComponent := range importedPkg.Components { + if importedComponent.Name == name && compatibleComponent(importedComponent, arch, flavor) { + found = append(found, importedComponent) } } if len(found) == 0 { return v1alpha1.ZarfPackage{}, nil, fmt.Errorf("no compatible component named %s found", name) - } else if len(found) > 1 { + } + // A concrete-arch build resolves to one component; a skeleton retains every arch variant. + if len(found) > 1 && arch != v1alpha1.SkeletonArch { return v1alpha1.ZarfPackage{}, nil, fmt.Errorf("multiple components named %s found", name) } - importedComponent := found[0] importPath, err := fetchOCISkeleton(ctx, component, pkgPath.BaseDir, cachePath, remoteOptions) if err != nil { @@ -178,16 +179,18 @@ func resolveImports(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath, if !fileInfo.IsDir() { importPath = filepath.Dir(importPath) } - importedComponent = fixPaths(importedComponent, importPath, pkgPath.BaseDir) - composed, err := overrideMetadata(importedComponent, component) - if err != nil { - return v1alpha1.ZarfPackage{}, nil, err - } - composed = overrideDeprecated(composed, component) - composed = overrideActions(composed, component) - composed = overrideResources(composed, component) - components = append(components, composed) + for _, importedComponent := range found { + importedComponent = fixPaths(importedComponent, importPath, pkgPath.BaseDir) + composed, err := overrideMetadata(importedComponent, component) + if err != nil { + return v1alpha1.ZarfPackage{}, nil, err + } + composed = overrideDeprecated(composed, component) + composed = overrideActions(composed, component) + composed = overrideResources(composed, component) + components = append(components, composed) + } variables = append(variables, importedPkg.Variables...) constants = append(constants, importedPkg.Constants...) for _, v := range importedPkg.Values.Files { @@ -278,7 +281,7 @@ func validateComponentCompose(c v1alpha1.ZarfComponent) error { } func compatibleComponent(c v1alpha1.ZarfComponent, arch, flavor string) bool { - satisfiesArch := c.Only.Cluster.Architecture == "" || c.Only.Cluster.Architecture == arch + satisfiesArch := c.Only.Cluster.Architecture == "" || arch == v1alpha1.SkeletonArch || c.Only.Cluster.Architecture == arch satisfiesFlavor := c.Only.Flavor == "" || c.Only.Flavor == flavor return satisfiesArch && satisfiesFlavor } diff --git a/src/pkg/packager/load/import_test.go b/src/pkg/packager/load/import_test.go index b0df766a6e..459fcdb894 100644 --- a/src/pkg/packager/load/import_test.go +++ b/src/pkg/packager/load/import_test.go @@ -521,3 +521,52 @@ func TestCompatibleComponent(t *testing.T) { }) } } + +func TestCompatibleComponentSkeletonArch(t *testing.T) { + t.Parallel() + + for _, arch := range []string{"amd64", "arm64"} { + c := v1alpha1.ZarfComponent{ + Only: v1alpha1.ZarfComponentOnlyTarget{ + Cluster: v1alpha1.ZarfComponentOnlyCluster{Architecture: arch}, + }, + } + require.True(t, compatibleComponent(c, v1alpha1.SkeletonArch, ""), + "skeleton compose must retain %s component", arch) + } +} + +func TestResolveImportsSkeletonRetainsAllArches(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + + dir := t.TempDir() + zarfYAML := `kind: ZarfPackageConfig +metadata: + name: arch-skeleton +components: + - name: injector + only: + cluster: + architecture: amd64 + - name: injector + only: + cluster: + architecture: arm64 +` + require.NoError(t, os.WriteFile(filepath.Join(dir, layout.ZarfYAML), []byte(zarfYAML), 0o644)) + b, err := os.ReadFile(filepath.Join(dir, layout.ZarfYAML)) + require.NoError(t, err) + pkg, err := pkgcfg.Parse(ctx, b) + require.NoError(t, err) + + resolved, _, err := resolveImports(ctx, pkg, dir, v1alpha1.SkeletonArch, "", []string{}, "", false, types.RemoteOptions{}) + require.NoError(t, err) + + archs := []string{} + for _, c := range resolved.Components { + archs = append(archs, c.Only.Cluster.Architecture) + } + require.ElementsMatch(t, []string{"amd64", "arm64"}, archs, + "skeleton must retain all only.cluster.architecture variants") +} diff --git a/src/pkg/packager/load/load.go b/src/pkg/packager/load/load.go index 93d3fb806e..ba85eb46f8 100644 --- a/src/pkg/packager/load/load.go +++ b/src/pkg/packager/load/load.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "time" "github.com/zarf-dev/zarf/src/api/v1alpha1" @@ -26,6 +27,21 @@ import ( "github.com/zarf-dev/zarf/src/types" ) +// VariantDimension is a dimension along which components of the same name may vary. +type VariantDimension string + +const ( + // VariantArchitecture is the only.cluster.architecture dimension of a component. + VariantArchitecture VariantDimension = "architecture" + // VariantFlavor is the only.flavor dimension of a component. + VariantFlavor VariantDimension = "flavor" +) + +// AllVariantDimension returns every variant dimension. +func AllVariantDimension() []VariantDimension { + return []VariantDimension{VariantArchitecture, VariantFlavor} +} + // DefinitionOptions are the optional parameters to load.PackageDefinition type DefinitionOptions struct { Flavor string @@ -41,6 +57,10 @@ type DefinitionOptions struct { IsInteractive bool // SkipVersionCheck skips version requirement validation SkipVersionCheck bool + // SkipVariantFilters lists dimensions to retain instead of filtering out. + // e.g. VariantArchitecture retains every only.cluster.architecture variant of a + // component, as used when publishing skeletons. + SkipVariantFilters []VariantDimension types.RemoteOptions } @@ -75,7 +95,11 @@ func PackageDefinition(ctx context.Context, packagePath string, opts DefinitionO if err != nil { return DefinedPackage{}, err } - pkg.Metadata.Architecture = config.GetArch(pkg.Metadata.Architecture) + if slices.Contains(opts.SkipVariantFilters, VariantArchitecture) { + pkg.Metadata.Architecture = v1alpha1.SkeletonArch + } else { + pkg.Metadata.Architecture = config.GetArch(pkg.Metadata.Architecture) + } opts.CachePath, err = utils.ResolveCachePath(opts.CachePath) if err != nil { return DefinedPackage{}, err diff --git a/src/pkg/packager/publish.go b/src/pkg/packager/publish.go index 0be36efc0a..72197bdd7b 100644 --- a/src/pkg/packager/publish.go +++ b/src/pkg/packager/publish.go @@ -231,6 +231,7 @@ func PublishSkeleton(ctx context.Context, path string, ref registry.Reference, o Flavor: opts.Flavor, SkipVersionCheck: opts.SkipVersionCheck, SkipRequiredValues: true, + SkipVariantFilters: []load.VariantDimension{load.VariantArchitecture}, RemoteOptions: opts.RemoteOptions, }) if err != nil { diff --git a/src/pkg/packager/publish_test.go b/src/pkg/packager/publish_test.go index 9999e3cc10..e65c6cb0da 100644 --- a/src/pkg/packager/publish_test.go +++ b/src/pkg/packager/publish_test.go @@ -220,6 +220,31 @@ func TestPublishSkeleton(t *testing.T) { } } +func TestPublishSkeletonMultiArch(t *testing.T) { + ctx := testutil.TestContext(t) + registryRef := createRegistry(ctx, t) + + ref, err := PublishSkeleton(ctx, "testdata/skeleton-multiarch", registryRef, PublishSkeletonOptions{ + RemoteOptions: defaultTestRemoteOptions(), + }) + require.NoError(t, err) + + rmt, err := zoci.NewRemote(ctx, ref.String(), zoci.PlatformForSkeleton(), oci.WithPlainHTTP(true)) + require.NoError(t, err) + pkg, err := rmt.FetchZarfYAML(ctx) + require.NoError(t, err) + + got := []string{} + for _, comp := range pkg.Components { + got = append(got, comp.Name+"/"+comp.Only.Cluster.Architecture) + } + // Skeletons retain every architecture variant for both direct and imported components. + require.ElementsMatch(t, []string{ + "direct/amd64", "direct/arm64", + "imported/amd64", "imported/arm64", + }, got) +} + func TestPublishPackage(t *testing.T) { signOpts := signing.DefaultSignBlobOptions() signOpts.Key = filepath.Join("testdata", "publish", "cosign.key") diff --git a/src/pkg/packager/testdata/skeleton-multiarch/sub/zarf.yaml b/src/pkg/packager/testdata/skeleton-multiarch/sub/zarf.yaml new file mode 100644 index 0000000000..8c99978d05 --- /dev/null +++ b/src/pkg/packager/testdata/skeleton-multiarch/sub/zarf.yaml @@ -0,0 +1,16 @@ +kind: ZarfPackageConfig +metadata: + name: skeleton-multiarch-sub + description: sub-package with a same-named component gated per architecture + +components: + - name: shared + required: true + only: + cluster: + architecture: amd64 + - name: shared + required: true + only: + cluster: + architecture: arm64 diff --git a/src/pkg/packager/testdata/skeleton-multiarch/zarf.yaml b/src/pkg/packager/testdata/skeleton-multiarch/zarf.yaml new file mode 100644 index 0000000000..1365f4474c --- /dev/null +++ b/src/pkg/packager/testdata/skeleton-multiarch/zarf.yaml @@ -0,0 +1,22 @@ +kind: ZarfPackageConfig +metadata: + name: skeleton-multiarch + description: package with same-named components gated per architecture, direct and imported + version: 0.0.1 + +components: + - name: direct + required: true + only: + cluster: + architecture: amd64 + - name: direct + required: true + only: + cluster: + architecture: arm64 + - name: imported + required: true + import: + path: sub + name: shared diff --git a/src/test/e2e/10_component_flavor_test.go b/src/test/e2e/10_component_flavor_test.go index ccab81c988..02c5be1a75 100644 --- a/src/test/e2e/10_component_flavor_test.go +++ b/src/test/e2e/10_component_flavor_test.go @@ -85,34 +85,30 @@ func TestPublishFlavor(t *testing.T) { reg.Registry = testutil.SetupInMemoryRegistry(testutil.TestContext(t), t, 31888) ref := reg.String() - expectedIDs := []string{"combined", "via-import"} - flavorTest := filepath.Join("src", "test", "packages", "10-package-flavors") - _, _, err := e2e.Zarf(t, "package", "publish", flavorTest, "--flavor", "vanilla", "--no-color", "oci://"+ref, "--plain-http") - require.NoError(t, err) - - stdOut, _, err := e2e.Zarf(t, "package", "inspect", "definition", "oci://"+ref+"/test-package-flavors:v0.0.0-vanilla", "--plain-http", "-a", "skeleton") - require.NoError(t, err) - var config v1alpha1.ZarfPackage - err = yaml.Unmarshal([]byte(stdOut), &config) - require.NoError(t, err) - - for i, component := range config.Components { - require.Equal(t, expectedIDs[i], component.Name) + // A skeleton is architecture-agnostic, so it retains every arch variant of the flavor's components. + assertSkeleton := func(t *testing.T, flavor string) { + t.Helper() + _, _, err := e2e.Zarf(t, "package", "publish", flavorTest, "--flavor", flavor, "oci://"+ref, "--plain-http") + require.NoError(t, err) + + stdOut, _, err := e2e.Zarf(t, "package", "inspect", "definition", "oci://"+ref+"/test-package-flavors:v0.0.0-"+flavor, "--plain-http", "-a", "skeleton") + require.NoError(t, err) + + var config v1alpha1.ZarfPackage + require.NoError(t, yaml.Unmarshal([]byte(stdOut), &config)) + + got := []string{} + for _, component := range config.Components { + got = append(got, component.Name+"/"+component.Only.Cluster.Architecture) + } + require.ElementsMatch(t, []string{ + "combined/amd64", "combined/arm64", + "via-import/amd64", "via-import/arm64", + }, got) } - flavorTest = filepath.Join("src", "test", "packages", "10-package-flavors") - _, _, err = e2e.Zarf(t, "package", "publish", flavorTest, "--flavor", "chocolate", "--no-color", "oci://"+ref, "--plain-http") - require.NoError(t, err) - - stdOut, _, err = e2e.Zarf(t, "package", "inspect", "definition", "oci://"+ref+"/test-package-flavors:v0.0.0-chocolate", "--plain-http", "-a", "skeleton") - require.NoError(t, err) - - err = yaml.Unmarshal([]byte(stdOut), &config) - require.NoError(t, err) - - for i, component := range config.Components { - require.Equal(t, expectedIDs[i], component.Name) - } + assertSkeleton(t, "vanilla") + assertSkeleton(t, "chocolate") }