From 38b3d9fbd3cdc92dbd8b886c67efc39fd32d9915 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Tue, 5 May 2026 08:52:53 -0400 Subject: [PATCH 01/27] improve oci testing Signed-off-by: Austin Abro --- go.mod | 2 +- src/pkg/images/pull_test.go | 195 ++++++++++++++++++++----------- src/pkg/zoci/pull_test.go | 7 +- src/test/testutil/ociregistry.go | 149 +++++++++++++++++++++++ src/test/testutil/registry.go | 10 ++ 5 files changed, 291 insertions(+), 72 deletions(-) create mode 100644 src/test/testutil/ociregistry.go diff --git a/go.mod b/go.mod index 94c31c6fe5..29fb03ee03 100644 --- a/go.mod +++ b/go.mod @@ -527,7 +527,7 @@ require ( github.com/oleiade/reflections v1.1.0 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/open-policy-agent/opa v1.14.1 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/openvex/go-vex v0.2.7 // indirect diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 3ea7efdaf6..1c2c5ac7fc 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -5,6 +5,8 @@ package images import ( + "bytes" + "context" "crypto/sha256" "encoding/json" "fmt" @@ -12,78 +14,125 @@ import ( "path/filepath" "testing" + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/test/testutil" - "oras.land/oras-go/v2" - orasRemote "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote" ) +// pushDockerManifestList pushes a Docker-mediaType manifest list referencing the given children +// so tests can exercise the docker code path through isIndex alongside OCI image indexes. +func pushDockerManifestList(ctx context.Context, t *testing.T, repo *remote.Repository, children []ocispec.Descriptor) ocispec.Descriptor { + t.Helper() + list := struct { + specs.Versioned + MediaType string `json:"mediaType"` + Manifests []ocispec.Descriptor `json:"manifests"` + }{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: DockerMediaTypeManifestList, + Manifests: children, + } + body, err := json.Marshal(list) + require.NoError(t, err) + desc := ocispec.Descriptor{ + MediaType: DockerMediaTypeManifestList, + Digest: digest.FromBytes(body), + Size: int64(len(body)), + } + require.NoError(t, repo.Push(ctx, desc, bytes.NewReader(body))) + return desc +} + func TestCheckForIndex(t *testing.T) { t.Parallel() + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + + platforms := []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + + ociRepo := testutil.NewRepo(t, upstream+"/fixtures/idx") + ociChildren := make([]ocispec.Descriptor, 0, len(platforms)) + for _, p := range platforms { + desc := testutil.PushSinglePlatformImage(ctx, t, ociRepo, p.Architecture) + p := p + desc.Platform = &p + ociChildren = append(ociChildren, desc) + } + ociIdx := testutil.PushIndex(ctx, t, ociRepo, ociChildren) + require.NoError(t, ociRepo.Tag(ctx, ociIdx, "v1")) + + dockerRepo := testutil.NewRepo(t, upstream+"/fixtures/docker-list") + dockerChildren := make([]ocispec.Descriptor, 0, len(platforms)) + for _, p := range platforms { + desc := testutil.PushSinglePlatformImage(ctx, t, dockerRepo, p.Architecture) + p := p + desc.Platform = &p + dockerChildren = append(dockerChildren, desc) + } + dockerList := pushDockerManifestList(ctx, t, dockerRepo, dockerChildren) + require.NoError(t, dockerRepo.Tag(ctx, dockerList, "v1")) + + manifestDigest := testutil.PushImage(ctx, t, upstream+"/fixtures/img", "v1") + testCases := []struct { - name string - ref string - file string - arch string - expectedErr string + name string + ref string + expectedDigests []string + expectedErr string }{ { - name: "index sha", - ref: "ghcr.io/zarf-dev/zarf/agent:v0.32.6@sha256:05a82656df5466ce17c3e364c16792ae21ce68438bfe06eeab309d0520c16b48", - file: "agent-index.json", - arch: "arm64", + name: "oci index sha", + ref: fmt.Sprintf("%s/fixtures/idx@%s", upstream, ociIdx.Digest), expectedErr: "%s resolved to an OCI image index which is not supported by Zarf, select a specific platform to use", + expectedDigests: []string{ + ociChildren[0].Digest.String(), + ociChildren[1].Digest.String(), + }, }, { name: "docker manifest list", - ref: "defenseunicorns/zarf-game@sha256:0b694ca1c33afae97b7471488e07968599f1d2470c629f76af67145ca64428af", - file: "game-index.json", - arch: "arm64", + ref: fmt.Sprintf("%s/fixtures/docker-list@%s", upstream, dockerList.Digest), expectedErr: "%s resolved to an OCI image index which is not supported by Zarf, select a specific platform to use", + expectedDigests: []string{ + dockerChildren[0].Digest.String(), + dockerChildren[1].Digest.String(), + }, }, { - name: "image manifest", - ref: "ghcr.io/zarf-dev/zarf/agent:v0.32.6", - file: "agent-manifest.json", - arch: "arm64", - expectedErr: "", + name: "image manifest by tag", + ref: fmt.Sprintf("%s/fixtures/img:v1", upstream), }, { - name: "image manifest sha'd", - ref: "ghcr.io/zarf-dev/zarf/agent:v0.32.6@sha256:b3fabdc7d4ecd0f396016ef78da19002c39e3ace352ea0ae4baa2ce9d5958376", - file: "agent-manifest.json", - arch: "arm64", - expectedErr: "", + name: "image manifest by digest", + ref: fmt.Sprintf("%s/fixtures/img@%s", upstream, manifestDigest), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctx := testutil.TestContext(t) refInfo, err := transform.ParseImageRef(tc.ref) require.NoError(t, err) - repo, err := orasRemote.NewRepository(refInfo.Reference) - require.NoError(t, err) - _, b, err := oras.FetchBytes(ctx, repo, refInfo.Reference, oras.DefaultFetchBytesOptions) - require.NoError(t, err) - var idx ocispec.Index - err = json.Unmarshal(b, &idx) - require.NoError(t, err) + cacheDir := t.TempDir() dstDir := t.TempDir() opts := PullOptions{ - Arch: tc.arch, + Arch: "amd64", CacheDirectory: cacheDir, + PlainHTTP: true, } _, err = Pull(ctx, []transform.Image{refInfo}, dstDir, opts) if tc.expectedErr != "" { require.ErrorContains(t, err, fmt.Sprintf(tc.expectedErr, refInfo.Reference)) - // Ensure the error message contains the digest of the manifests the user can use - for _, manifest := range idx.Manifests { - require.ErrorContains(t, err, manifest.Digest.String()) + for _, d := range tc.expectedDigests { + require.ErrorContains(t, err, d) } return } @@ -94,40 +143,48 @@ func TestCheckForIndex(t *testing.T) { func TestPull(t *testing.T) { t.Parallel() + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + + testutil.PushImage(ctx, t, upstream+"/fixtures/container", "0.0.1") + testutil.PushImage(ctx, t, upstream+"/fixtures/sig", "v1.sig") + testutil.PushImage(ctx, t, upstream+"/fixtures/helm", "6.4.0") + shaDigest := testutil.PushImage(ctx, t, upstream+"/fixtures/sha-pinned", "ignored") + + overrideUpstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + testutil.PushImage(ctx, t, overrideUpstream+"/library/podinfo", "6.4.0") + testCases := []struct { name string refs []string - RegistryOverrides []RegistryOverride - arch string + registryOverrides []RegistryOverride expectErr bool }{ { - name: "pull a container image, a cosign signature, a Helm chart, and a sha'd container image", + name: "pull a container image, a cosign-style signature, a chart-style image, and a sha'd image", refs: []string{ - "ghcr.io/zarf-dev/doom-game:0.0.1", - "ghcr.io/stefanprodan/podinfo:sha256-57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8.sig", - "ghcr.io/stefanprodan/manifests/podinfo:6.4.0", - "ghcr.io/fluxcd/image-automation-controller@sha256:48a89734dc82c3a2d4138554b3ad4acf93230f770b3a582f7f48be38436d031c", + fmt.Sprintf("%s/fixtures/container:0.0.1", upstream), + fmt.Sprintf("%s/fixtures/sig:v1.sig", upstream), + fmt.Sprintf("%s/fixtures/helm:6.4.0", upstream), + fmt.Sprintf("%s/fixtures/sha-pinned@%s", upstream, shaDigest), }, - arch: "amd64", }, { name: "error when pulling an image that doesn't exist", refs: []string{ - "ghcr.io/zarf-dev/zarf/imagethatdoesntexist:v1.1.1", + fmt.Sprintf("%s/fixtures/missing:does-not-exist", upstream), }, expectErr: true, }, { name: "test registry overrides", refs: []string{ - "stefanprodan/podinfo:6.4.0", + "fake.example/library/podinfo:6.4.0", }, - arch: "amd64", - RegistryOverrides: []RegistryOverride{ + registryOverrides: []RegistryOverride{ { - Source: "docker.io", - Override: "ghcr.io", + Source: "fake.example", + Override: overrideUpstream, }, }, }, @@ -136,7 +193,6 @@ func TestPull(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - ctx := testutil.TestContext(t) var images []transform.Image for _, ref := range tc.refs { image, err := transform.ParseImageRef(ref) @@ -148,13 +204,14 @@ func TestPull(t *testing.T) { cacheDir := t.TempDir() opts := PullOptions{ CacheDirectory: cacheDir, - RegistryOverrides: tc.RegistryOverrides, - Arch: tc.arch, + RegistryOverrides: tc.registryOverrides, + Arch: "amd64", + PlainHTTP: true, } imageManifests, err := Pull(ctx, images, destDir, opts) if tc.expectErr { - require.Error(t, err, tc.expectErr) + require.Error(t, err) return } require.NoError(t, err) @@ -175,7 +232,6 @@ func TestPull(t *testing.T) { } require.ElementsMatch(t, expectedImageAnnotations, actualImageAnnotations) - // Make sure all the layers of the image are pulled in for _, imageWithManifest := range imageManifests { for _, layer := range imageWithManifest.Manifest.Layers { require.FileExists(t, filepath.Join(destDir, fmt.Sprintf("blobs/sha256/%s", layer.Digest.Hex()))) @@ -190,23 +246,30 @@ func TestPullInvalidCache(t *testing.T) { // pulling an image with an invalid layer in the cache should still pull the image t.Parallel() ctx := testutil.TestContext(t) - ref, err := transform.ParseImageRef("ghcr.io/fluxcd/image-automation-controller@sha256:48a89734dc82c3a2d4138554b3ad4acf93230f770b3a582f7f48be38436d031c") + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + + repo := testutil.NewRepo(t, upstream+"/fixtures/cache") + layer := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 128)) + config := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageConfig, []byte(`{"architecture":"amd64"}`)) + manifest := testutil.PushManifest(ctx, t, repo, config, []ocispec.Descriptor{layer}) + require.NoError(t, repo.Tag(ctx, manifest, "v1")) + + ref, err := transform.ParseImageRef(fmt.Sprintf("%s/fixtures/cache@%s", upstream, manifest.Digest)) require.NoError(t, err) + destDir := t.TempDir() cacheDir := t.TempDir() - require.NoError(t, os.MkdirAll(cacheDir, 0777)) - invalidContent := []byte("this mimics a corrupted file") - // This is the sha of a layer of the image. - // we intentionally put junk data into the cache with this layer to test that it will get cleaned up. - correctLayerSha := "d94c8059c3cffb9278601bf9f8be070d50c84796401a4c5106eb8a4042445bbc" + require.NoError(t, os.MkdirAll(cacheDir, 0o777)) + + correctLayerSha := layer.Digest.Hex() invalidLayerPath := filepath.Join(cacheDir, fmt.Sprintf("sha256:%s", correctLayerSha)) - err = os.WriteFile(invalidLayerPath, invalidContent, 0777) - require.NoError(t, err) + require.NoError(t, os.WriteFile(invalidLayerPath, []byte("this mimics a corrupted file"), 0o777)) - opts := PullOptions{ + _, err = Pull(ctx, []transform.Image{ref}, destDir, PullOptions{ CacheDirectory: cacheDir, - } - _, err = Pull(ctx, []transform.Image{ref}, destDir, opts) + Arch: "amd64", + PlainHTTP: true, + }) require.NoError(t, err) pulledLayerPath := filepath.Join(destDir, "blobs", "sha256", correctLayerSha) diff --git a/src/pkg/zoci/pull_test.go b/src/pkg/zoci/pull_test.go index 9cca7a6d6b..d98fe31285 100644 --- a/src/pkg/zoci/pull_test.go +++ b/src/pkg/zoci/pull_test.go @@ -10,7 +10,6 @@ import ( "slices" "testing" - "github.com/defenseunicorns/pkg/helpers/v2" "github.com/defenseunicorns/pkg/oci" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" @@ -24,11 +23,9 @@ import ( ) func createRegistry(ctx context.Context, t *testing.T) registry.Reference { - dstPort, err := helpers.GetAvailablePort() - require.NoError(t, err) - dstRegistryURL := testutil.SetupInMemoryRegistry(ctx, t, dstPort) + t.Helper() return registry.Reference{ - Registry: dstRegistryURL, + Registry: testutil.SetupInMemoryRegistryDynamic(ctx, t), Repository: "my-namespace", } } diff --git a/src/test/testutil/ociregistry.go b/src/test/testutil/ociregistry.go new file mode 100644 index 0000000000..0246827982 --- /dev/null +++ b/src/test/testutil/ociregistry.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package testutil + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "testing" + + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry/remote" +) + +// NewRepo returns a plaintext-HTTP oras-go Repository suitable for pushing fixtures into an +// in-memory registry during tests. +func NewRepo(t *testing.T, refStr string) *remote.Repository { + t.Helper() + repo, err := remote.NewRepository(refStr) + require.NoError(t, err) + repo.PlainHTTP = true + return repo +} + +// RandomBytes returns n cryptographically random bytes; used as blob content that hashes +// differently on every test run. +func RandomBytes(t *testing.T, n int) []byte { + t.Helper() + b := make([]byte, n) + _, err := rand.Read(b) + require.NoError(t, err) + return b +} + +// PushBlob pushes raw bytes with the given media type and returns the resulting descriptor. +func PushBlob(ctx context.Context, t *testing.T, repo *remote.Repository, mediaType string, data []byte) ocispec.Descriptor { + t.Helper() + desc := ocispec.Descriptor{ + MediaType: mediaType, + Digest: digest.FromBytes(data), + Size: int64(len(data)), + } + if exists, err := repo.Exists(ctx, desc); err == nil && exists { + return desc + } + require.NoError(t, repo.Push(ctx, desc, bytes.NewReader(data))) + return desc +} + +// PushManifest constructs an image manifest pointing at the given config and layers, pushes it, +// and returns its descriptor. +func PushManifest(ctx context.Context, t *testing.T, repo *remote.Repository, config ocispec.Descriptor, layers []ocispec.Descriptor) ocispec.Descriptor { + t.Helper() + manifest := ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: config, + Layers: layers, + } + body, err := json.Marshal(manifest) + require.NoError(t, err) + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: digest.FromBytes(body), + Size: int64(len(body)), + } + require.NoError(t, repo.Push(ctx, desc, bytes.NewReader(body))) + return desc +} + +// PushIndex builds and pushes an OCI image index referencing the given child descriptors. +// Children may themselves be manifests or indexes; nested indexes are supported by the OCI spec. +func PushIndex(ctx context.Context, t *testing.T, repo *remote.Repository, children []ocispec.Descriptor) ocispec.Descriptor { + t.Helper() + idx := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: children, + } + body, err := json.Marshal(idx) + require.NoError(t, err) + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageIndex, + Digest: digest.FromBytes(body), + Size: int64(len(body)), + } + require.NoError(t, repo.Push(ctx, desc, bytes.NewReader(body))) + return desc +} + +// PushSinglePlatformImage creates a config blob, a random layer, and a manifest that references +// both. The config embeds arch so distinct architectures produce distinct config blobs. +func PushSinglePlatformImage(ctx context.Context, t *testing.T, repo *remote.Repository, arch string) ocispec.Descriptor { + t.Helper() + layer := PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, RandomBytes(t, 64)) + configJSON := fmt.Sprintf(`{"architecture":%q}`, arch) + config := PushBlob(ctx, t, repo, ocispec.MediaTypeImageConfig, []byte(configJSON)) + return PushManifest(ctx, t, repo, config, []ocispec.Descriptor{layer}) +} + +// PushImage pushes a single-manifest image and tags it; returns the manifest digest. +func PushImage(ctx context.Context, t *testing.T, repoRef, tag string) string { + t.Helper() + repo := NewRepo(t, repoRef) + desc := PushSinglePlatformImage(ctx, t, repo, "amd64") + require.NoError(t, repo.Tag(ctx, desc, tag)) + return desc.Digest.String() +} + +// PushMultiArchIndex pushes a flat multi-arch OCI image index with one single-platform manifest +// per entry in platforms. Returns the index digest. +func PushMultiArchIndex(ctx context.Context, t *testing.T, repoRef, tag string, platforms []ocispec.Platform) string { + t.Helper() + repo := NewRepo(t, repoRef) + children := make([]ocispec.Descriptor, 0, len(platforms)) + for _, platform := range platforms { + desc := PushSinglePlatformImage(ctx, t, repo, platform.Architecture) + p := platform + desc.Platform = &p + children = append(children, desc) + } + idx := PushIndex(ctx, t, repo, children) + require.NoError(t, repo.Tag(ctx, idx, tag)) + return idx.Digest.String() +} + +// PushNestedIndex pushes an OCI image index whose only child is itself an image index containing +// one single-platform manifest per entry in platforms. Returns the outer index digest. +func PushNestedIndex(ctx context.Context, t *testing.T, repoRef, tag string, platforms []ocispec.Platform) string { + t.Helper() + repo := NewRepo(t, repoRef) + inner := make([]ocispec.Descriptor, 0, len(platforms)) + for _, platform := range platforms { + desc := PushSinglePlatformImage(ctx, t, repo, platform.Architecture) + p := platform + desc.Platform = &p + inner = append(inner, desc) + } + innerIdx := PushIndex(ctx, t, repo, inner) + outerIdx := PushIndex(ctx, t, repo, []ocispec.Descriptor{innerIdx}) + require.NoError(t, repo.Tag(ctx, outerIdx, tag)) + return outerIdx.Digest.String() +} diff --git a/src/test/testutil/registry.go b/src/test/testutil/registry.go index 88c81bc29c..154fe2982b 100644 --- a/src/test/testutil/registry.go +++ b/src/test/testutil/registry.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/defenseunicorns/pkg/helpers/v2" "github.com/distribution/distribution/v3/configuration" "github.com/distribution/distribution/v3/registry" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" // used for docker test registry @@ -18,6 +19,15 @@ import ( "github.com/stretchr/testify/require" ) +// SetupInMemoryRegistryDynamic starts an in-memory registry on an automatically-allocated port +// and returns the address. Use this when tests do not need a well-known port. +func SetupInMemoryRegistryDynamic(ctx context.Context, t *testing.T) string { + t.Helper() + port, err := helpers.GetAvailablePort() + require.NoError(t, err) + return SetupInMemoryRegistry(ctx, t, port) +} + // SetupInMemoryRegistry sets up an in-memory registry on localhost and returns the address. func SetupInMemoryRegistry(ctx context.Context, t *testing.T, port int) string { t.Helper() From 84a663db12ace0200709770f6dc700f2517810c0 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Tue, 5 May 2026 12:13:48 -0400 Subject: [PATCH 02/27] improve oci testing Signed-off-by: Austin Abro --- src/pkg/images/pull_test.go | 3 +- src/pkg/zoci/pull_test.go | 162 +++++++++++++++++++++++------- src/pkg/zoci/push_package_test.go | 11 +- src/test/testutil/registry.go | 3 +- 4 files changed, 130 insertions(+), 49 deletions(-) diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 1c2c5ac7fc..4cb743d772 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -23,8 +23,7 @@ import ( "oras.land/oras-go/v2/registry/remote" ) -// pushDockerManifestList pushes a Docker-mediaType manifest list referencing the given children -// so tests can exercise the docker code path through isIndex alongside OCI image indexes. +// pushDockerManifestList pushes a Docker-mediaType manifest list to exercise isIndex's docker path. func pushDockerManifestList(ctx context.Context, t *testing.T, repo *remote.Repository, children []ocispec.Descriptor) ocispec.Descriptor { t.Helper() list := struct { diff --git a/src/pkg/zoci/pull_test.go b/src/pkg/zoci/pull_test.go index d98fe31285..2370b8ba00 100644 --- a/src/pkg/zoci/pull_test.go +++ b/src/pkg/zoci/pull_test.go @@ -6,11 +6,13 @@ package zoci_test import ( "context" + "fmt" "os" - "slices" + "path/filepath" "testing" "github.com/defenseunicorns/pkg/oci" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/pkg/packager" @@ -79,44 +81,129 @@ func TestAllLayersRespectsRequestedComponents(t *testing.T) { require.Len(t, allLayersSubset, 3) } +// writeVirtualPackageDef writes a minimal zarf package definition that references imageRef. +func writeVirtualPackageDef(t *testing.T, imageRef string) string { + t.Helper() + dir := t.TempDir() + zarfYAML := fmt.Sprintf(`kind: ZarfPackageConfig +metadata: + name: assemble-layers-test + version: 0.0.1 + architecture: amd64 +documentation: + readme: README.md +components: + - name: alpine + required: true + manifests: + - name: alpine + namespace: test + files: + - pod.yaml + images: + - %s +`, imageRef) + pod := fmt.Sprintf(`apiVersion: v1 +kind: Pod +metadata: + name: test-pod +spec: + containers: + - name: test + image: %s +`, imageRef) + require.NoError(t, os.WriteFile(filepath.Join(dir, "zarf.yaml"), []byte(zarfYAML), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pod.yaml"), []byte(pod), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0o644)) + return dir +} + +// virtualImage holds descriptors of the image pushed by buildVirtualPackage so callers can +// assert the package layers reference these exact blobs. +type virtualImage struct { + layer ocispec.Descriptor + config ocispec.Descriptor + manifest ocispec.Descriptor +} + +// virtualPackage bundles the in-memory registry address, built package tar path, build tmpdir, +// and image descriptors returned by buildVirtualPackage. +type virtualPackage struct { + registryAddr string + packagePath string + tmpdir string + image virtualImage +} + +// buildVirtualPackage pushes a virtual image to a fresh in-memory registry and runs +// packager.Create against a generated package def. SBOM is skipped as the image has random bytes +func buildVirtualPackage(ctx context.Context, t *testing.T) virtualPackage { + t.Helper() + upstreamAddr := testutil.SetupInMemoryRegistryDynamic(ctx, t) + imageRepo := testutil.NewRepo(t, upstreamAddr+"/fixtures/test-image") + layerDesc := testutil.PushBlob(ctx, t, imageRepo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 256)) + configDesc := testutil.PushBlob(ctx, t, imageRepo, ocispec.MediaTypeImageConfig, []byte(`{"architecture":"amd64","os":"linux"}`)) + manifestDesc := testutil.PushManifest(ctx, t, imageRepo, configDesc, []ocispec.Descriptor{layerDesc}) + require.NoError(t, imageRepo.Tag(ctx, manifestDesc, "test")) + imageRef := fmt.Sprintf("%s/fixtures/test-image:test", upstreamAddr) + + pkgDefDir := writeVirtualPackageDef(t, imageRef) + tmpdir := t.TempDir() + packagePath, err := packager.Create(ctx, pkgDefDir, tmpdir, packager.CreateOptions{ + CachePath: tmpdir, + RemoteOptions: types.RemoteOptions{PlainHTTP: true}, + SkipSBOM: true, // random-bytes layer can't be syft-scanned + }) + require.NoError(t, err) + return virtualPackage{ + registryAddr: upstreamAddr, + packagePath: packagePath, + tmpdir: tmpdir, + image: virtualImage{ + layer: layerDesc, + config: configDesc, + manifest: manifestDesc, + }, + } +} + func TestAssembleLayers(t *testing.T) { ctx := testutil.TestContext(t) + pkg := buildVirtualPackage(ctx, t) - remote, pkgLayout := publishAndConnect(ctx, t, "testdata/basic") - components := pkgLayout.Pkg.Components + pkgLayout, err := layout.LoadFromTar(ctx, pkg.packagePath, layout.PackageLayoutOptions{}) + require.NoError(t, err) - nonDeterministicLayers := []string{"zarf.yaml", "checksums.txt"} - expectedImageLayers := []string{ - "sha256:da324ac903c3287a9ab7f12d10fea0177251ca5d1aae156b293f042a722c414d", - "sha256:18f0797eab35a4597c1e9624aa4f15fd91f6254e5538c1e0d193b2a95dd4acc6", - "sha256:1c4eef651f65e2f7daee7ee785882ac164b02b78fb74503052a26dc061c90474", - "sha256:aded1e1a5b3705116fa0a92ba074a5e0b0031647d9c315983ccba2ee5428ec8b", - "sha256:f18232174bc91741fdf3da96d85011092101a032a93a388b79e99e69c2d5c870", - } + registryRef := registry.Reference{Registry: pkg.registryAddr, Repository: "zarf-packages"} + packageRef, err := packager.PublishPackage(ctx, pkgLayout, registryRef, packager.PublishPackageOptions{ + RemoteOptions: types.RemoteOptions{PlainHTTP: true}, + OCIConcurrency: 3, + }) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(pkgLayout.Pkg.Metadata.Name) }) //nolint:errcheck + + cacheModifier, err := zoci.GetOCICacheModifier(ctx, pkg.tmpdir) + require.NoError(t, err) + platform := oci.PlatformForArch(pkgLayout.Pkg.Build.Architecture) + remote, err := zoci.NewRemote(ctx, packageRef.String(), platform, append([]oci.Modifier{oci.WithPlainHTTP(true)}, cacheModifier)...) + require.NoError(t, err) + + components := pkgLayout.Pkg.Components tests := []struct { - name string - include []zoci.LayerType - expectedLen int - verifyDigests bool - expectedDigest []string + name string + include []zoci.LayerType + expectedLen int }{ { name: "all layers (default)", include: nil, - expectedLen: 10, + expectedLen: 9, }, { - name: "sbom layers", - include: []zoci.LayerType{zoci.SbomLayers}, - expectedLen: 3, - }, - { - name: "image layers", - include: []zoci.LayerType{zoci.ImageLayers}, - expectedLen: 7, - verifyDigests: true, - expectedDigest: expectedImageLayers, + name: "image layers", + include: []zoci.LayerType{zoci.ImageLayers}, + expectedLen: 7, }, { name: "component layers", @@ -129,21 +216,22 @@ func TestAssembleLayers(t *testing.T) { expectedLen: 3, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { layers, err := remote.AssembleLayers(ctx, components, tt.include...) require.NoError(t, err) require.Len(t, layers, tt.expectedLen) - - if tt.verifyDigests { - for _, layer := range layers { - if !slices.Contains(nonDeterministicLayers, layer.Annotations["org.opencontainers.image.title"]) { - t.Logf("Layer: %s, Title: %s", layer.Digest.String(), layer.Annotations["org.opencontainers.image.title"]) - require.Contains(t, tt.expectedDigest, layer.Digest.String()) - } - } - } }) } + + // Verify image-walking logic against known digests instead of upstream-drifting ones. + imageLayers, err := remote.AssembleLayers(ctx, components, zoci.ImageLayers) + require.NoError(t, err) + digests := map[string]struct{}{} + for _, l := range imageLayers { + digests[l.Digest.String()] = struct{}{} + } + require.Contains(t, digests, pkg.image.manifest.Digest.String(), "image manifest blob present") + require.Contains(t, digests, pkg.image.config.Digest.String(), "image config blob present") + require.Contains(t, digests, pkg.image.layer.Digest.String(), "image layer blob present") } diff --git a/src/pkg/zoci/push_package_test.go b/src/pkg/zoci/push_package_test.go index 80935a133e..dfe0cfd3dd 100644 --- a/src/pkg/zoci/push_package_test.go +++ b/src/pkg/zoci/push_package_test.go @@ -10,7 +10,6 @@ import ( "github.com/defenseunicorns/pkg/oci" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" - "github.com/zarf-dev/zarf/src/pkg/packager" "github.com/zarf-dev/zarf/src/pkg/packager/filters" "github.com/zarf-dev/zarf/src/pkg/packager/layout" "github.com/zarf-dev/zarf/src/pkg/zoci" @@ -21,17 +20,13 @@ import ( func TestPushPackage(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) - registryRef := createRegistry(ctx, t) + pkg := buildVirtualPackage(ctx, t) - tmpdir := t.TempDir() - packagePath, err := packager.Create(ctx, "testdata/basic", tmpdir, packager.CreateOptions{CachePath: tmpdir, SkipSBOM: true}) - require.NoError(t, err) - - pkgLayout, err := layout.LoadFromTar(ctx, packagePath, layout.PackageLayoutOptions{Filter: filters.Empty()}) + pkgLayout, err := layout.LoadFromTar(ctx, pkg.packagePath, layout.PackageLayoutOptions{Filter: filters.Empty()}) require.NoError(t, err) platform := oci.PlatformForArch(pkgLayout.Pkg.Build.Architecture) - remote, err := zoci.NewRemote(ctx, registryRef.String()+"/"+pkgLayout.Pkg.Metadata.Name+":"+pkgLayout.Pkg.Metadata.Version, platform, oci.WithPlainHTTP(true)) + remote, err := zoci.NewRemote(ctx, pkg.registryAddr+"/"+pkgLayout.Pkg.Metadata.Name+":"+pkgLayout.Pkg.Metadata.Version, platform, oci.WithPlainHTTP(true)) require.NoError(t, err) desc, err := remote.PushPackage(ctx, pkgLayout, zoci.PublishOptions{ diff --git a/src/test/testutil/registry.go b/src/test/testutil/registry.go index 154fe2982b..11fdefffdd 100644 --- a/src/test/testutil/registry.go +++ b/src/test/testutil/registry.go @@ -19,8 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -// SetupInMemoryRegistryDynamic starts an in-memory registry on an automatically-allocated port -// and returns the address. Use this when tests do not need a well-known port. +// SetupInMemoryRegistryDynamic starts an in-memory registry on an auto-allocated port. func SetupInMemoryRegistryDynamic(ctx context.Context, t *testing.T) string { t.Helper() port, err := helpers.GetAvailablePort() From 96e528605699d9aa18ecbba5c59209501ea41a43 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Tue, 5 May 2026 12:33:22 -0400 Subject: [PATCH 03/27] improve oci testing Signed-off-by: Austin Abro --- src/internal/agent/hooks/common_test.go | 28 ++++++++----------- src/internal/agent/hooks/flux-ocirepo_test.go | 5 ++-- src/pkg/images/push_test.go | 7 +---- src/pkg/packager/publish_test.go | 13 ++------- 4 files changed, 18 insertions(+), 35 deletions(-) diff --git a/src/internal/agent/hooks/common_test.go b/src/internal/agent/hooks/common_test.go index f29c08fc21..34fd8ea157 100644 --- a/src/internal/agent/hooks/common_test.go +++ b/src/internal/agent/hooks/common_test.go @@ -16,7 +16,6 @@ import ( "testing" "time" - "github.com/defenseunicorns/pkg/helpers/v2" v1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/pkg/state" @@ -61,19 +60,17 @@ func populateLocalRegistry(ctx context.Context, t *testing.T, localURL string, a require.NoError(t, err) } -func setupRegistry(ctx context.Context, t *testing.T, port int, artifacts []transform.Image, copyOpts oras.CopyOptions) (string, error) { - localURL := testutil.SetupInMemoryRegistry(ctx, t, port) - +// populateLocalRegistries copies each artifact into the in-memory registry at registryURL. +func populateLocalRegistries(ctx context.Context, t *testing.T, registryURL string, artifacts []transform.Image, copyOpts oras.CopyOptions) { + t.Helper() for _, art := range artifacts { - populateLocalRegistry(ctx, t, localURL, art, copyOpts) + populateLocalRegistry(ctx, t, registryURL, art, copyOpts) } - - return localURL, nil } type mediaTypeTest struct { name string - image string + relRef string expected string artifact []transform.Image Opts oras.CopyOptions @@ -81,8 +78,6 @@ type mediaTypeTest struct { func TestConfigMediaTypes(t *testing.T) { t.Parallel() - port, err := helpers.GetAvailablePort() - require.NoError(t, err) linuxAmd64Opts := oras.DefaultCopyOptions linuxAmd64Opts.WithTargetPlatform(&v1.Platform{ @@ -95,7 +90,7 @@ func TestConfigMediaTypes(t *testing.T) { // https://oci.dag.dev/?image=ghcr.io%2Fstefanprodan%2Fmanifests%2Fpodinfo%3A6.9.0 name: "flux manifest", expected: "application/vnd.cncf.flux.config.v1+json", - image: fmt.Sprintf("localhost:%d/stefanprodan/manifests/podinfo:6.9.0-zarf-2823281104", port), + relRef: "stefanprodan/manifests/podinfo:6.9.0-zarf-2823281104", Opts: oras.DefaultCopyOptions, artifact: []transform.Image{ { @@ -109,7 +104,7 @@ func TestConfigMediaTypes(t *testing.T) { // https://oci.dag.dev/?image=ghcr.io%2Fstefanprodan%2Fcharts%2Fpodinfo%3A6.9.0 name: "helm chart manifest", expected: "application/vnd.cncf.helm.config.v1+json", - image: fmt.Sprintf("localhost:%d/stefanprodan/charts/podinfo:6.9.0", port), + relRef: "stefanprodan/charts/podinfo:6.9.0", Opts: oras.DefaultCopyOptions, artifact: []transform.Image{ { @@ -120,10 +115,9 @@ func TestConfigMediaTypes(t *testing.T) { }, }, { - // name: "docker image manifest", expected: "application/vnd.oci.image.config.v1+json", - image: fmt.Sprintf("localhost:%d/zarf-dev/images/hello-world:latest", port), + relRef: "zarf-dev/images/hello-world:latest", Opts: linuxAmd64Opts, artifact: []transform.Image{ { @@ -139,11 +133,11 @@ func TestConfigMediaTypes(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) - url, err := setupRegistry(ctx, t, port, tt.artifact, tt.Opts) - require.NoError(t, err) + url := testutil.SetupInMemoryRegistryDynamic(ctx, t) + populateLocalRegistries(ctx, t, url, tt.artifact, tt.Opts) s := &state.State{RegistryInfo: state.RegistryInfo{Address: url}} - mediaType, err := getManifestConfigMediaType(ctx, s, orasRetry.DefaultClient.Transport, tt.image) + mediaType, err := getManifestConfigMediaType(ctx, s, orasRetry.DefaultClient.Transport, fmt.Sprintf("%s/%s", url, tt.relRef)) require.NoError(t, err) require.Equal(t, tt.expected, mediaType) }) diff --git a/src/internal/agent/hooks/flux-ocirepo_test.go b/src/internal/agent/hooks/flux-ocirepo_test.go index 3966d532dc..18b554c7e5 100644 --- a/src/internal/agent/hooks/flux-ocirepo_test.go +++ b/src/internal/agent/hooks/flux-ocirepo_test.go @@ -19,6 +19,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/cluster" "github.com/zarf-dev/zarf/src/pkg/state" "github.com/zarf-dev/zarf/src/pkg/transform" + "github.com/zarf-dev/zarf/src/test/testutil" v1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -596,8 +597,8 @@ func TestFluxOCIMutationWebhook(t *testing.T) { } ctx := context.Background() - _, err = setupRegistry(ctx, t, port, artifacts, oras.DefaultCopyOptions) - require.NoError(t, err) + url := testutil.SetupInMemoryRegistry(ctx, t, port) + populateLocalRegistries(ctx, t, url, artifacts, oras.DefaultCopyOptions) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/src/pkg/images/push_test.go b/src/pkg/images/push_test.go index 87a96d567b..b864a4243f 100644 --- a/src/pkg/images/push_test.go +++ b/src/pkg/images/push_test.go @@ -9,7 +9,6 @@ import ( "fmt" "testing" - "github.com/defenseunicorns/pkg/helpers/v2" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/pkg/state" "github.com/zarf-dev/zarf/src/pkg/transform" @@ -73,10 +72,7 @@ func TestPush(t *testing.T) { require.NoError(t, saveIndexToOCILayout(tc.SourceDirectory, idx)) }() ctx := testutil.TestContext(t) - // setup in memory registry - port, err := helpers.GetAvailablePort() - require.NoError(t, err) - address := testutil.SetupInMemoryRegistry(ctx, t, port) + address := testutil.SetupInMemoryRegistryDynamic(ctx, t) if tc.namespace != "" { address = fmt.Sprintf("%s/%s", address, tc.namespace) } @@ -84,7 +80,6 @@ func TestPush(t *testing.T) { regInfo := state.RegistryInfo{ Address: address, } - require.NoError(t, err) for _, name := range tc.imageNames { ref, err := transform.ParseImageRef(name) diff --git a/src/pkg/packager/publish_test.go b/src/pkg/packager/publish_test.go index eb4d3c2dc3..9ccc5bb395 100644 --- a/src/pkg/packager/publish_test.go +++ b/src/pkg/packager/publish_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "testing" - "github.com/defenseunicorns/pkg/helpers/v2" "github.com/defenseunicorns/pkg/oci" goyaml "github.com/goccy/go-yaml" "github.com/stretchr/testify/require" @@ -56,22 +55,16 @@ func pullFromRemote(ctx context.Context, t *testing.T, packageRef string, archit } func createRegistry(ctx context.Context, t *testing.T) registry.Reference { - // Setup destination registry - dstPort, err := helpers.GetAvailablePort() - require.NoError(t, err) - dstRegistryURL := testutil.SetupInMemoryRegistry(ctx, t, dstPort) - dstRegistryRef := registry.Reference{ - Registry: dstRegistryURL, + return registry.Reference{ + Registry: testutil.SetupInMemoryRegistryDynamic(ctx, t), Repository: "my-namespace", } - - return dstRegistryRef } func TestPublishError(t *testing.T) { ctx := context.Background() - registryURL := testutil.SetupInMemoryRegistry(ctx, t, 5000) + registryURL := testutil.SetupInMemoryRegistryDynamic(ctx, t) defaultRef := registry.Reference{ Registry: registryURL, Repository: "my-namespace", From a28bb7f2b0e25d9fe0bdb0c07b9cdfa74cac207b Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Tue, 5 May 2026 15:16:39 -0400 Subject: [PATCH 04/27] better naming Signed-off-by: Austin Abro --- src/internal/agent/hooks/common_test.go | 9 ++++----- src/internal/agent/hooks/flux-ocirepo_test.go | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/internal/agent/hooks/common_test.go b/src/internal/agent/hooks/common_test.go index 34fd8ea157..e147341359 100644 --- a/src/internal/agent/hooks/common_test.go +++ b/src/internal/agent/hooks/common_test.go @@ -35,7 +35,7 @@ const ( maxAttemptsFactor = 2 ) -func populateLocalRegistry(ctx context.Context, t *testing.T, localURL string, artifact transform.Image, copyOpts oras.CopyOptions) { +func pushToRegistry(ctx context.Context, t *testing.T, localURL string, artifact transform.Image, copyOpts oras.CopyOptions) { localReg, err := remote.NewRegistry(localURL) require.NoError(t, err) @@ -60,11 +60,10 @@ func populateLocalRegistry(ctx context.Context, t *testing.T, localURL string, a require.NoError(t, err) } -// populateLocalRegistries copies each artifact into the in-memory registry at registryURL. -func populateLocalRegistries(ctx context.Context, t *testing.T, registryURL string, artifacts []transform.Image, copyOpts oras.CopyOptions) { +func populateRegistry(ctx context.Context, t *testing.T, registryURL string, artifacts []transform.Image, copyOpts oras.CopyOptions) { t.Helper() for _, art := range artifacts { - populateLocalRegistry(ctx, t, registryURL, art, copyOpts) + pushToRegistry(ctx, t, registryURL, art, copyOpts) } } @@ -134,7 +133,7 @@ func TestConfigMediaTypes(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) url := testutil.SetupInMemoryRegistryDynamic(ctx, t) - populateLocalRegistries(ctx, t, url, tt.artifact, tt.Opts) + populateRegistry(ctx, t, url, tt.artifact, tt.Opts) s := &state.State{RegistryInfo: state.RegistryInfo{Address: url}} mediaType, err := getManifestConfigMediaType(ctx, s, orasRetry.DefaultClient.Transport, fmt.Sprintf("%s/%s", url, tt.relRef)) diff --git a/src/internal/agent/hooks/flux-ocirepo_test.go b/src/internal/agent/hooks/flux-ocirepo_test.go index 18b554c7e5..73dd1c7622 100644 --- a/src/internal/agent/hooks/flux-ocirepo_test.go +++ b/src/internal/agent/hooks/flux-ocirepo_test.go @@ -598,7 +598,7 @@ func TestFluxOCIMutationWebhook(t *testing.T) { ctx := context.Background() url := testutil.SetupInMemoryRegistry(ctx, t, port) - populateLocalRegistries(ctx, t, url, artifacts, oras.DefaultCopyOptions) + populateRegistry(ctx, t, url, artifacts, oras.DefaultCopyOptions) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 8bcd1916dd133a1a03f209f15a89cc22f5b71452 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Wed, 6 May 2026 08:25:13 -0400 Subject: [PATCH 05/27] remove testadata Signed-off-by: Austin Abro --- src/pkg/zoci/testdata/basic/README.md | 1 - src/pkg/zoci/testdata/basic/pod.yaml | 16 ---------------- src/pkg/zoci/testdata/basic/zarf.yaml | 21 --------------------- 3 files changed, 38 deletions(-) delete mode 100644 src/pkg/zoci/testdata/basic/README.md delete mode 100644 src/pkg/zoci/testdata/basic/pod.yaml delete mode 100644 src/pkg/zoci/testdata/basic/zarf.yaml diff --git a/src/pkg/zoci/testdata/basic/README.md b/src/pkg/zoci/testdata/basic/README.md deleted file mode 100644 index 52fde55895..0000000000 --- a/src/pkg/zoci/testdata/basic/README.md +++ /dev/null @@ -1 +0,0 @@ -# Basic Package Documentation diff --git a/src/pkg/zoci/testdata/basic/pod.yaml b/src/pkg/zoci/testdata/basic/pod.yaml deleted file mode 100644 index d3d28e2661..0000000000 --- a/src/pkg/zoci/testdata/basic/pod.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - creationTimestamp: null - labels: - run: test-pod - name: test-pod -spec: - containers: - - image: ghcr.io/zarf-dev/images/alpine:3.21.3 - name: test-pod - command: ["sleep", "3600"] - resources: {} - dnsPolicy: ClusterFirst - restartPolicy: Always -status: {} diff --git a/src/pkg/zoci/testdata/basic/zarf.yaml b/src/pkg/zoci/testdata/basic/zarf.yaml deleted file mode 100644 index b24aba279f..0000000000 --- a/src/pkg/zoci/testdata/basic/zarf.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/zarf-dev/zarf/v0.54.0/zarf.schema.json -kind: ZarfPackageConfig - -metadata: - name: basic-pod - version: 0.0.1 - architecture: amd64 - -documentation: - readme: README.md - -components: - - name: alpine - required: true - manifests: - - name: alpine - namespace: test - files: - - pod.yaml - images: - - ghcr.io/zarf-dev/images/alpine:3.21.3 From c69053bf8b30c6dc59e902cf710975d50335e726 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Wed, 6 May 2026 08:29:22 -0400 Subject: [PATCH 06/27] inner looop assignment no longer needed Signed-off-by: Austin Abro --- src/internal/agent/hooks/argocd-applicationset_test.go | 1 - src/pkg/images/pull_test.go | 2 -- src/test/testutil/ociregistry.go | 6 ++---- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/internal/agent/hooks/argocd-applicationset_test.go b/src/internal/agent/hooks/argocd-applicationset_test.go index 080c2d2749..9eb589f063 100644 --- a/src/internal/agent/hooks/argocd-applicationset_test.go +++ b/src/internal/agent/hooks/argocd-applicationset_test.go @@ -96,7 +96,6 @@ func TestArgoAppSetWebhook(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() rr := sendAdmissionRequest(t, tt.admissionReq, handler) diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 4cb743d772..a667b741d9 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -60,7 +60,6 @@ func TestCheckForIndex(t *testing.T) { ociChildren := make([]ocispec.Descriptor, 0, len(platforms)) for _, p := range platforms { desc := testutil.PushSinglePlatformImage(ctx, t, ociRepo, p.Architecture) - p := p desc.Platform = &p ociChildren = append(ociChildren, desc) } @@ -71,7 +70,6 @@ func TestCheckForIndex(t *testing.T) { dockerChildren := make([]ocispec.Descriptor, 0, len(platforms)) for _, p := range platforms { desc := testutil.PushSinglePlatformImage(ctx, t, dockerRepo, p.Architecture) - p := p desc.Platform = &p dockerChildren = append(dockerChildren, desc) } diff --git a/src/test/testutil/ociregistry.go b/src/test/testutil/ociregistry.go index 0246827982..f43b2a7ab4 100644 --- a/src/test/testutil/ociregistry.go +++ b/src/test/testutil/ociregistry.go @@ -121,8 +121,7 @@ func PushMultiArchIndex(ctx context.Context, t *testing.T, repoRef, tag string, children := make([]ocispec.Descriptor, 0, len(platforms)) for _, platform := range platforms { desc := PushSinglePlatformImage(ctx, t, repo, platform.Architecture) - p := platform - desc.Platform = &p + desc.Platform = &platform children = append(children, desc) } idx := PushIndex(ctx, t, repo, children) @@ -138,8 +137,7 @@ func PushNestedIndex(ctx context.Context, t *testing.T, repoRef, tag string, pla inner := make([]ocispec.Descriptor, 0, len(platforms)) for _, platform := range platforms { desc := PushSinglePlatformImage(ctx, t, repo, platform.Architecture) - p := platform - desc.Platform = &p + desc.Platform = &platform inner = append(inner, desc) } innerIdx := PushIndex(ctx, t, repo, inner) From 4fa0b0521fb7d45e5834deecb5bc37af4eccf829 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Wed, 6 May 2026 11:54:38 -0400 Subject: [PATCH 07/27] WIP Signed-off-by: Austin Abro --- src/pkg/feature/feature.go | 16 +- src/pkg/images/common.go | 83 ++++++++- src/pkg/images/pull.go | 66 +++---- src/pkg/images/pull_test.go | 16 +- src/pkg/images/push.go | 44 ++--- src/pkg/images/push_test.go | 1 - src/pkg/images/unpack.go | 49 ++---- src/pkg/images/unpack_test.go | 28 +-- src/pkg/packager/deploy.go | 1 - src/pkg/packager/layout/assemble.go | 49 +++++- src/pkg/packager/layout/sbom.go | 164 +++++++++++++++--- src/pkg/packager/mirror.go | 1 - src/test/e2e/48_multi_platform_image_test.go | 110 ++++++++++++ .../packages/48-multi-platform-image/pod.yaml | 14 ++ .../48-multi-platform-image/zarf.yaml | 17 ++ 15 files changed, 514 insertions(+), 145 deletions(-) create mode 100644 src/test/e2e/48_multi_platform_image_test.go create mode 100644 src/test/packages/48-multi-platform-image/pod.yaml create mode 100644 src/test/packages/48-multi-platform-image/zarf.yaml diff --git a/src/pkg/feature/feature.go b/src/pkg/feature/feature.go index 7fe52ccd63..ec7bd52511 100644 --- a/src/pkg/feature/feature.go +++ b/src/pkg/feature/feature.go @@ -203,10 +203,11 @@ func featuresToMap(fs []Feature) map[Name]Feature { // List of feature names const ( // AxolotlMode declares the "axolotl-mode" feature - AxolotlMode Name = "axolotl-mode" - RegistryProxy Name = "registry-proxy" - Values Name = "values" - BundleSignature Name = "bundle-signature" + AxolotlMode Name = "axolotl-mode" + RegistryProxy Name = "registry-proxy" + Values Name = "values" + BundleSignature Name = "bundle-signature" + MultiPlatformImages Name = "multi-platform-images" ) func init() { @@ -251,6 +252,13 @@ func init() { Since: "v0.72.0", Stage: Alpha, }, + { + Name: MultiPlatformImages, + Description: "Allows pinning images by an index digest. Every manifest under the index will be used", + Enabled: false, + Since: "v0.76.0", + Stage: Alpha, + }, } err := setDefault(features) diff --git a/src/pkg/images/common.go b/src/pkg/images/common.go index ff479466df..f62c2b1d27 100644 --- a/src/pkg/images/common.go +++ b/src/pkg/images/common.go @@ -21,6 +21,7 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/state" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/retry" ) @@ -119,7 +120,8 @@ func ShouldUsePlainHTTP(ctx context.Context, registryURL string, client *auth.Cl return true, nil } -func isManifest(mediaType string) bool { +// IsManifest reports whether the media type represents an OCI manifest. +func IsManifest(mediaType string) bool { switch mediaType { case ocispec.MediaTypeImageManifest, DockerMediaTypeManifest: return true @@ -127,7 +129,8 @@ func isManifest(mediaType string) bool { return false } -func isIndex(mediaType string) bool { +// IsIndex reports whether the media type represents an OCI image index. +func IsIndex(mediaType string) bool { switch mediaType { case ocispec.MediaTypeImageIndex, DockerMediaTypeManifestList: return true @@ -214,12 +217,80 @@ func WithPushAuth(ri state.RegistryInfo) crane.Option { return WithBasicAuth(ri.PushUsername, ri.PushPassword) } -func getSizeOfImage(manifestDesc ocispec.Descriptor, manifest ocispec.Manifest) int64 { - var totalSize int64 - totalSize += manifestDesc.Size +// formatPlatform renders an ocispec.Platform as "arch[/variant]". Empty input returns "". +func formatPlatform(p *ocispec.Platform) string { + if p == nil || p.Architecture == "" { + return "" + } + s := p.Architecture + if p.Variant != "" { + s += "/" + p.Variant + } + return s +} + +// getSizeOfManifest returns the total byte size of a manifest plus its config and layers. +func getSizeOfManifest(manifestDesc ocispec.Descriptor, manifestBytes []byte) (int64, error) { + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return 0, fmt.Errorf("unable to unmarshal manifest %s: %w", manifestDesc.Digest, err) + } + totalSize := manifestDesc.Size for _, layer := range manifest.Layers { totalSize += layer.Size } totalSize += manifest.Config.Size - return totalSize + return totalSize, nil +} + +// inspectIndex walks an OCI image index (recursing into nested indexes) and returns the total +// byte size of every referenced blob and one "arch[/variant]" string per leaf manifest. +func inspectIndex(ctx context.Context, fetcher content.Fetcher, indexDesc ocispec.Descriptor, indexBytes []byte) (int64, []string, error) { + var idx ocispec.Index + if err := json.Unmarshal(indexBytes, &idx); err != nil { + return 0, nil, fmt.Errorf("unable to unmarshal index: %w", err) + } + childSize, platforms, err := sumManifestsSize(ctx, fetcher, idx.Manifests) + if err != nil { + return 0, nil, err + } + return indexDesc.Size + childSize, platforms, nil +} + +// sumManifestsSize walks each descriptor (recursing into nested indexes) and totals up the byte +// size of every referenced blob plus one "arch[/variant]" string per leaf manifest. +func sumManifestsSize(ctx context.Context, fetcher content.Fetcher, manifests []ocispec.Descriptor) (int64, []string, error) { + var totalSize int64 + var platforms []string + for _, child := range manifests { + switch { + case IsIndex(child.MediaType): + b, err := content.FetchAll(ctx, fetcher, child) + if err != nil { + return 0, nil, fmt.Errorf("failed to fetch nested index %s: %w", child.Digest, err) + } + size, childPlatforms, err := inspectIndex(ctx, fetcher, child, b) + if err != nil { + return 0, nil, err + } + totalSize += size + platforms = append(platforms, childPlatforms...) + case IsManifest(child.MediaType): + b, err := content.FetchAll(ctx, fetcher, child) + if err != nil { + return 0, nil, fmt.Errorf("failed to fetch child manifest %s: %w", child.Digest, err) + } + size, err := getSizeOfManifest(child, b) + if err != nil { + return 0, nil, err + } + totalSize += size + if s := formatPlatform(child.Platform); s != "" { + platforms = append(platforms, s) + } + default: + totalSize += child.Size + } + } + return totalSize, platforms, nil } diff --git a/src/pkg/images/pull.go b/src/pkg/images/pull.go index c7e1da8a08..46eca2fab1 100644 --- a/src/pkg/images/pull.go +++ b/src/pkg/images/pull.go @@ -37,6 +37,7 @@ import ( orasCache "github.com/defenseunicorns/pkg/oci/cache" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/internal/dns" + "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" orasRemote "oras.land/oras-go/v2/registry/remote" @@ -68,7 +69,7 @@ type imageWithOverride struct { } // Pull pulls all images to the destination directory. -func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory string, opts PullOptions) ([]ImageWithManifest, error) { +func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory string, opts PullOptions) ([]PulledImage, error) { if len(imageList) == 0 { return nil, fmt.Errorf("image list is required") } @@ -154,7 +155,7 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory // TODO: in the future we could support Windows images OS: "linux", } - imagesWithManifests := []ImageWithManifest{} + pulledImages := []PulledImage{} imagesInfo := []imagePullInfo{} dockerFallBackImages := []imageWithOverride{} var imageListLock sync.Mutex @@ -203,8 +204,13 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory return nil } - // If the image sha points to an index then error - if image.original.Digest != "" && isIndex(desc.MediaType) { + // When the ref is digest-pinned to an index, preserve the entire index intact under + // the multi-platform-images feature flag so the original digest round-trips. Without + // the flag (or for tag-resolved indexes) we keep the existing platform-filter path + // and the existing digest+index error. + // FIXME: I'm not sure if the preserveIndex framing is quite right + preserveIndex := image.original.Digest != "" && IsIndex(desc.MediaType) && feature.IsEnabled(feature.MultiPlatformImages) + if image.original.Digest != "" && IsIndex(desc.MediaType) && !preserveIndex { // Both index types can be marshalled into an ocispec.Index // https://github.com/oras-project/oras-go/blob/853e0125ccad32ff691e4ed70e156c7619021bfd/internal/manifestutil/parser.go#L55 var idx ocispec.Index @@ -215,7 +221,7 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory } // If a manifest was returned from FetchBytes, either it's a tag with only one image or it's a non container image // If it's not a manifest then we received an index and need to pull the manifest by platform - if !isManifest(desc.MediaType) { + if !IsManifest(desc.MediaType) && !preserveIndex { fetchOpts.FetchOptions.TargetPlatform = platform desc, b, err = oras.FetchBytes(ectx, repo, image.overridden.Reference, fetchOpts) if err != nil { @@ -223,17 +229,22 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory } } - // extra validation before we marshall, this should never be true - if !isManifest(desc.MediaType) { + if !preserveIndex && !IsManifest(desc.MediaType) { return fmt.Errorf("received unexpected mediatype %s", desc.MediaType) } - // Both oci and docker manifest types can be marshalled into a manifest - // https://github.com/oras-project/oras-go/blob/853e0125ccad32ff691e4ed70e156c7619021bfd/internal/manifestutil/parser.go#L37 - var manifest ocispec.Manifest - if err := json.Unmarshal(b, &manifest); err != nil { - return err + + var size int64 + if preserveIndex { + size, _, err = inspectIndex(ectx, repo, desc, b) + if err != nil { + return fmt.Errorf("failed to inspect index %s: %w", image.overridden.Reference, err) + } + } else { + size, err = getSizeOfManifest(desc, b) + if err != nil { + return err + } } - size := getSizeOfImage(desc, manifest) imageListLock.Lock() defer imageListLock.Unlock() imagesInfo = append(imagesInfo, imagePullInfo{ @@ -242,11 +253,8 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory byteSize: size, manifestDesc: desc, }) - imagesWithManifests = append(imagesWithManifests, ImageWithManifest{ - Image: image.original, - Manifest: manifest, - }) - l.Debug("pulled manifest for image", "name", image.overridden.Reference) + pulledImages = append(pulledImages, PulledImage{Image: image.original}) + l.Debug("pulled image", "name", image.overridden.Reference) return nil }) } @@ -267,7 +275,7 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory if err != nil { return nil, fmt.Errorf("failed to pull images from docker: %w", err) } - imagesWithManifests = append(imagesWithManifests, daemonImagesWithManifests...) + pulledImages = append(pulledImages, daemonImagesWithManifests...) } for _, imageInfo := range imagesInfo { @@ -279,7 +287,7 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory l.Info("done pulling images", "count", imageCount, "duration", time.Since(pullStart).Round(time.Millisecond*100)) - return imagesWithManifests, nil + return pulledImages, nil } func constructIndexError(idx ocispec.Index, image transform.Image) error { @@ -317,9 +325,9 @@ func getDockerEndpointHost() (string, error) { return endpoint.Host, nil } -func pullFromDockerDaemon(ctx context.Context, daemonImages []imageWithOverride, dst *oci.Store, arch string, concurrency int) (_ []ImageWithManifest, err error) { +func pullFromDockerDaemon(ctx context.Context, daemonImages []imageWithOverride, dst *oci.Store, arch string, concurrency int) (_ []PulledImage, err error) { l := logger.From(ctx) - imagesWithManifests := []ImageWithManifest{} + pulledImages := []PulledImage{} dockerEndPointHost, err := getDockerEndpointHost() if err != nil { return nil, err @@ -414,18 +422,14 @@ func pullFromDockerDaemon(ctx context.Context, daemonImages []imageWithOverride, if err != nil { return fmt.Errorf("failed to get manifest from docker image source: %w", err) } - if !isManifest(desc.MediaType) { + if !IsManifest(desc.MediaType) { return fmt.Errorf("expected to find image manifest instead found %s", desc.MediaType) } - var manifest ocispec.Manifest - if err := json.Unmarshal(b, &manifest); err != nil { + pulledImages = append(pulledImages, PulledImage{Image: daemonImage.original}) + size, err := getSizeOfManifest(desc, b) + if err != nil { return err } - imagesWithManifests = append(imagesWithManifests, ImageWithManifest{ - Image: daemonImage.original, - Manifest: manifest, - }) - size := getSizeOfImage(desc, manifest) l.Info("pulling image from docker daemon", "name", daemonImage.overridden.Reference, "size", utils.ByteFormat(float64(size), 2)) copyOpts := oras.DefaultCopyOptions copyOpts.WithTargetPlatform(platform) @@ -441,7 +445,7 @@ func pullFromDockerDaemon(ctx context.Context, daemonImages []imageWithOverride, } } - return imagesWithManifests, nil + return pulledImages, nil } func orasSave(ctx context.Context, imageInfo imagePullInfo, opts PullOptions, dst *oci.Store, client *auth.Client) error { diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index a667b741d9..27a62e64ba 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -206,12 +206,13 @@ func TestPull(t *testing.T) { PlainHTTP: true, } - imageManifests, err := Pull(ctx, images, destDir, opts) + pulled, err := Pull(ctx, images, destDir, opts) if tc.expectErr { require.Error(t, err) return } require.NoError(t, err) + require.Len(t, pulled, len(images)) idx, err := getIndexFromOCILayout(filepath.Join(destDir)) require.NoError(t, err) @@ -229,8 +230,17 @@ func TestPull(t *testing.T) { } require.ElementsMatch(t, expectedImageAnnotations, actualImageAnnotations) - for _, imageWithManifest := range imageManifests { - for _, layer := range imageWithManifest.Manifest.Layers { + // Walk the layout's index to verify every manifest's layers landed on disk. + for _, m := range idx.Manifests { + if !IsManifest(m.MediaType) { + continue + } + manifestPath := filepath.Join(destDir, "blobs", "sha256", m.Digest.Hex()) + body, err := os.ReadFile(manifestPath) + require.NoError(t, err) + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(body, &manifest)) + for _, layer := range manifest.Layers { require.FileExists(t, filepath.Join(destDir, fmt.Sprintf("blobs/sha256/%s", layer.Digest.Hex()))) require.FileExists(t, filepath.Join(cacheDir, fmt.Sprintf("blobs/sha256/%s", layer.Digest.Hex()))) } diff --git a/src/pkg/images/push.go b/src/pkg/images/push.go index 26c27fc1d1..a353034323 100644 --- a/src/pkg/images/push.go +++ b/src/pkg/images/push.go @@ -6,7 +6,6 @@ package images import ( "context" - "encoding/json" "fmt" "net/http" "strings" @@ -35,7 +34,6 @@ const defaultRetries = 3 type PushOptions struct { OCIConcurrency int NoChecksum bool - Arch string Retries int PlainHTTP bool InsecureSkipTLSVerify bool @@ -152,16 +150,12 @@ func Push(ctx context.Context, imageList []transform.Image, sourceDirectory stri if err != nil { return fmt.Errorf("failed to parse ref %s: %w", dstName, err) } - defaultPlatform := &ocispec.Platform{ - Architecture: cfg.Arch, - OS: "linux", - } if tunnel != nil { return tunnel.Wrap(func() error { - return copyImage(ctx, src, remoteRepo, srcName, dstName, ociConcurrency, defaultPlatform) + return copyImage(ctx, src, remoteRepo, srcName, dstName, ociConcurrency) }) } - return copyImage(ctx, src, remoteRepo, srcName, dstName, ociConcurrency, defaultPlatform) + return copyImage(ctx, src, remoteRepo, srcName, dstName, ociConcurrency) } pushed := []string{} // Delete the images that were already successfully pushed so that they aren't attempted on the next retry @@ -237,10 +231,10 @@ func addRefNameAnnotationToImages(ociLayoutDirectory string) error { return err } // Crane sets ocispec.AnnotationBaseImageName instead of ocispec.AnnotationRefName - // which ORAS uses to find images. We do this to be backwards compatible with packages built with Crane + // which ORAS uses to find images. We do this to be backwards compatible with packages built with Crane. var correctedManifests []ocispec.Descriptor for _, manifest := range idx.Manifests { - if manifest.Annotations[ocispec.AnnotationRefName] == "" { + if manifest.Annotations[ocispec.AnnotationRefName] == "" && manifest.Annotations[ocispec.AnnotationBaseImageName] != "" { manifest.Annotations[ocispec.AnnotationRefName] = manifest.Annotations[ocispec.AnnotationBaseImageName] } correctedManifests = append(correctedManifests, manifest) @@ -253,33 +247,29 @@ func addRefNameAnnotationToImages(ociLayoutDirectory string) error { return nil } -func copyImage(ctx context.Context, src *oci.Store, remote oras.Target, srcName string, dstName string, concurrency int, defaultPlatform *ocispec.Platform) error { - // Assume no platform to start as it can be nil in non container image situations +func copyImage(ctx context.Context, src *oci.Store, remote oras.Target, srcName string, dstName string, concurrency int) error { fetchOpts := oras.DefaultFetchBytesOptions desc, b, err := oras.FetchBytes(ctx, src, srcName, fetchOpts) if err != nil { return fmt.Errorf("failed to resolve image: %s: %w", srcName, err) } - // If an index is pulled we should try pulling with the default platform - if isIndex(desc.MediaType) { - fetchOpts.TargetPlatform = defaultPlatform - desc, b, err = oras.FetchBytes(ctx, src, srcName, fetchOpts) + var size int64 + switch { + case IsIndex(desc.MediaType): + size, _, err = inspectIndex(ctx, src, desc, b) if err != nil { - return fmt.Errorf("failed to resolve image %s with architecture %s: %w", srcName, defaultPlatform.Architecture, err) + return fmt.Errorf("failed to inspect index %s: %w", srcName, err) } + case IsManifest(desc.MediaType): + size, err = getSizeOfManifest(desc, b) + if err != nil { + return err + } + default: + return fmt.Errorf("expected OCI manifest or index got %s", desc.MediaType) } - if !isManifest(desc.MediaType) { - return fmt.Errorf("expected OCI manifest got %s", desc.MediaType) - } - - var manifest ocispec.Manifest - if err := json.Unmarshal(b, &manifest); err != nil { - return err - } - size := getSizeOfImage(desc, manifest) - copyOpts := oras.DefaultCopyOptions copyOpts.Concurrency = concurrency diff --git a/src/pkg/images/push_test.go b/src/pkg/images/push_test.go index b864a4243f..5bd820c1da 100644 --- a/src/pkg/images/push_test.go +++ b/src/pkg/images/push_test.go @@ -90,7 +90,6 @@ func TestPush(t *testing.T) { // push images to registry opts := PushOptions{ PlainHTTP: true, - Arch: "amd64", } err = Push(ctx, imageList, tc.SourceDirectory, regInfo, opts) diff --git a/src/pkg/images/unpack.go b/src/pkg/images/unpack.go index ef412702a0..e0d24ef8ae 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -6,7 +6,6 @@ package images import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -24,10 +23,9 @@ import ( "oras.land/oras-go/v2/content/oci" ) -// ImageWithManifest represents an image reference and its associated OCI manifest. -type ImageWithManifest struct { - Image transform.Image - Manifest ocispec.Manifest +// PulledImage describes an image that landed in the destination OCI layout. +type PulledImage struct { + Image transform.Image } const ( @@ -38,8 +36,8 @@ const ( ) // Unpack extracts an image tar and loads it into an OCI layout directory. -// It returns a list of ImageWithManifest for all images in the tar. -func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir string, arch string) (_ []ImageWithManifest, err error) { +// It returns a list of PulledImage for all images in the tar. +func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir string, arch string) (_ []PulledImage, err error) { if len(imageArchive.Images) == 0 { return nil, fmt.Errorf("images must be defined") } @@ -105,7 +103,7 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str requestedImages[ref.Reference] = false } - var imagesWithManifest []ImageWithManifest + var pulledImages []PulledImage var foundImages []string for _, manifestDesc := range srcIdx.Manifests { imageName := getRefFromManifest(manifestDesc) @@ -123,31 +121,19 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } requestedImages[manifestImg.Reference] = true - foundDesc, manifestData, err := oras.FetchBytes(ctx, srcStore, manifestDesc.Digest.String(), oras.DefaultFetchBytesOptions) + foundDesc, _, err := oras.FetchBytes(ctx, srcStore, manifestDesc.Digest.String(), oras.DefaultFetchBytesOptions) if err != nil { return nil, fmt.Errorf("failed to fetch manifest for %s: %w", imageName, err) } - // If an image index is returned, then grab the manifest at the specific platform, and set the platform for the later oras.Copy - var platform *ocispec.Platform - if foundDesc.MediaType == ocispec.MediaTypeImageIndex { - platform = &ocispec.Platform{ - Architecture: arch, - OS: "linux", - } - fbOptions := oras.DefaultFetchBytesOptions - fbOptions.TargetPlatform = platform - foundDesc, manifestData, err = oras.FetchBytes(ctx, srcStore, foundDesc.Digest.String(), fbOptions) - if err != nil { - return nil, fmt.Errorf("failed to fetch manifest for %s: %w", imageName, err) - } - } - - var ociManifest ocispec.Manifest - if err := json.Unmarshal(manifestData, &ociManifest); err != nil { - return nil, fmt.Errorf("failed to parse OCI manifest for %s: %w", imageName, err) - } logger.From(ctx).Info("pulling image from archive", "image", manifestImg.Reference, "archive", imageArchive.Path) + // Image-archive unpack is single-arch: when the archive holds an index, filter to the + // package architecture; single-manifest sources copy as-is. Index-sha preservation lives + // on the registry pull path (images.Pull), not here. + var platform *ocispec.Platform + if IsIndex(foundDesc.MediaType) { + platform = &ocispec.Platform{Architecture: arch, OS: "linux"} + } copyOpts := oras.DefaultCopyOptions copyOpts.WithTargetPlatform(platform) desc, err := oras.Copy(ctx, srcStore, manifestDesc.Digest.String(), dstStore, manifestImg.Reference, copyOpts) @@ -161,10 +147,7 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str return nil, fmt.Errorf("failed to tag image: %w", err) } - imagesWithManifest = append(imagesWithManifest, ImageWithManifest{ - Image: manifestImg, - Manifest: ociManifest, - }) + pulledImages = append(pulledImages, PulledImage{Image: manifestImg}) } explainErr := fmt.Sprintf("image references are determined by the inclusion of one of the following "+ @@ -175,7 +158,7 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } } - return imagesWithManifest, nil + return pulledImages, nil } // getRefFromManifest extracts the image reference from a manifest descriptor. diff --git a/src/pkg/images/unpack_test.go b/src/pkg/images/unpack_test.go index c73b448407..3aaa3c089f 100644 --- a/src/pkg/images/unpack_test.go +++ b/src/pkg/images/unpack_test.go @@ -5,7 +5,9 @@ package images import ( + "encoding/json" "errors" + "os" "path/filepath" "testing" @@ -141,15 +143,12 @@ func TestUnpackMultipleImages(t *testing.T) { } require.NoError(t, err) - imageMap := make(map[string]ImageWithManifest) + seen := make(map[string]bool) for _, img := range images { - imageMap[img.Image.Reference] = img + seen[img.Image.Reference] = true } - for _, ref := range tc.requestedImages { - img, found := imageMap[ref] - require.True(t, found) - require.NotEmpty(t, img.Manifest.Config.Digest) + require.True(t, seen[ref], "expected pulled image for %s", ref) } idx, err := getIndexFromOCILayout(dstDir) @@ -162,11 +161,18 @@ func TestUnpackMultipleImages(t *testing.T) { require.Contains(t, tc.requestedImages, imageName) } - // Verify all the required layers exist in the oci layout - for _, img := range images { - for _, layer := range img.Manifest.Layers { - layerBlobPath := filepath.Join(dstDir, "blobs", "sha256", layer.Digest.Hex()) - require.FileExists(t, layerBlobPath) + // Verify every manifest's layers landed on disk by re-reading from the layout. + for _, m := range idx.Manifests { + if !IsManifest(m.MediaType) { + continue + } + manifestPath := filepath.Join(dstDir, "blobs", "sha256", m.Digest.Hex()) + body, err := os.ReadFile(manifestPath) + require.NoError(t, err) + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(body, &manifest)) + for _, layer := range manifest.Layers { + require.FileExists(t, filepath.Join(dstDir, "blobs", "sha256", layer.Digest.Hex())) } } }) diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index b9c6477af7..8a11341663 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -486,7 +486,6 @@ func (d *deployer) deployComponent(ctx context.Context, pkgLayout *layout.Packag OCIConcurrency: opts.OCIConcurrency, PlainHTTP: opts.PlainHTTP, NoChecksum: noImgChecksum, - Arch: pkgLayout.Pkg.Build.Architecture, Retries: opts.Retries, InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify, Cluster: d.c, diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index e7e8bd2104..904dbe6950 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -8,6 +8,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -22,6 +23,7 @@ import ( "github.com/defenseunicorns/pkg/helpers/v2" goyaml "github.com/goccy/go-yaml" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/config/lang" @@ -111,7 +113,7 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } componentImages := []transform.Image{} - manifests := []images.ImageWithManifest{} + manifests := []images.PulledImage{} for _, component := range pkg.Components { for _, imageArchive := range component.ImageArchives { if !filepath.IsAbs(imageArchive.Path) { @@ -152,11 +154,10 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath manifests = append(manifests, imageManifests...) } - for _, manifest := range manifests { - ok := images.OnlyHasImageLayers(manifest.Manifest) - if ok { - sbomImageList = append(sbomImageList, manifest.Image) - } + for _, pulled := range manifests { + // Hand every pulled image to the SBOM step; the per-platform manifest filter (skip helm + // charts, etc.) lives in generateSBOM where each platform manifest is inspected directly. + sbomImageList = append(sbomImageList, pulled.Image) // Sort images index to make build reproducible. err = utils.SortImagesIndex(filepath.Join(buildPath, ImagesDir)) @@ -165,6 +166,19 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } } + // If the package layout preserves any image index (multi-platform-images flag at create time), + // stamp a version requirement so an older Zarf doesn't try to deploy it without index support. + hasIndex, err := imageLayoutHasIndex(filepath.Join(buildPath, ImagesDir)) + if err != nil { + return nil, fmt.Errorf("failed to inspect image layout: %w", err) + } + if hasIndex { + pkg.Build.VersionRequirements = append(pkg.Build.VersionRequirements, v1alpha1.VersionRequirement{ + Version: "v0.76.0", + Reason: "This package contains multi-platform images preserved by index digest, which require v0.76.0+ to deploy.", + }) + } + l.Info("composed components successfully") if !opts.SkipSBOM && pkg.IsSBOMAble() { @@ -1076,3 +1090,26 @@ func createDocumentationTar(pkg v1alpha1.ZarfPackage, packagePath, buildPath str return nil } + +// imageLayoutHasIndex reports whether any top-level entry in the OCI layout's index.json is an +// image index — i.e. the layout preserves a multi-platform image graph. +func imageLayoutHasIndex(imageDir string) (bool, error) { + idxPath := filepath.Join(imageDir, "index.json") + b, err := os.ReadFile(idxPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("failed to read %s: %w", idxPath, err) + } + var idx ocispec.Index + if err := json.Unmarshal(b, &idx); err != nil { + return false, fmt.Errorf("failed to parse %s: %w", idxPath, err) + } + for _, m := range idx.Manifests { + if images.IsIndex(m.MediaType) { + return true, nil + } + } + return false, nil +} diff --git a/src/pkg/packager/layout/sbom.go b/src/pkg/packager/layout/sbom.go index 32726dba0d..10c23707e0 100644 --- a/src/pkg/packager/layout/sbom.go +++ b/src/pkg/packager/layout/sbom.go @@ -31,10 +31,13 @@ import ( "github.com/anchore/syft/syft/source/stereoscopesource" "github.com/defenseunicorns/pkg/helpers/v2" v1 "github.com/google/go-containerregistry/pkg/v1" + clayout "github.com/google/go-containerregistry/pkg/v1/layout" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/config" "github.com/zarf-dev/zarf/src/pkg/archive" + "github.com/zarf-dev/zarf/src/pkg/images" "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" @@ -62,22 +65,44 @@ func generateSBOM(ctx context.Context, pkg v1alpha1.ZarfPackage, buildPath strin componentSBOMs = append(componentSBOMs, comp.Name) } } - jsonList, err := generateJSONList(componentSBOMs, images) - if err != nil { - return err + type imageSBOMTarget struct { + img v1.Image + identifier string } - + var targets []imageSBOMTarget for _, refInfo := range images { - img, err := utils.LoadOCIImage(filepath.Join(buildPath, string(ImagesDir)), refInfo) + platformImages, err := loadOCIImagePlatforms(filepath.Join(buildPath, string(ImagesDir)), refInfo) if err != nil { return fmt.Errorf("failed to load OCI image: %w", err) } - l.Info("creating image SBOM", "reference", refInfo.Reference) - b, err := createImageSBOM(ctx, cachePath, outputPath, img, refInfo.Reference) + for _, pi := range platformImages { + identifier := refInfo.Reference + if pi.platform != nil && pi.platform.Architecture != "" { + identifier = fmt.Sprintf("%s-%s-%s", refInfo.Reference, pi.platform.OS, pi.platform.Architecture) + if pi.platform.Variant != "" { + identifier = fmt.Sprintf("%s-%s", identifier, pi.platform.Variant) + } + } + targets = append(targets, imageSBOMTarget{img: pi.image, identifier: identifier}) + } + } + + identifiers := make([]string, 0, len(targets)) + for _, t := range targets { + identifiers = append(identifiers, t.identifier) + } + jsonList, err := generateJSONList(componentSBOMs, identifiers) + if err != nil { + return err + } + + for _, t := range targets { + l.Info("creating image SBOM", "reference", t.identifier) + b, err := createImageSBOM(ctx, cachePath, outputPath, t.img, t.identifier) if err != nil { return fmt.Errorf("failed to create image sbom: %w", err) } - err = createSBOMViewerAsset(outputPath, refInfo.Reference, b, jsonList) + err = createSBOMViewerAsset(outputPath, t.identifier, b, jsonList) if err != nil { return err } @@ -112,7 +137,7 @@ func generateSBOM(ctx context.Context, pkg v1alpha1.ZarfPackage, buildPath strin return nil } -func createImageSBOM(ctx context.Context, cachePath, outputPath string, img v1.Image, src string) ([]byte, error) { +func createImageSBOM(ctx context.Context, cachePath, outputPath string, img v1.Image, identifier string) ([]byte, error) { imageCachePath := filepath.Join(cachePath, ImagesDir) // This is a write cache @@ -120,18 +145,14 @@ func createImageSBOM(ctx context.Context, cachePath, outputPath string, img v1.I return nil, fmt.Errorf("failed to create image cache directory %s: %w", imageCachePath, err) } - refInfo, err := transform.ParseImageRef(src) - if err != nil { - return nil, fmt.Errorf("failed to create ref for image %s: %w", src, err) - } - syftImage := image.New(img, file.NewTempDirGenerator("zarf"), imageCachePath, image.WithTags(refInfo.Reference)) - err = syftImage.Read() + syftImage := image.New(img, file.NewTempDirGenerator("zarf"), imageCachePath, image.WithTags(identifier)) + err := syftImage.Read() if err != nil { return nil, err } cfg := getDefaultSyftConfig() syftSrc := stereoscopesource.New(syftImage, stereoscopesource.ImageConfig{ - Reference: refInfo.Reference, + Reference: identifier, }) sbom, err := syft.CreateSBOM(ctx, syftSrc, cfg) if err != nil { @@ -142,7 +163,7 @@ func createImageSBOM(ctx context.Context, cachePath, outputPath string, img v1.I return nil, err } - normalizedName := getNormalizedFileName(fmt.Sprintf("%s.json", refInfo.Reference)) + normalizedName := getNormalizedFileName(fmt.Sprintf("%s.json", identifier)) path := filepath.Join(outputPath, normalizedName) err = os.WriteFile(path, jsonData, 0o666) if err != nil { @@ -358,11 +379,10 @@ func getNormalizedFileName(identifier string) string { return transformRegex.ReplaceAllString(identifier, "_") } -func generateJSONList(components []string, imageList []transform.Image) ([]byte, error) { +func generateJSONList(components []string, imageIdentifiers []string) ([]byte, error) { var jsonList []string - for _, refInfo := range imageList { - normalized := getNormalizedFileName(refInfo.Reference) - jsonList = append(jsonList, normalized) + for _, id := range imageIdentifiers { + jsonList = append(jsonList, getNormalizedFileName(id)) } for _, k := range components { normalized := getNormalizedFileName(fmt.Sprintf("%s%s", componentPrefix, k)) @@ -377,3 +397,105 @@ func getDefaultSyftConfig() *syft.CreateSBOMConfig { cfg.ToolVersion = config.CLIVersion return cfg } + +// platformImage pairs a loaded image with the platform it targets. platform is nil for images +// stored as a single-platform manifest. +type platformImage struct { + image v1.Image + platform *v1.Platform +} + +// loadOCIImagePlatforms returns the v1.Images for refInfo. Single-platform images return one +// entry with a nil platform; image indexes return one entry per platform manifest. Non-container +// images (helm charts, cosign sigs) are skipped — returning an empty slice is not an error. +func loadOCIImagePlatforms(imgPath string, refInfo transform.Image) ([]platformImage, error) { + layoutPath := clayout.Path(imgPath) + imgIdx, err := layoutPath.ImageIndex() + if err != nil { + return nil, fmt.Errorf("failed to get image index: %w", err) + } + idxManifest, err := imgIdx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to get image manifest: %w", err) + } + + for _, manifest := range idxManifest.Manifests { + if manifest.Annotations[ocispec.AnnotationRefName] != refInfo.Reference { + continue + } + + if images.IsIndex(string(manifest.MediaType)) { + return collectPlatformImagesFromIndex(imgIdx, manifest.Digest, refInfo.Reference) + } + + img, err := layoutPath.Image(manifest.Digest) + if err != nil { + return nil, fmt.Errorf("failed to lookup image %s: %w", refInfo.Reference, err) + } + isContainer, err := imageHasOnlyContainerLayers(img) + if err != nil { + return nil, fmt.Errorf("failed to inspect manifest for %s: %w", refInfo.Reference, err) + } + if !isContainer { + return nil, nil + } + return []platformImage{{image: img}}, nil + } + + return nil, fmt.Errorf("unable to find image (%s) at the path (%s)", refInfo.Reference, imgPath) +} + +func imageHasOnlyContainerLayers(img v1.Image) (bool, error) { + raw, err := img.RawManifest() + if err != nil { + return false, err + } + var manifest ocispec.Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return false, err + } + return images.OnlyHasImageLayers(manifest), nil +} + +func collectPlatformImagesFromIndex(parent v1.ImageIndex, indexDigest v1.Hash, ref string) ([]platformImage, error) { + idx, err := parent.ImageIndex(indexDigest) + if err != nil { + return nil, fmt.Errorf("failed to load image index for %s: %w", ref, err) + } + manifest, err := idx.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to parse image index manifest for %s: %w", ref, err) + } + var platformImages []platformImage + for _, child := range manifest.Manifests { + switch { + case images.IsIndex(string(child.MediaType)): + nested, err := collectPlatformImagesFromIndex(idx, child.Digest, ref) + if err != nil { + return nil, err + } + platformImages = append(platformImages, nested...) + case images.IsManifest(string(child.MediaType)): + img, err := idx.Image(child.Digest) + if err != nil { + return nil, fmt.Errorf("failed to lookup platform image for %s: %w", ref, err) + } + rawManifest, err := img.RawManifest() + if err != nil { + return nil, fmt.Errorf("failed to read platform manifest for %s: %w", ref, err) + } + var childManifest ocispec.Manifest + if err := json.Unmarshal(rawManifest, &childManifest); err != nil { + return nil, fmt.Errorf("failed to parse platform manifest for %s: %w", ref, err) + } + if !images.OnlyHasImageLayers(childManifest) { + continue + } + platformImages = append(platformImages, platformImage{ + image: img, + platform: child.Platform, + }) + } + } + return platformImages, nil +} diff --git a/src/pkg/packager/mirror.go b/src/pkg/packager/mirror.go index 7d66209593..fe6c8dc7e5 100644 --- a/src/pkg/packager/mirror.go +++ b/src/pkg/packager/mirror.go @@ -64,7 +64,6 @@ func PushImagesToRegistry(ctx context.Context, pkgLayout *layout.PackageLayout, OCIConcurrency: opts.OCIConcurrency, PlainHTTP: opts.PlainHTTP, NoChecksum: opts.NoImageChecksum, - Arch: pkgLayout.Pkg.Build.Architecture, Retries: opts.Retries, InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify, Cluster: opts.Cluster, diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go new file mode 100644 index 0000000000..0cf9182db0 --- /dev/null +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" // used for docker test registry + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry" + + "github.com/zarf-dev/zarf/src/pkg/packager/layout" + "github.com/zarf-dev/zarf/src/test/testutil" +) + +// podinfoIndexDigest is the index digest of ghcr.io/stefanprodan/podinfo:6.4.0. +const podinfoIndexDigest = "sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8" + +const multiPlatformImagesFlag = "--features=\"multi-platform-images=true\"" + +// TestMultiPlatformIndexImage exercises the multi-platform-images feature flag end-to-end: +// create + publish + pull + deploy of a single-arch package whose only image is pinned by an +// index digest. The package layout must preserve the full upstream index, the per-platform +// SBOMs must exist, and the image must end up at the original digest in the destination registry. +func TestMultiPlatformIndexImage(t *testing.T) { + t.Log("E2E: index-sha image with multi-platform-images feature flag") + + pkgDefinitionPath := filepath.Join("src", "test", "packages", "48-multi-platform-image") + createDir := t.TempDir() + + stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm", multiPlatformImagesFlag) + require.NoError(t, err, stdOut, stdErr) + + createdPkgPath := filepath.Join(createDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") + require.FileExists(t, createdPkgPath) + + registryURL := testutil.SetupInMemoryRegistryDynamic(testutil.TestContext(t), t) + ref := registry.Reference{ + Registry: registryURL, + Repository: "multi-platform-image", + Reference: "0.0.1", + } + + stdOut, stdErr, err = e2e.Zarf(t, "package", "publish", createdPkgPath, "oci://"+registryURL, "--plain-http", multiPlatformImagesFlag) + require.NoError(t, err, stdOut, stdErr) + + pullDir := t.TempDir() + stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir, multiPlatformImagesFlag) + require.NoError(t, err, stdOut, stdErr) + + pulledPkgPath := filepath.Join(pullDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") + pkgLayout, err := layout.LoadFromTar(t.Context(), pulledPkgPath, layout.PackageLayoutOptions{}) + require.NoError(t, err) + + idxBytes, err := os.ReadFile(filepath.Join(pkgLayout.GetImageDirPath(), "index.json")) + require.NoError(t, err) + var idx ocispec.Index + require.NoError(t, json.Unmarshal(idxBytes, &idx)) + + digestedRoot := verifyPreservedIndex(t, pkgLayout, idx, podinfoIndexDigest) + require.Equal(t, podinfoIndexDigest, digestedRoot, "index-pinned image must keep its original index digest in the package layout") + + sbomDir := t.TempDir() + require.NoError(t, pkgLayout.GetSBOM(t.Context(), sbomDir)) + sbomEntries, err := os.ReadDir(sbomDir) + require.NoError(t, err) + count := 0 + for _, entry := range sbomEntries { + name := entry.Name() + if strings.HasSuffix(name, ".json") && strings.Contains(name, "podinfo_6.4.0") { + count++ + } + } + require.GreaterOrEqual(t, count, 2, "expected per-platform SBOMs for the digested multi-platform image") + + stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pulledPkgPath, "--confirm", "--skip-version-check", multiPlatformImagesFlag) + require.NoError(t, err, stdOut, stdErr) + t.Cleanup(func() { + _, _, err = e2e.Zarf(t, "package", "remove", "multi-platform-image", "--confirm", "--skip-version-check", multiPlatformImagesFlag) + require.NoError(t, err) + }) +} + +// verifyPreservedIndex finds the top-level index.json entry whose ref-name annotation matches +// imageSubstring, asserts it points at an OCI image index with multiple platform manifests +// stored on disk, and returns the underlying index digest. +func verifyPreservedIndex(t *testing.T, pkgLayout *layout.PackageLayout, topIdx ocispec.Index, imageSubstring string) string { + t.Helper() + for _, m := range topIdx.Manifests { + if !strings.Contains(m.Annotations[ocispec.AnnotationRefName], imageSubstring) { + continue + } + require.Equal(t, ocispec.MediaTypeImageIndex, m.MediaType, "image %s must be stored as an OCI index", imageSubstring) + blobPath := filepath.Join(pkgLayout.GetImageDirPath(), "blobs", "sha256", strings.TrimPrefix(m.Digest.String(), "sha256:")) + b, err := os.ReadFile(blobPath) + require.NoError(t, err) + var pulledIdx ocispec.Index + require.NoError(t, json.Unmarshal(b, &pulledIdx)) + require.Greater(t, len(pulledIdx.Manifests), 1, "expected multiple platform manifests under the %s index", imageSubstring) + return m.Digest.String() + } + t.Fatalf("expected to find %s in the package layout", imageSubstring) + return "" +} diff --git a/src/test/packages/48-multi-platform-image/pod.yaml b/src/test/packages/48-multi-platform-image/pod.yaml new file mode 100644 index 0000000000..5795624baa --- /dev/null +++ b/src/test/packages/48-multi-platform-image/pod.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Pod +metadata: + name: multi-platform-podinfo + namespace: multi-platform-test + labels: + app: multi-platform-podinfo +spec: + containers: + - name: podinfo + image: ghcr.io/stefanprodan/podinfo:6.4.0@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 + ports: + - containerPort: 9898 + restartPolicy: Always diff --git a/src/test/packages/48-multi-platform-image/zarf.yaml b/src/test/packages/48-multi-platform-image/zarf.yaml new file mode 100644 index 0000000000..1f0e671c31 --- /dev/null +++ b/src/test/packages/48-multi-platform-image/zarf.yaml @@ -0,0 +1,17 @@ +kind: ZarfPackageConfig +metadata: + name: multi-platform-image + description: Pin a container image by index digest; the multi-platform-images feature flag preserves the entire index in the package. + version: 0.0.1 + architecture: amd64 + +components: + - name: podinfo + required: true + images: + - ghcr.io/stefanprodan/podinfo:6.4.0@sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8 + manifests: + - name: podinfo + namespace: multi-platform-test + files: + - pod.yaml From ce2e9df66f8578f45028f6628b66be6a0b9766bf Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 13:39:09 -0400 Subject: [PATCH 08/27] fix pull and improve testing Signed-off-by: Austin Abro --- src/pkg/zoci/pull.go | 79 +++++++++++---- src/pkg/zoci/pull_test.go | 195 +++++++++++++++++++++++++++++++------- 2 files changed, 224 insertions(+), 50 deletions(-) diff --git a/src/pkg/zoci/pull.go b/src/pkg/zoci/pull.go index e98d43988a..1a4f932557 100644 --- a/src/pkg/zoci/pull.go +++ b/src/pkg/zoci/pull.go @@ -158,7 +158,7 @@ func (r *Remote) LayersFromComponents(ctx context.Context, pkg v1alpha1.ZarfPack } // LayersFromImages returns the layers for the given images to pull from OCI. -func (r *Remote) LayersFromImages(ctx context.Context, images map[string]bool) ([]ocispec.Descriptor, error) { +func (r *Remote) LayersFromImages(ctx context.Context, imageList map[string]bool) ([]ocispec.Descriptor, error) { root, err := r.FetchRoot(ctx) if err != nil { return []ocispec.Descriptor{}, err @@ -173,7 +173,7 @@ func (r *Remote) LayersFromImages(ctx context.Context, images map[string]bool) ( layers = append(layers, root.Locate(layout.IndexPath), root.Locate(layout.OCILayoutPath)) - for image := range images { + for image := range imageList { // use docker's transform lib to parse the image ref // this properly mirrors the logic within create refInfo, err := transform.ParseImageRef(image) @@ -181,28 +181,75 @@ func (r *Remote) LayersFromImages(ctx context.Context, images map[string]bool) ( return nil, fmt.Errorf("failed to parse image ref %q: %w", image, err) } - manifestDescriptor := helpers.Find(index.Manifests, func(layer ocispec.Descriptor) bool { + entry := helpers.Find(index.Manifests, func(layer ocispec.Descriptor) bool { return layer.Annotations[ocispec.AnnotationBaseImageName] == refInfo.Reference || // A backwards compatibility shim for older Zarf versions that would leave docker.io off of image annotations (layer.Annotations[ocispec.AnnotationBaseImageName] == refInfo.Path+refInfo.TagOrDigest && refInfo.Host == "docker.io") }) - // even though these are technically image manifests, we store them as Zarf blobs - manifestDescriptor.MediaType = ZarfLayerMediaTypeBlob + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, entry.Digest.Encoded()))) - manifest, err := r.FetchManifest(ctx, manifestDescriptor) - if err != nil { - return nil, err - } - - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifestDescriptor.Digest.Encoded()))) - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifest.Config.Digest.Encoded()))) - - for _, layer := range manifest.Layers { - layerPath := filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()) - layers = append(layers, root.Locate(layerPath)) + switch { + case images.IsIndex(entry.MediaType): + childLayers, err := r.layersFromIndexChildren(ctx, root, entry) + if err != nil { + return nil, err + } + layers = append(layers, childLayers...) + case images.IsManifest(entry.MediaType): + // even though these are technically image manifests, we store them as Zarf blobs + entry.MediaType = ZarfLayerMediaTypeBlob + manifest, err := r.FetchManifest(ctx, entry) + if err != nil { + return nil, err + } + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifest.Config.Digest.Encoded()))) + for _, layer := range manifest.Layers { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()))) + } + default: + return nil, fmt.Errorf("unexpected media type %q for image %s", entry.MediaType, entry.Digest) } } // Remove duplicate descriptors in case of shared base layers return oci.RemoveDuplicateDescriptors(layers), nil } + +// layersFromIndexChildren walks an OCI image index's children and returns every blob +// (child manifests, their configs, and their layers) that must be pulled alongside the index. +// Recurses into nested indexes — the OCI spec allows an index entry to point at either +// an image manifest or another image index. +func (r *Remote) layersFromIndexChildren(ctx context.Context, root *oci.Manifest, indexDesc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + idx, err := oci.FetchJSONFile[*ocispec.Index](ctx, r.FetchLayer, root, filepath.Join(layout.ImagesBlobsDir, indexDesc.Digest.Encoded())) + if err != nil { + return nil, fmt.Errorf("failed to fetch child index %s: %w", indexDesc.Digest, err) + } + layers := make([]ocispec.Descriptor, 0, len(idx.Manifests)) + for _, child := range idx.Manifests { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, child.Digest.Encoded()))) + switch { + case images.IsIndex(child.MediaType): + nestedLayers, err := r.layersFromIndexChildren(ctx, root, child) + if err != nil { + return nil, err + } + layers = append(layers, nestedLayers...) + case images.IsManifest(child.MediaType): + childWithBlobType := child + childWithBlobType.MediaType = ZarfLayerMediaTypeBlob + childManifest, err := r.FetchManifest(ctx, childWithBlobType) + if err != nil { + return nil, fmt.Errorf("failed to fetch child manifest %s: %w", child.Digest, err) + } + if childManifest.Config.Digest != "" { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, childManifest.Config.Digest.Encoded()))) + } + for _, layer := range childManifest.Layers { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()))) + } + default: + return nil, fmt.Errorf("unexpected media type %q for index child %s", child.MediaType, child.Digest) + } + } + return layers, nil +} diff --git a/src/pkg/zoci/pull_test.go b/src/pkg/zoci/pull_test.go index 2370b8ba00..4239d8f22a 100644 --- a/src/pkg/zoci/pull_test.go +++ b/src/pkg/zoci/pull_test.go @@ -6,22 +6,29 @@ package zoci_test import ( "context" + "encoding/json" "fmt" "os" + "path" "path/filepath" + "strings" "testing" "github.com/defenseunicorns/pkg/oci" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/pkg/feature" + "github.com/zarf-dev/zarf/src/pkg/images" "github.com/zarf-dev/zarf/src/pkg/packager" "github.com/zarf-dev/zarf/src/pkg/packager/layout" "github.com/zarf-dev/zarf/src/pkg/zoci" "github.com/zarf-dev/zarf/src/test/testutil" "github.com/zarf-dev/zarf/src/types" _ "modernc.org/sqlite" + "oras.land/oras-go/v2" "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" ) func createRegistry(ctx context.Context, t *testing.T) registry.Reference { @@ -118,25 +125,41 @@ spec: return dir } -// virtualImage holds descriptors of the image pushed by buildVirtualPackage so callers can -// assert the package layers reference these exact blobs. type virtualImage struct { layer ocispec.Descriptor config ocispec.Descriptor manifest ocispec.Descriptor } -// virtualPackage bundles the in-memory registry address, built package tar path, build tmpdir, -// and image descriptors returned by buildVirtualPackage. type virtualPackage struct { registryAddr string packagePath string - tmpdir string image virtualImage } -// buildVirtualPackage pushes a virtual image to a fresh in-memory registry and runs -// packager.Create against a generated package def. SBOM is skipped as the image has random bytes +// publishPackage loads a package from packagePath, publishes it to upstream/zarf-packages, +// and returns a connected Remote plus the package's components. +func publishPackage(ctx context.Context, t *testing.T, packagePath, upstream string) (*zoci.Remote, []v1alpha1.ZarfComponent) { + t.Helper() + pkgLayout, err := layout.LoadFromTar(ctx, packagePath, layout.PackageLayoutOptions{}) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(pkgLayout.Pkg.Metadata.Name) }) //nolint:errcheck + + dstRef := registry.Reference{Registry: upstream, Repository: "zarf-packages"} + packageRef, err := packager.PublishPackage(ctx, pkgLayout, dstRef, packager.PublishPackageOptions{ + RemoteOptions: types.RemoteOptions{PlainHTTP: true}, + OCIConcurrency: 3, + }) + require.NoError(t, err) + + platform := oci.PlatformForArch(pkgLayout.Pkg.Build.Architecture) + r, err := zoci.NewRemote(ctx, packageRef.String(), platform, oci.WithPlainHTTP(true)) + require.NoError(t, err) + return r, pkgLayout.Pkg.Components +} + +// buildVirtualPackage pushes a virtual image to a fresh in-memory registry and builds a zarf +// package referencing it. func buildVirtualPackage(ctx context.Context, t *testing.T) virtualPackage { t.Helper() upstreamAddr := testutil.SetupInMemoryRegistryDynamic(ctx, t) @@ -147,18 +170,10 @@ func buildVirtualPackage(ctx context.Context, t *testing.T) virtualPackage { require.NoError(t, imageRepo.Tag(ctx, manifestDesc, "test")) imageRef := fmt.Sprintf("%s/fixtures/test-image:test", upstreamAddr) - pkgDefDir := writeVirtualPackageDef(t, imageRef) - tmpdir := t.TempDir() - packagePath, err := packager.Create(ctx, pkgDefDir, tmpdir, packager.CreateOptions{ - CachePath: tmpdir, - RemoteOptions: types.RemoteOptions{PlainHTTP: true}, - SkipSBOM: true, // random-bytes layer can't be syft-scanned - }) - require.NoError(t, err) + packagePath := createVirtualPackage(ctx, t, imageRef) return virtualPackage{ registryAddr: upstreamAddr, packagePath: packagePath, - tmpdir: tmpdir, image: virtualImage{ layer: layerDesc, config: configDesc, @@ -167,28 +182,25 @@ func buildVirtualPackage(ctx context.Context, t *testing.T) virtualPackage { } } -func TestAssembleLayers(t *testing.T) { - ctx := testutil.TestContext(t) - pkg := buildVirtualPackage(ctx, t) - - pkgLayout, err := layout.LoadFromTar(ctx, pkg.packagePath, layout.PackageLayoutOptions{}) - require.NoError(t, err) - - registryRef := registry.Reference{Registry: pkg.registryAddr, Repository: "zarf-packages"} - packageRef, err := packager.PublishPackage(ctx, pkgLayout, registryRef, packager.PublishPackageOptions{ - RemoteOptions: types.RemoteOptions{PlainHTTP: true}, +// createVirtualPackage creates a package with an in memory zarf yaml and a single virtual image for the provided ref +func createVirtualPackage(ctx context.Context, t *testing.T, imageRef string) string { + t.Helper() + pkgDefDir := writeVirtualPackageDef(t, imageRef) + tmpdir := t.TempDir() + packagePath, err := packager.Create(ctx, pkgDefDir, tmpdir, packager.CreateOptions{ OCIConcurrency: 3, + CachePath: tmpdir, + RemoteOptions: types.RemoteOptions{PlainHTTP: true}, + SkipSBOM: true, }) require.NoError(t, err) - t.Cleanup(func() { os.Remove(pkgLayout.Pkg.Metadata.Name) }) //nolint:errcheck - - cacheModifier, err := zoci.GetOCICacheModifier(ctx, pkg.tmpdir) - require.NoError(t, err) - platform := oci.PlatformForArch(pkgLayout.Pkg.Build.Architecture) - remote, err := zoci.NewRemote(ctx, packageRef.String(), platform, append([]oci.Modifier{oci.WithPlainHTTP(true)}, cacheModifier)...) - require.NoError(t, err) + return packagePath +} - components := pkgLayout.Pkg.Components +func TestAssembleLayers(t *testing.T) { + ctx := testutil.TestContext(t) + pkg := buildVirtualPackage(ctx, t) + remote, components := publishPackage(ctx, t, pkg.packagePath, pkg.registryAddr) tests := []struct { name string @@ -235,3 +247,118 @@ func TestAssembleLayers(t *testing.T) { require.Contains(t, digests, pkg.image.config.Digest.String(), "image config blob present") require.Contains(t, digests, pkg.image.layer.Digest.String(), "image layer blob present") } + +func buildAndPublishPackage(ctx context.Context, t *testing.T, imageRef, upstream string) *zoci.Remote { + t.Helper() + packagePath := createVirtualPackage(ctx, t, imageRef) + r, _ := publishPackage(ctx, t, packagePath, upstream) + return r +} + +// expectedLayerPaths walks the OCI graph rooted at rootDigest in repo and returns every blob path that LayersFromImages should emit. +func expectedLayerPaths(ctx context.Context, t *testing.T, repo *remote.Repository, rootDigest string) []string { + t.Helper() + blobDir := path.Join(layout.ImagesDir, "blobs", "sha256") + paths := []string{ + path.Join(layout.ImagesDir, "index.json"), + path.Join(layout.ImagesDir, "oci-layout"), + } + var walk func(d string) + walk = func(d string) { + paths = append(paths, path.Join(blobDir, strings.TrimPrefix(d, "sha256:"))) + desc, body, err := oras.FetchBytes(ctx, repo, d, oras.DefaultFetchBytesOptions) + require.NoError(t, err) + if images.IsIndex(desc.MediaType) { + var idx ocispec.Index + require.NoError(t, json.Unmarshal(body, &idx)) + for _, c := range idx.Manifests { + walk(c.Digest.String()) + } + return + } + var m ocispec.Manifest + require.NoError(t, json.Unmarshal(body, &m)) + paths = append(paths, path.Join(blobDir, m.Config.Digest.Encoded())) + for _, l := range m.Layers { + paths = append(paths, path.Join(blobDir, l.Digest.Encoded())) + } + } + walk(rootDigest) + return paths +} + +func pathsFromLayers(layers []ocispec.Descriptor) []string { + out := make([]string, 0, len(layers)) + for _, l := range layers { + out = append(out, l.Annotations[ocispec.AnnotationTitle]) + } + return out +} + +func requireNoDuplicatePaths(t *testing.T, paths []string) { + t.Helper() + seen := make(map[string]struct{}, len(paths)) + for _, p := range paths { + _, dup := seen[p] + require.False(t, dup, "duplicate layer path in result: %s", p) + seen[p] = struct{}{} + } +} + +func TestLayersFromImages_SingleArch(t *testing.T) { + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + digest := testutil.PushImage(ctx, t, upstream+"/fixtures/single", "test") + imageRef := fmt.Sprintf("%s/fixtures/single:test@%s", upstream, digest) + + r := buildAndPublishPackage(ctx, t, imageRef, upstream) + layers, err := r.LayersFromImages(ctx, map[string]bool{imageRef: true}) + require.NoError(t, err) + + expected := expectedLayerPaths(ctx, t, testutil.NewRepo(t, upstream+"/fixtures/single"), digest) + actual := pathsFromLayers(layers) + require.ElementsMatch(t, expected, actual) + requireNoDuplicatePaths(t, actual) +} + +func TestLayersFromImages_MultiArch(t *testing.T) { + _ = feature.Set([]feature.Feature{{Name: feature.MultiPlatformImages, Enabled: true}}) //nolint:errcheck + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + platforms := []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + digest := testutil.PushMultiArchIndex(ctx, t, upstream+"/fixtures/multi", "test", platforms) + imageRef := fmt.Sprintf("%s/fixtures/multi:test@%s", upstream, digest) + + r := buildAndPublishPackage(ctx, t, imageRef, upstream) + layers, err := r.LayersFromImages(ctx, map[string]bool{imageRef: true}) + require.NoError(t, err) + + expected := expectedLayerPaths(ctx, t, testutil.NewRepo(t, upstream+"/fixtures/multi"), digest) + actual := pathsFromLayers(layers) + require.ElementsMatch(t, expected, actual) + requireNoDuplicatePaths(t, actual) +} + +func TestLayersFromImages_NestedIndex(t *testing.T) { + _ = feature.Set([]feature.Feature{{Name: feature.MultiPlatformImages, Enabled: true}}) //nolint:errcheck + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + platforms := []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + digest := testutil.PushNestedIndex(ctx, t, upstream+"/fixtures/nested", "test", platforms) + imageRef := fmt.Sprintf("%s/fixtures/nested:test@%s", upstream, digest) + + r := buildAndPublishPackage(ctx, t, imageRef, upstream) + layers, err := r.LayersFromImages(ctx, map[string]bool{imageRef: true}) + require.NoError(t, err) + + expected := expectedLayerPaths(ctx, t, testutil.NewRepo(t, upstream+"/fixtures/nested"), digest) + actual := pathsFromLayers(layers) + require.ElementsMatch(t, expected, actual) + requireNoDuplicatePaths(t, actual) +} From 92b7098b84adf05898ccf91996877feaeda221bb Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 13:48:41 -0400 Subject: [PATCH 09/27] remove feature flag Signed-off-by: Austin Abro --- src/pkg/feature/feature.go | 16 +++------ src/pkg/images/pull.go | 34 +++---------------- src/pkg/images/pull_test.go | 32 ++++------------- src/pkg/packager/layout/assemble.go | 2 +- src/pkg/zoci/pull_test.go | 3 -- src/test/e2e/48_multi_platform_image_test.go | 16 ++++----- .../48-multi-platform-image/zarf.yaml | 2 +- 7 files changed, 24 insertions(+), 81 deletions(-) diff --git a/src/pkg/feature/feature.go b/src/pkg/feature/feature.go index ec7bd52511..7fe52ccd63 100644 --- a/src/pkg/feature/feature.go +++ b/src/pkg/feature/feature.go @@ -203,11 +203,10 @@ func featuresToMap(fs []Feature) map[Name]Feature { // List of feature names const ( // AxolotlMode declares the "axolotl-mode" feature - AxolotlMode Name = "axolotl-mode" - RegistryProxy Name = "registry-proxy" - Values Name = "values" - BundleSignature Name = "bundle-signature" - MultiPlatformImages Name = "multi-platform-images" + AxolotlMode Name = "axolotl-mode" + RegistryProxy Name = "registry-proxy" + Values Name = "values" + BundleSignature Name = "bundle-signature" ) func init() { @@ -252,13 +251,6 @@ func init() { Since: "v0.72.0", Stage: Alpha, }, - { - Name: MultiPlatformImages, - Description: "Allows pinning images by an index digest. Every manifest under the index will be used", - Enabled: false, - Since: "v0.76.0", - Stage: Alpha, - }, } err := setDefault(features) diff --git a/src/pkg/images/pull.go b/src/pkg/images/pull.go index 46eca2fab1..8f080cbe3e 100644 --- a/src/pkg/images/pull.go +++ b/src/pkg/images/pull.go @@ -6,7 +6,6 @@ package images import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -37,7 +36,6 @@ import ( orasCache "github.com/defenseunicorns/pkg/oci/cache" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/internal/dns" - "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" orasRemote "oras.land/oras-go/v2/registry/remote" @@ -204,21 +202,10 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory return nil } - // When the ref is digest-pinned to an index, preserve the entire index intact under - // the multi-platform-images feature flag so the original digest round-trips. Without - // the flag (or for tag-resolved indexes) we keep the existing platform-filter path - // and the existing digest+index error. - // FIXME: I'm not sure if the preserveIndex framing is quite right - preserveIndex := image.original.Digest != "" && IsIndex(desc.MediaType) && feature.IsEnabled(feature.MultiPlatformImages) - if image.original.Digest != "" && IsIndex(desc.MediaType) && !preserveIndex { - // Both index types can be marshalled into an ocispec.Index - // https://github.com/oras-project/oras-go/blob/853e0125ccad32ff691e4ed70e156c7619021bfd/internal/manifestutil/parser.go#L55 - var idx ocispec.Index - if err := json.Unmarshal(b, &idx); err != nil { - return fmt.Errorf("unable to unmarshal index.json: %w", err) - } - return constructIndexError(idx, image.overridden) - } + // When the ref is digest-pinned to an index, preserve the entire index intact so the + // original digest round-trips. Tag-resolved indexes are still platform-filtered below. + // FIXME: I might not need this + preserveIndex := image.original.Digest != "" && IsIndex(desc.MediaType) // If a manifest was returned from FetchBytes, either it's a tag with only one image or it's a non container image // If it's not a manifest then we received an index and need to pull the manifest by platform if !IsManifest(desc.MediaType) && !preserveIndex { @@ -290,19 +277,6 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory return pulledImages, nil } -func constructIndexError(idx ocispec.Index, image transform.Image) error { - lines := []string{"The following images are available in the index:"} - name := image.Name - if image.Tag != "" { - name += ":" + image.Tag - } - for _, desc := range idx.Manifests { - lines = append(lines, fmt.Sprintf("image - %s@%s with platform %s", name, desc.Digest, desc.Platform)) - } - imageOptions := strings.Join(lines, "\n") - return fmt.Errorf("%s resolved to an OCI image index which is not supported by Zarf, select a specific platform to use: %s", image.Reference, imageOptions) -} - func getDockerEndpointHost() (string, error) { dockerCli, err := command.NewDockerCli(command.WithStandardStreams()) if err != nil { diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 27a62e64ba..2b6645f897 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -46,6 +46,7 @@ func pushDockerManifestList(ctx context.Context, t *testing.T, repo *remote.Repo return desc } +// FIXME: could probably delete this test func TestCheckForIndex(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) @@ -79,28 +80,16 @@ func TestCheckForIndex(t *testing.T) { manifestDigest := testutil.PushImage(ctx, t, upstream+"/fixtures/img", "v1") testCases := []struct { - name string - ref string - expectedDigests []string - expectedErr string + name string + ref string }{ { - name: "oci index sha", - ref: fmt.Sprintf("%s/fixtures/idx@%s", upstream, ociIdx.Digest), - expectedErr: "%s resolved to an OCI image index which is not supported by Zarf, select a specific platform to use", - expectedDigests: []string{ - ociChildren[0].Digest.String(), - ociChildren[1].Digest.String(), - }, + name: "oci index sha", + ref: fmt.Sprintf("%s/fixtures/idx@%s", upstream, ociIdx.Digest), }, { - name: "docker manifest list", - ref: fmt.Sprintf("%s/fixtures/docker-list@%s", upstream, dockerList.Digest), - expectedErr: "%s resolved to an OCI image index which is not supported by Zarf, select a specific platform to use", - expectedDigests: []string{ - dockerChildren[0].Digest.String(), - dockerChildren[1].Digest.String(), - }, + name: "docker manifest list", + ref: fmt.Sprintf("%s/fixtures/docker-list@%s", upstream, dockerList.Digest), }, { name: "image manifest by tag", @@ -126,13 +115,6 @@ func TestCheckForIndex(t *testing.T) { PlainHTTP: true, } _, err = Pull(ctx, []transform.Image{refInfo}, dstDir, opts) - if tc.expectedErr != "" { - require.ErrorContains(t, err, fmt.Sprintf(tc.expectedErr, refInfo.Reference)) - for _, d := range tc.expectedDigests { - require.ErrorContains(t, err, d) - } - return - } require.NoError(t, err) }) } diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index 904dbe6950..b67c63601d 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -166,7 +166,7 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } } - // If the package layout preserves any image index (multi-platform-images flag at create time), + // If the package layout preserves any image index (digest-pinned multi-platform image), // stamp a version requirement so an older Zarf doesn't try to deploy it without index support. hasIndex, err := imageLayoutHasIndex(filepath.Join(buildPath, ImagesDir)) if err != nil { diff --git a/src/pkg/zoci/pull_test.go b/src/pkg/zoci/pull_test.go index 4239d8f22a..e21d005697 100644 --- a/src/pkg/zoci/pull_test.go +++ b/src/pkg/zoci/pull_test.go @@ -18,7 +18,6 @@ import ( ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" - "github.com/zarf-dev/zarf/src/pkg/feature" "github.com/zarf-dev/zarf/src/pkg/images" "github.com/zarf-dev/zarf/src/pkg/packager" "github.com/zarf-dev/zarf/src/pkg/packager/layout" @@ -322,7 +321,6 @@ func TestLayersFromImages_SingleArch(t *testing.T) { } func TestLayersFromImages_MultiArch(t *testing.T) { - _ = feature.Set([]feature.Feature{{Name: feature.MultiPlatformImages, Enabled: true}}) //nolint:errcheck ctx := testutil.TestContext(t) upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) platforms := []ocispec.Platform{ @@ -343,7 +341,6 @@ func TestLayersFromImages_MultiArch(t *testing.T) { } func TestLayersFromImages_NestedIndex(t *testing.T) { - _ = feature.Set([]feature.Feature{{Name: feature.MultiPlatformImages, Enabled: true}}) //nolint:errcheck ctx := testutil.TestContext(t) upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) platforms := []ocispec.Platform{ diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go index 0cf9182db0..950f6660c8 100644 --- a/src/test/e2e/48_multi_platform_image_test.go +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -22,19 +22,17 @@ import ( // podinfoIndexDigest is the index digest of ghcr.io/stefanprodan/podinfo:6.4.0. const podinfoIndexDigest = "sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8" -const multiPlatformImagesFlag = "--features=\"multi-platform-images=true\"" - -// TestMultiPlatformIndexImage exercises the multi-platform-images feature flag end-to-end: +// TestMultiPlatformIndexImage exercises digest-pinned multi-platform images end-to-end: // create + publish + pull + deploy of a single-arch package whose only image is pinned by an // index digest. The package layout must preserve the full upstream index, the per-platform // SBOMs must exist, and the image must end up at the original digest in the destination registry. func TestMultiPlatformIndexImage(t *testing.T) { - t.Log("E2E: index-sha image with multi-platform-images feature flag") + t.Log("E2E: index-sha image preserved as the full upstream index") pkgDefinitionPath := filepath.Join("src", "test", "packages", "48-multi-platform-image") createDir := t.TempDir() - stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm", multiPlatformImagesFlag) + stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm") require.NoError(t, err, stdOut, stdErr) createdPkgPath := filepath.Join(createDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") @@ -47,11 +45,11 @@ func TestMultiPlatformIndexImage(t *testing.T) { Reference: "0.0.1", } - stdOut, stdErr, err = e2e.Zarf(t, "package", "publish", createdPkgPath, "oci://"+registryURL, "--plain-http", multiPlatformImagesFlag) + stdOut, stdErr, err = e2e.Zarf(t, "package", "publish", createdPkgPath, "oci://"+registryURL, "--plain-http") require.NoError(t, err, stdOut, stdErr) pullDir := t.TempDir() - stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir, multiPlatformImagesFlag) + stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir) require.NoError(t, err, stdOut, stdErr) pulledPkgPath := filepath.Join(pullDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") @@ -79,10 +77,10 @@ func TestMultiPlatformIndexImage(t *testing.T) { } require.GreaterOrEqual(t, count, 2, "expected per-platform SBOMs for the digested multi-platform image") - stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pulledPkgPath, "--confirm", "--skip-version-check", multiPlatformImagesFlag) + stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pulledPkgPath, "--confirm", "--skip-version-check") require.NoError(t, err, stdOut, stdErr) t.Cleanup(func() { - _, _, err = e2e.Zarf(t, "package", "remove", "multi-platform-image", "--confirm", "--skip-version-check", multiPlatformImagesFlag) + _, _, err = e2e.Zarf(t, "package", "remove", "multi-platform-image", "--confirm", "--skip-version-check") require.NoError(t, err) }) } diff --git a/src/test/packages/48-multi-platform-image/zarf.yaml b/src/test/packages/48-multi-platform-image/zarf.yaml index 1f0e671c31..4b0af1fc49 100644 --- a/src/test/packages/48-multi-platform-image/zarf.yaml +++ b/src/test/packages/48-multi-platform-image/zarf.yaml @@ -1,7 +1,7 @@ kind: ZarfPackageConfig metadata: name: multi-platform-image - description: Pin a container image by index digest; the multi-platform-images feature flag preserves the entire index in the package. + description: Pin a container image by index digest; the entire index is preserved in the package. version: 0.0.1 architecture: amd64 From 883a9de7971e346efd502bf87c2d54dba9a41873 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 13:57:50 -0400 Subject: [PATCH 10/27] record package metadata Signed-off-by: Austin Abro --- src/pkg/packager/layout/assemble.go | 66 ++++++++++++-------- src/pkg/packager/layout/assemble_test.go | 76 ++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index b67c63601d..c1cf7ebd67 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -166,19 +166,6 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } } - // If the package layout preserves any image index (digest-pinned multi-platform image), - // stamp a version requirement so an older Zarf doesn't try to deploy it without index support. - hasIndex, err := imageLayoutHasIndex(filepath.Join(buildPath, ImagesDir)) - if err != nil { - return nil, fmt.Errorf("failed to inspect image layout: %w", err) - } - if hasIndex { - pkg.Build.VersionRequirements = append(pkg.Build.VersionRequirements, v1alpha1.VersionRequirement{ - Version: "v0.76.0", - Reason: "This package contains multi-platform images preserved by index digest, which require v0.76.0+ to deploy.", - }) - } - l.Info("composed components successfully") if !opts.SkipSBOM && pkg.IsSBOMAble() { @@ -216,7 +203,10 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } pkg.Metadata.AggregateChecksum = checksumSha - pkg = recordPackageMetadata(pkg, opts.Flavor, opts.RegistryOverrides, opts.WithBuildMachineInfo) + pkg, err = recordPackageMetadata(pkg, opts.Flavor, opts.RegistryOverrides, opts.WithBuildMachineInfo, buildPath) + if err != nil { + return nil, err + } b, err := goyaml.Marshal(pkg) if err != nil { @@ -290,7 +280,10 @@ func AssembleSkeleton(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } pkg.Metadata.AggregateChecksum = checksumSha - pkg = recordPackageMetadata(pkg, opts.Flavor, nil, opts.WithBuildMachineInfo) + pkg, err = recordPackageMetadata(pkg, opts.Flavor, nil, opts.WithBuildMachineInfo, buildPath) + if err != nil { + return nil, err + } b, err := goyaml.Marshal(pkg) if err != nil { @@ -794,7 +787,7 @@ func assembleSkeletonComponent(ctx context.Context, component v1alpha1.ZarfCompo return nil } -func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOverrides []images.RegistryOverride, withBuildMachineInfo bool) v1alpha1.ZarfPackage { +func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOverrides []images.RegistryOverride, withBuildMachineInfo bool, buildPath string) (v1alpha1.ZarfPackage, error) { now := time.Now() if withBuildMachineInfo { // Just use $USER env variable to avoid CGO issue. @@ -827,17 +820,15 @@ func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOver // Record the flavor of Zarf used to build this package (if any). pkg.Build.Flavor = flavor - var versionRequirements []v1alpha1.VersionRequirement - for _, comp := range pkg.Components { - if len(comp.ImageArchives) > 0 { - versionRequirements = append(versionRequirements, v1alpha1.VersionRequirement{ - Version: "v0.68.0", - Reason: "This package contains image archives which will only be recognized on v0.68.0+", - }) - break + hasIndex := false + if buildPath != "" { + var err error + hasIndex, err = imageLayoutHasIndex(filepath.Join(buildPath, ImagesDir)) + if err != nil { + return v1alpha1.ZarfPackage{}, fmt.Errorf("failed to inspect image layout: %w", err) } } - pkg.Build.VersionRequirements = versionRequirements + pkg.Build.VersionRequirements = collectVersionRequirements(pkg, hasIndex) // We lose the ordering for the user-provided registry overrides. overrides := make(map[string]string, len(registryOverrides)) @@ -855,7 +846,30 @@ func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOver // Signature files are appended by SignPackage() if signing occurs. pkg.Build.ProvenanceFiles = []string{Checksums} - return pkg + return pkg, nil +} + +// collectVersionRequirements returns the minimum-Zarf-version requirements implied by a package's +// contents. hasIndex reports whether the assembled image layout preserves a multi-platform image +// index — that information lives on disk, so callers compute it before invoking this. +func collectVersionRequirements(pkg v1alpha1.ZarfPackage, hasIndex bool) []v1alpha1.VersionRequirement { + var reqs []v1alpha1.VersionRequirement + for _, comp := range pkg.Components { + if len(comp.ImageArchives) > 0 { + reqs = append(reqs, v1alpha1.VersionRequirement{ + Version: "v0.68.0", + Reason: "This package contains image archives which will only be recognized on v0.68.0+", + }) + break + } + } + if hasIndex { + reqs = append(reqs, v1alpha1.VersionRequirement{ + Version: "v0.76.0", + Reason: "This package contains multi-platform images preserved by index digest, which require v0.76.0+ to deploy.", + }) + } + return reqs } func getChecksum(dirPath string) (string, string, error) { diff --git a/src/pkg/packager/layout/assemble_test.go b/src/pkg/packager/layout/assemble_test.go index 7fab8bc464..a9854ac011 100644 --- a/src/pkg/packager/layout/assemble_test.go +++ b/src/pkg/packager/layout/assemble_test.go @@ -217,3 +217,79 @@ func TestValidateImageArchivesNoDuplicates(t *testing.T) { }) } } + +func TestCollectVersionRequirements(t *testing.T) { + t.Parallel() + + imageArchivesReq := v1alpha1.VersionRequirement{ + Version: "v0.68.0", + Reason: "This package contains image archives which will only be recognized on v0.68.0+", + } + indexReq := v1alpha1.VersionRequirement{ + Version: "v0.76.0", + Reason: "This package contains multi-platform images preserved by index digest, which require v0.76.0+ to deploy.", + } + + tests := []struct { + name string + pkg v1alpha1.ZarfPackage + hasIndex bool + expected []v1alpha1.VersionRequirement + }{ + { + name: "no requirements for a plain package", + pkg: v1alpha1.ZarfPackage{}, + expected: nil, + }, + { + name: "image archives trigger v0.68.0", + pkg: v1alpha1.ZarfPackage{ + Components: []v1alpha1.ZarfComponent{ + { + Name: "c1", + ImageArchives: []v1alpha1.ImageArchive{ + {Path: "/tmp/archive.tar", Images: []string{"nginx:1.21"}}, + }, + }, + }, + }, + expected: []v1alpha1.VersionRequirement{imageArchivesReq}, + }, + { + name: "preserved index triggers v0.76.0", + pkg: v1alpha1.ZarfPackage{}, + hasIndex: true, + expected: []v1alpha1.VersionRequirement{indexReq}, + }, + { + name: "image archives and preserved index trigger both", + pkg: v1alpha1.ZarfPackage{ + Components: []v1alpha1.ZarfComponent{ + { + Name: "c1", + ImageArchives: []v1alpha1.ImageArchive{{Path: "/tmp/a.tar", Images: []string{"x:y"}}}, + }, + }, + }, + hasIndex: true, + expected: []v1alpha1.VersionRequirement{imageArchivesReq, indexReq}, + }, + { + name: "image archives requirement is only emitted once across components", + pkg: v1alpha1.ZarfPackage{ + Components: []v1alpha1.ZarfComponent{ + {Name: "c1", ImageArchives: []v1alpha1.ImageArchive{{Path: "/tmp/a.tar", Images: []string{"x:y"}}}}, + {Name: "c2", ImageArchives: []v1alpha1.ImageArchive{{Path: "/tmp/b.tar", Images: []string{"p:q"}}}}, + }, + }, + expected: []v1alpha1.VersionRequirement{imageArchivesReq}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.expected, collectVersionRequirements(tt.pkg, tt.hasIndex)) + }) + } +} From abbe2d5339cdb1547b3875eb86adf1e8346e0ed4 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 15:33:33 -0400 Subject: [PATCH 11/27] check for image index Signed-off-by: Austin Abro --- src/pkg/packager/deploy.go | 17 +++++++++++++---- src/pkg/packager/layout/package.go | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index 8a11341663..f6f8836a2b 100644 --- a/src/pkg/packager/deploy.go +++ b/src/pkg/packager/deploy.go @@ -230,7 +230,7 @@ func (d *deployer) deployComponents(ctx context.Context, pkgLayout *layout.Packa if err != nil { return nil, fmt.Errorf("unable to connect to the Kubernetes cluster: %w", err) } - if err := d.verifyPackageIsDeployable(ctx, pkgLayout.Pkg); err != nil { + if err := d.verifyPackageIsDeployable(ctx, pkgLayout); err != nil { return nil, fmt.Errorf("package is not deployable to this system: %w", err) } } @@ -714,8 +714,8 @@ func (d *deployer) installManifests(ctx context.Context, pkgLayout *layout.Packa return installedCharts, nil } -func (d *deployer) verifyPackageIsDeployable(ctx context.Context, pkg v1alpha1.ZarfPackage) error { - if err := verifyClusterCompatibility(ctx, d.c, pkg); err != nil { +func (d *deployer) verifyPackageIsDeployable(ctx context.Context, pkgLayout *layout.PackageLayout) error { + if err := verifyClusterCompatibility(ctx, d.c, pkgLayout); err != nil { if errors.Is(err, lang.ErrUnableToCheckArch) { logger.From(ctx).Warn("unable to validate package architecture", "error", err) } else { @@ -757,12 +757,21 @@ func setupState(ctx context.Context, c *cluster.Cluster, connected bool) (*state return s, nil } -func verifyClusterCompatibility(ctx context.Context, c *cluster.Cluster, pkg v1alpha1.ZarfPackage) error { +func verifyClusterCompatibility(ctx context.Context, c *cluster.Cluster, pkgLayout *layout.PackageLayout) error { + pkg := pkgLayout.Pkg // Ignore this check if the package contains no images if !pkg.HasImages() { return nil } + hasImageIndex, err := pkgLayout.HasImageIndex() + if err != nil { + return fmt.Errorf("failed to inspect package image layout: %w", err) + } + if hasImageIndex { + return nil + } + // Get node architectures nodeList, err := c.Clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) if err != nil { diff --git a/src/pkg/packager/layout/package.go b/src/pkg/packager/layout/package.go index 610756db49..fdf180ea7a 100644 --- a/src/pkg/packager/layout/package.go +++ b/src/pkg/packager/layout/package.go @@ -548,6 +548,11 @@ func (p *PackageLayout) GetImageDirPath() string { return filepath.Join(p.dirPath, ImagesDir) } +// HasImageIndex reports whether the package layout has a multi-platform image +func (p *PackageLayout) HasImageIndex() (bool, error) { + return imageLayoutHasIndex(p.GetImageDirPath()) +} + // Archive creates a tarball from the package layout and returns the path to that tarball func (p *PackageLayout) Archive(ctx context.Context, dirPath string, maxPackageSize int) (string, error) { filename, err := p.FileName() From 9057a43b17d076df9f856114eed5b8b122ad2afb Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 15:41:45 -0400 Subject: [PATCH 12/27] isIndex Signed-off-by: Austin Abro --- src/pkg/images/pull.go | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/pkg/images/pull.go b/src/pkg/images/pull.go index 8f080cbe3e..19ddfa8d6d 100644 --- a/src/pkg/images/pull.go +++ b/src/pkg/images/pull.go @@ -202,13 +202,10 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory return nil } - // When the ref is digest-pinned to an index, preserve the entire index intact so the - // original digest round-trips. Tag-resolved indexes are still platform-filtered below. - // FIXME: I might not need this - preserveIndex := image.original.Digest != "" && IsIndex(desc.MediaType) + isIndexSha := image.original.Digest != "" && IsIndex(desc.MediaType) // If a manifest was returned from FetchBytes, either it's a tag with only one image or it's a non container image // If it's not a manifest then we received an index and need to pull the manifest by platform - if !IsManifest(desc.MediaType) && !preserveIndex { + if !IsManifest(desc.MediaType) && !isIndexSha { fetchOpts.FetchOptions.TargetPlatform = platform desc, b, err = oras.FetchBytes(ectx, repo, image.overridden.Reference, fetchOpts) if err != nil { @@ -216,21 +213,20 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory } } - if !preserveIndex && !IsManifest(desc.MediaType) { - return fmt.Errorf("received unexpected mediatype %s", desc.MediaType) - } - var size int64 - if preserveIndex { + switch { + case IsIndex(desc.MediaType): size, _, err = inspectIndex(ectx, repo, desc, b) if err != nil { return fmt.Errorf("failed to inspect index %s: %w", image.overridden.Reference, err) } - } else { + case IsManifest(desc.MediaType): size, err = getSizeOfManifest(desc, b) if err != nil { return err } + default: + return fmt.Errorf("received unexpected mediatype %s", desc.MediaType) } imageListLock.Lock() defer imageListLock.Unlock() From e9fbd0ccb7d4281367b2e7781222a5f0553d01be Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 15:47:09 -0400 Subject: [PATCH 13/27] display platforms Signed-off-by: Austin Abro --- src/pkg/images/pull.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/pkg/images/pull.go b/src/pkg/images/pull.go index 19ddfa8d6d..c656f81ff5 100644 --- a/src/pkg/images/pull.go +++ b/src/pkg/images/pull.go @@ -59,6 +59,9 @@ type imagePullInfo struct { ref string manifestDesc ocispec.Descriptor byteSize int64 + // platforms is populated only when the image resolves to an OCI image index; one entry per + // leaf manifest in "arch[/variant]" form. Empty for single-platform manifests. + platforms []string } type imageWithOverride struct { @@ -160,7 +163,6 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory // This loop pulls the metadata from images with three goals // - Get all the manifests from images that will be pulled so they can be returned to the function - // - discover if any images are sha'd to an index, if so error and inform user on the different available platforms // - Mark any images that don't resolve so we can attempt to pull them from the daemon eg, ectx := errgroup.WithContext(ctx) eg.SetLimit(10) @@ -214,9 +216,10 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory } var size int64 + var platforms []string switch { case IsIndex(desc.MediaType): - size, _, err = inspectIndex(ectx, repo, desc, b) + size, platforms, err = inspectIndex(ectx, repo, desc, b) if err != nil { return fmt.Errorf("failed to inspect index %s: %w", image.overridden.Reference, err) } @@ -235,6 +238,7 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory ref: image.original.Reference, byteSize: size, manifestDesc: desc, + platforms: platforms, }) pulledImages = append(pulledImages, PulledImage{Image: image.original}) l.Debug("pulled image", "name", image.overridden.Reference) @@ -439,7 +443,11 @@ func orasSave(ctx context.Context, imageInfo imagePullInfo, opts PullOptions, ds copyOpts := oras.DefaultCopyOptions copyOpts.Concurrency = opts.OCIConcurrency copyOpts.WithTargetPlatform(imageInfo.manifestDesc.Platform) - l.Info("saving image", "name", imageInfo.registryOverrideRef, "size", utils.ByteFormat(float64(imageInfo.byteSize), 2)) + logArgs := []any{"name", imageInfo.registryOverrideRef, "size", utils.ByteFormat(float64(imageInfo.byteSize), 2)} + if len(imageInfo.platforms) > 0 { + logArgs = append(logArgs, "platforms", strings.Join(imageInfo.platforms, ",")) + } + l.Info("saving image", logArgs...) localCache, err := oci.NewWithContext(ctx, opts.CacheDirectory) if err != nil { return fmt.Errorf("failed to create oci formatted directory: %w", err) From 890e4037644a58c1f6b2b3a00f54411fa6e1604b Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Thu, 7 May 2026 16:01:31 -0400 Subject: [PATCH 14/27] better test Signed-off-by: Austin Abro --- src/test/e2e/48_multi_platform_image_test.go | 19 +++++++++---------- .../48-multi-platform-image/zarf.yaml | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go index 950f6660c8..2cdc32fb53 100644 --- a/src/test/e2e/48_multi_platform_image_test.go +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -5,6 +5,7 @@ package test import ( "encoding/json" + "fmt" "os" "path/filepath" "strings" @@ -22,11 +23,9 @@ import ( // podinfoIndexDigest is the index digest of ghcr.io/stefanprodan/podinfo:6.4.0. const podinfoIndexDigest = "sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8" -// TestMultiPlatformIndexImage exercises digest-pinned multi-platform images end-to-end: -// create + publish + pull + deploy of a single-arch package whose only image is pinned by an -// index digest. The package layout must preserve the full upstream index, the per-platform -// SBOMs must exist, and the image must end up at the original digest in the destination registry. -func TestMultiPlatformIndexImage(t *testing.T) { +// TestIndexImage exercises digest-pinned multi-platform images end-to-end: +// create + publish + pull + deploy of a single-arch package with an image pulled by index digest +func TestIndexImage(t *testing.T) { t.Log("E2E: index-sha image preserved as the full upstream index") pkgDefinitionPath := filepath.Join("src", "test", "packages", "48-multi-platform-image") @@ -35,13 +34,12 @@ func TestMultiPlatformIndexImage(t *testing.T) { stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm") require.NoError(t, err, stdOut, stdErr) - createdPkgPath := filepath.Join(createDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") - require.FileExists(t, createdPkgPath) + createdPkgPath := filepath.Join(createDir, fmt.Sprintf("zarf-package-index-image-%s-0.0.1.tar.zst", e2e.Arch)) registryURL := testutil.SetupInMemoryRegistryDynamic(testutil.TestContext(t), t) ref := registry.Reference{ Registry: registryURL, - Repository: "multi-platform-image", + Repository: "index-image", Reference: "0.0.1", } @@ -52,7 +50,8 @@ func TestMultiPlatformIndexImage(t *testing.T) { stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir) require.NoError(t, err, stdOut, stdErr) - pulledPkgPath := filepath.Join(pullDir, "zarf-package-multi-platform-image-amd64-0.0.1.tar.zst") + pulledPkgPath := filepath.Join(pullDir, fmt.Sprintf("zarf-package-index-image-%s-0.0.1.tar.zst", e2e.Arch)) + pkgLayout, err := layout.LoadFromTar(t.Context(), pulledPkgPath, layout.PackageLayoutOptions{}) require.NoError(t, err) @@ -80,7 +79,7 @@ func TestMultiPlatformIndexImage(t *testing.T) { stdOut, stdErr, err = e2e.Zarf(t, "package", "deploy", pulledPkgPath, "--confirm", "--skip-version-check") require.NoError(t, err, stdOut, stdErr) t.Cleanup(func() { - _, _, err = e2e.Zarf(t, "package", "remove", "multi-platform-image", "--confirm", "--skip-version-check") + _, _, err = e2e.Zarf(t, "package", "remove", "index-image", "--confirm", "--skip-version-check") require.NoError(t, err) }) } diff --git a/src/test/packages/48-multi-platform-image/zarf.yaml b/src/test/packages/48-multi-platform-image/zarf.yaml index 4b0af1fc49..72a1067404 100644 --- a/src/test/packages/48-multi-platform-image/zarf.yaml +++ b/src/test/packages/48-multi-platform-image/zarf.yaml @@ -1,6 +1,6 @@ kind: ZarfPackageConfig metadata: - name: multi-platform-image + name: index-image description: Pin a container image by index digest; the entire index is preserved in the package. version: 0.0.1 architecture: amd64 From 00643ee60479451b06a0d7bb78d26c2810c0ad4c Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 08:42:16 -0400 Subject: [PATCH 15/27] better tests Signed-off-by: Austin Abro --- src/pkg/images/pull_test.go | 71 ++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 2b6645f897..25ab9828b9 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -12,6 +12,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/opencontainers/go-digest" @@ -23,6 +24,43 @@ import ( "oras.land/oras-go/v2/registry/remote" ) +// requireManifestBlobs asserts the manifest blob at digest is on disk in destDir along with its +// config and every layer. Returns the parsed manifest so callers can make test-specific assertions. +func requireManifestBlobs(t *testing.T, destDir, digest string) ocispec.Manifest { + t.Helper() + path := filepath.Join(destDir, "blobs", "sha256", strings.TrimPrefix(digest, "sha256:")) + require.FileExists(t, path) + b, err := os.ReadFile(path) + require.NoError(t, err) + var m ocispec.Manifest + require.NoError(t, json.Unmarshal(b, &m)) + require.FileExists(t, filepath.Join(destDir, "blobs", "sha256", m.Config.Digest.Hex())) + for _, layer := range m.Layers { + require.FileExists(t, filepath.Join(destDir, "blobs", "sha256", layer.Digest.Hex())) + } + return m +} + +// requireIndexBlobs asserts the index blob at digest is on disk and every descendant (nested +// indexes + leaf manifests with their config/layers) is too. Returns the parsed top-level index. +func requireIndexBlobs(t *testing.T, destDir, digest string) ocispec.Index { + t.Helper() + path := filepath.Join(destDir, "blobs", "sha256", strings.TrimPrefix(digest, "sha256:")) + require.FileExists(t, path) + b, err := os.ReadFile(path) + require.NoError(t, err) + var idx ocispec.Index + require.NoError(t, json.Unmarshal(b, &idx)) + for _, child := range idx.Manifests { + if IsIndex(child.MediaType) { + requireIndexBlobs(t, destDir, child.Digest.String()) + continue + } + requireManifestBlobs(t, destDir, child.Digest.String()) + } + return idx +} + // pushDockerManifestList pushes a Docker-mediaType manifest list to exercise isIndex's docker path. func pushDockerManifestList(ctx context.Context, t *testing.T, repo *remote.Repository, children []ocispec.Descriptor) ocispec.Descriptor { t.Helper() @@ -46,7 +84,6 @@ func pushDockerManifestList(ctx context.Context, t *testing.T, repo *remote.Repo return desc } -// FIXME: could probably delete this test func TestCheckForIndex(t *testing.T) { t.Parallel() ctx := testutil.TestContext(t) @@ -116,6 +153,22 @@ func TestCheckForIndex(t *testing.T) { } _, err = Pull(ctx, []transform.Image{refInfo}, dstDir, opts) require.NoError(t, err) + + idx, err := getIndexFromOCILayout(dstDir) + require.NoError(t, err) + var top *ocispec.Descriptor + for i := range idx.Manifests { + if idx.Manifests[i].Annotations[ocispec.AnnotationRefName] == refInfo.Reference { + top = &idx.Manifests[i] + break + } + } + require.NotNil(t, top, "no manifest tagged with ref %s in %v", refInfo.Reference, idx.Manifests) + if IsIndex(top.MediaType) { + requireIndexBlobs(t, dstDir, top.Digest.String()) + return + } + requireManifestBlobs(t, dstDir, top.Digest.String()) }) } } @@ -212,18 +265,10 @@ func TestPull(t *testing.T) { } require.ElementsMatch(t, expectedImageAnnotations, actualImageAnnotations) - // Walk the layout's index to verify every manifest's layers landed on disk. - for _, m := range idx.Manifests { - if !IsManifest(m.MediaType) { - continue - } - manifestPath := filepath.Join(destDir, "blobs", "sha256", m.Digest.Hex()) - body, err := os.ReadFile(manifestPath) - require.NoError(t, err) - var manifest ocispec.Manifest - require.NoError(t, json.Unmarshal(body, &manifest)) - for _, layer := range manifest.Layers { - require.FileExists(t, filepath.Join(destDir, fmt.Sprintf("blobs/sha256/%s", layer.Digest.Hex()))) + // Make sure all the layers of the image are pulled in (including the shared cache). + for _, manifestDesc := range idx.Manifests { + m := requireManifestBlobs(t, destDir, manifestDesc.Digest.String()) + for _, layer := range m.Layers { require.FileExists(t, filepath.Join(cacheDir, fmt.Sprintf("blobs/sha256/%s", layer.Digest.Hex()))) } } From 06f56321ee63267286f0678428b66ec3732fa553 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 08:46:38 -0400 Subject: [PATCH 16/27] nested index Signed-off-by: Austin Abro --- src/pkg/images/pull_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index 25ab9828b9..06fc2508c1 100644 --- a/src/pkg/images/pull_test.go +++ b/src/pkg/images/pull_test.go @@ -114,6 +114,8 @@ func TestCheckForIndex(t *testing.T) { dockerList := pushDockerManifestList(ctx, t, dockerRepo, dockerChildren) require.NoError(t, dockerRepo.Tag(ctx, dockerList, "v1")) + nestedIdxDigest := testutil.PushNestedIndex(ctx, t, upstream+"/fixtures/nested-idx", "v1", platforms) + manifestDigest := testutil.PushImage(ctx, t, upstream+"/fixtures/img", "v1") testCases := []struct { @@ -128,6 +130,10 @@ func TestCheckForIndex(t *testing.T) { name: "docker manifest list", ref: fmt.Sprintf("%s/fixtures/docker-list@%s", upstream, dockerList.Digest), }, + { + name: "nested oci index sha", + ref: fmt.Sprintf("%s/fixtures/nested-idx@%s", upstream, nestedIdxDigest), + }, { name: "image manifest by tag", ref: fmt.Sprintf("%s/fixtures/img:v1", upstream), From 928623ad818ef0a7b594082db379a70eb032c455 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 09:21:00 -0400 Subject: [PATCH 17/27] unpack implementation and test Signed-off-by: Austin Abro --- src/pkg/images/unpack.go | 67 +++++++++-- src/pkg/images/unpack_test.go | 113 +++++++++++++++++++ src/test/e2e/48_multi_platform_image_test.go | 6 +- 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/src/pkg/images/unpack.go b/src/pkg/images/unpack.go index e0d24ef8ae..1fe952267a 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -6,6 +6,7 @@ package images import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -20,6 +21,7 @@ import ( "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/oci" ) @@ -127,16 +129,19 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } logger.From(ctx).Info("pulling image from archive", "image", manifestImg.Reference, "archive", imageArchive.Path) - // Image-archive unpack is single-arch: when the archive holds an index, filter to the - // package architecture; single-manifest sources copy as-is. Index-sha preservation lives - // on the registry pull path (images.Pull), not here. - var platform *ocispec.Platform - if IsIndex(foundDesc.MediaType) { - platform = &ocispec.Platform{Architecture: arch, OS: "linux"} + // Mirror images.Pull: an index-digest reference preserves the full index (all platforms), + // while a tag or manifest-digest reference resolves to a single platform manifest. For + // indexes that's a recursive walk so nested indexes work. + copyDesc := manifestDesc + isIndexSha := manifestImg.Digest != "" && IsIndex(foundDesc.MediaType) + if IsIndex(foundDesc.MediaType) && !isIndexSha { + target := &ocispec.Platform{Architecture: arch, OS: "linux"} + copyDesc, err = resolvePlatformManifest(ctx, srcStore, manifestDesc, target) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s/%s manifest for %s: %w", target.OS, target.Architecture, manifestImg.Reference, err) + } } - copyOpts := oras.DefaultCopyOptions - copyOpts.WithTargetPlatform(platform) - desc, err := oras.Copy(ctx, srcStore, manifestDesc.Digest.String(), dstStore, manifestImg.Reference, copyOpts) + desc, err := oras.Copy(ctx, srcStore, copyDesc.Digest.String(), dstStore, manifestImg.Reference, oras.DefaultCopyOptions) if err != nil { return nil, fmt.Errorf("failed to copy image %s from archive %s: %w", manifestImg.Reference, imageArchive.Path, err) } @@ -161,6 +166,50 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str return pulledImages, nil } +// resolvePlatformManifest walks an index (recursing into nested indexes) and returns the first +// leaf manifest descriptor whose platform matches target. If root is already a manifest, it is +// returned unchanged. +func resolvePlatformManifest(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor, target *ocispec.Platform) (ocispec.Descriptor, error) { + if !IsIndex(root.MediaType) { + return root, nil + } + body, err := content.FetchAll(ctx, src, root) + if err != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to fetch index %s: %w", root.Digest, err) + } + var idx ocispec.Index + if err := json.Unmarshal(body, &idx); err != nil { + return ocispec.Descriptor{}, fmt.Errorf("failed to unmarshal index %s: %w", root.Digest, err) + } + for _, child := range idx.Manifests { + if IsManifest(child.MediaType) && platformMatches(child.Platform, target) { + return child, nil + } + } + for _, child := range idx.Manifests { + if !IsIndex(child.MediaType) { + continue + } + if desc, err := resolvePlatformManifest(ctx, src, child, target); err == nil { + return desc, nil + } + } + return ocispec.Descriptor{}, fmt.Errorf("no manifest matched platform %s/%s in index %s", target.OS, target.Architecture, root.Digest) +} + +func platformMatches(got, want *ocispec.Platform) bool { + if got == nil || want == nil { + return false + } + if want.Architecture != "" && got.Architecture != want.Architecture { + return false + } + if want.OS != "" && got.OS != want.OS { + return false + } + return true +} + // getRefFromManifest extracts the image reference from a manifest descriptor. func getRefFromManifest(manifestDesc ocispec.Descriptor) string { if manifestDesc.Annotations == nil { diff --git a/src/pkg/images/unpack_test.go b/src/pkg/images/unpack_test.go index 3aaa3c089f..f430fd7afd 100644 --- a/src/pkg/images/unpack_test.go +++ b/src/pkg/images/unpack_test.go @@ -7,6 +7,7 @@ package images import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "testing" @@ -15,7 +16,9 @@ import ( "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/pkg/archive" + "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/test/testutil" + "oras.land/oras-go/v2/content/oci" ) func TestGetRefFromManifest(t *testing.T) { @@ -178,3 +181,113 @@ func TestUnpackMultipleImages(t *testing.T) { }) } } + +func TestUnpackImageIndexes(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + + platforms := []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + multiArchDigest := testutil.PushMultiArchIndex(ctx, t, upstream+"/fixtures/multi", "v1", platforms) + nestedDigest := testutil.PushNestedIndex(ctx, t, upstream+"/fixtures/nested", "v1", platforms) + + multiArchDigestRef := fmt.Sprintf("%s/fixtures/multi@%s", upstream, multiArchDigest) + nestedDigestRef := fmt.Sprintf("%s/fixtures/nested@%s", upstream, nestedDigest) + multiArchTagRef := fmt.Sprintf("%s/fixtures/multi:v1", upstream) + + testCases := []struct { + name string + pullRef string + // retagAs, when non-empty, swaps the source layout's ref annotation from pullRef to this + // value so Unpack sees a tag-style ref over an existing multi-arch index. + retagAs string + unpackRef string + expectIndex bool + }{ + { + name: "multi-arch index by digest preserves index", + pullRef: multiArchDigestRef, + unpackRef: multiArchDigestRef, + expectIndex: true, + }, + { + name: "nested index by digest preserves nested structure", + pullRef: nestedDigestRef, + unpackRef: nestedDigestRef, + expectIndex: true, + }, + { + name: "multi-arch index by tag filters to platform", + pullRef: multiArchDigestRef, + retagAs: multiArchTagRef, + unpackRef: multiArchTagRef, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + pullRefInfo, err := transform.ParseImageRef(tc.pullRef) + require.NoError(t, err) + + layoutDir := t.TempDir() + _, err = Pull(ctx, []transform.Image{pullRefInfo}, layoutDir, PullOptions{ + Arch: "amd64", + CacheDirectory: t.TempDir(), + PlainHTTP: true, + }) + require.NoError(t, err) + + if tc.retagAs != "" { + store, err := oci.NewWithContext(ctx, layoutDir) + require.NoError(t, err) + desc, err := store.Resolve(ctx, tc.pullRef) + require.NoError(t, err) + require.NoError(t, store.Untag(ctx, tc.pullRef)) + require.NoError(t, store.Tag(ctx, desc, tc.retagAs)) + } + + tarFile := filepath.Join(t.TempDir(), "images.tar") + require.NoError(t, archive.Compress(ctx, []string{layoutDir}, tarFile, archive.CompressOpts{})) + + dstDir := t.TempDir() + unpacked, err := Unpack(ctx, v1alpha1.ImageArchive{ + Path: tarFile, + Images: []string{tc.unpackRef}, + }, dstDir, "amd64") + require.NoError(t, err) + require.Len(t, unpacked, 1) + require.Equal(t, tc.unpackRef, unpacked[0].Image.Reference) + + dstIdx, err := getIndexFromOCILayout(dstDir) + require.NoError(t, err) + var top *ocispec.Descriptor + for i := range dstIdx.Manifests { + if dstIdx.Manifests[i].Annotations[ocispec.AnnotationRefName] == tc.unpackRef { + top = &dstIdx.Manifests[i] + break + } + } + require.NotNil(t, top, "no manifest tagged with ref %s in %v", tc.unpackRef, dstIdx.Manifests) + + if tc.expectIndex { + require.True(t, IsIndex(top.MediaType), "expected preserved index, got %s", top.MediaType) + preserved := requireIndexBlobs(t, dstDir, top.Digest.String()) + require.NotEmpty(t, preserved.Manifests) + return + } + + require.True(t, IsManifest(top.MediaType), "expected platform-filtered manifest, got %s", top.MediaType) + manifest := requireManifestBlobs(t, dstDir, top.Digest.String()) + cfgBytes, err := os.ReadFile(filepath.Join(dstDir, "blobs", "sha256", manifest.Config.Digest.Hex())) + require.NoError(t, err) + var cfg ocispec.Image + require.NoError(t, json.Unmarshal(cfgBytes, &cfg)) + require.Equal(t, "amd64", cfg.Architecture) + }) + } +} diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go index 2cdc32fb53..29bb7cef26 100644 --- a/src/test/e2e/48_multi_platform_image_test.go +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -5,7 +5,6 @@ package test import ( "encoding/json" - "fmt" "os" "path/filepath" "strings" @@ -34,7 +33,8 @@ func TestIndexImage(t *testing.T) { stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm") require.NoError(t, err, stdOut, stdErr) - createdPkgPath := filepath.Join(createDir, fmt.Sprintf("zarf-package-index-image-%s-0.0.1.tar.zst", e2e.Arch)) + // Since there's only one image, tagged by index digest this will also work on arm64 + createdPkgPath := filepath.Join(createDir, "zarf-package-index-image-amd64-0.0.1.tar.zst") registryURL := testutil.SetupInMemoryRegistryDynamic(testutil.TestContext(t), t) ref := registry.Reference{ @@ -50,7 +50,7 @@ func TestIndexImage(t *testing.T) { stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir) require.NoError(t, err, stdOut, stdErr) - pulledPkgPath := filepath.Join(pullDir, fmt.Sprintf("zarf-package-index-image-%s-0.0.1.tar.zst", e2e.Arch)) + pulledPkgPath := filepath.Join(pullDir, "zarf-package-index-image-amd64-0.0.1.tar.zst") pkgLayout, err := layout.LoadFromTar(t.Context(), pulledPkgPath, layout.PackageLayoutOptions{}) require.NoError(t, err) From ef6dc1bfe12680be8cfcb2eab763bf38e2f38a3f Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 09:32:52 -0400 Subject: [PATCH 18/27] improve unpack tests Signed-off-by: Austin Abro --- src/pkg/images/unpack_test.go | 130 ++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/src/pkg/images/unpack_test.go b/src/pkg/images/unpack_test.go index f430fd7afd..43fba67910 100644 --- a/src/pkg/images/unpack_test.go +++ b/src/pkg/images/unpack_test.go @@ -194,36 +194,17 @@ func TestUnpackImageIndexes(t *testing.T) { multiArchDigest := testutil.PushMultiArchIndex(ctx, t, upstream+"/fixtures/multi", "v1", platforms) nestedDigest := testutil.PushNestedIndex(ctx, t, upstream+"/fixtures/nested", "v1", platforms) - multiArchDigestRef := fmt.Sprintf("%s/fixtures/multi@%s", upstream, multiArchDigest) - nestedDigestRef := fmt.Sprintf("%s/fixtures/nested@%s", upstream, nestedDigest) - multiArchTagRef := fmt.Sprintf("%s/fixtures/multi:v1", upstream) - testCases := []struct { - name string - pullRef string - // retagAs, when non-empty, swaps the source layout's ref annotation from pullRef to this - // value so Unpack sees a tag-style ref over an existing multi-arch index. - retagAs string - unpackRef string - expectIndex bool + name string + ref string }{ { - name: "multi-arch index by digest preserves index", - pullRef: multiArchDigestRef, - unpackRef: multiArchDigestRef, - expectIndex: true, - }, - { - name: "nested index by digest preserves nested structure", - pullRef: nestedDigestRef, - unpackRef: nestedDigestRef, - expectIndex: true, + name: "multi-arch index by digest preserves index", + ref: fmt.Sprintf("%s/fixtures/multi@%s", upstream, multiArchDigest), }, { - name: "multi-arch index by tag filters to platform", - pullRef: multiArchDigestRef, - retagAs: multiArchTagRef, - unpackRef: multiArchTagRef, + name: "nested index by digest preserves nested structure", + ref: fmt.Sprintf("%s/fixtures/nested@%s", upstream, nestedDigest), }, } @@ -231,63 +212,104 @@ func TestUnpackImageIndexes(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - pullRefInfo, err := transform.ParseImageRef(tc.pullRef) + refInfo, err := transform.ParseImageRef(tc.ref) require.NoError(t, err) layoutDir := t.TempDir() - _, err = Pull(ctx, []transform.Image{pullRefInfo}, layoutDir, PullOptions{ + _, err = Pull(ctx, []transform.Image{refInfo}, layoutDir, PullOptions{ Arch: "amd64", CacheDirectory: t.TempDir(), PlainHTTP: true, }) require.NoError(t, err) - if tc.retagAs != "" { - store, err := oci.NewWithContext(ctx, layoutDir) - require.NoError(t, err) - desc, err := store.Resolve(ctx, tc.pullRef) - require.NoError(t, err) - require.NoError(t, store.Untag(ctx, tc.pullRef)) - require.NoError(t, store.Tag(ctx, desc, tc.retagAs)) - } - tarFile := filepath.Join(t.TempDir(), "images.tar") require.NoError(t, archive.Compress(ctx, []string{layoutDir}, tarFile, archive.CompressOpts{})) dstDir := t.TempDir() unpacked, err := Unpack(ctx, v1alpha1.ImageArchive{ Path: tarFile, - Images: []string{tc.unpackRef}, + Images: []string{tc.ref}, }, dstDir, "amd64") require.NoError(t, err) require.Len(t, unpacked, 1) - require.Equal(t, tc.unpackRef, unpacked[0].Image.Reference) + require.Equal(t, tc.ref, unpacked[0].Image.Reference) dstIdx, err := getIndexFromOCILayout(dstDir) require.NoError(t, err) var top *ocispec.Descriptor for i := range dstIdx.Manifests { - if dstIdx.Manifests[i].Annotations[ocispec.AnnotationRefName] == tc.unpackRef { + if dstIdx.Manifests[i].Annotations[ocispec.AnnotationRefName] == tc.ref { top = &dstIdx.Manifests[i] break } } - require.NotNil(t, top, "no manifest tagged with ref %s in %v", tc.unpackRef, dstIdx.Manifests) + require.NotNil(t, top, "no manifest tagged with ref %s in %v", tc.ref, dstIdx.Manifests) + require.True(t, IsIndex(top.MediaType), "expected preserved index, got %s", top.MediaType) + preserved := requireIndexBlobs(t, dstDir, top.Digest.String()) + require.NotEmpty(t, preserved.Manifests) + }) + } +} - if tc.expectIndex { - require.True(t, IsIndex(top.MediaType), "expected preserved index, got %s", top.MediaType) - preserved := requireIndexBlobs(t, dstDir, top.Digest.String()) - require.NotEmpty(t, preserved.Manifests) - return - } +func TestUnpackTaggedIndexFiltersToPlatform(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) - require.True(t, IsManifest(top.MediaType), "expected platform-filtered manifest, got %s", top.MediaType) - manifest := requireManifestBlobs(t, dstDir, top.Digest.String()) - cfgBytes, err := os.ReadFile(filepath.Join(dstDir, "blobs", "sha256", manifest.Config.Digest.Hex())) - require.NoError(t, err) - var cfg ocispec.Image - require.NoError(t, json.Unmarshal(cfgBytes, &cfg)) - require.Equal(t, "amd64", cfg.Architecture) - }) + platforms := []ocispec.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + digest := testutil.PushMultiArchIndex(ctx, t, upstream+"/fixtures/multi", "v1", platforms) + digestRef := fmt.Sprintf("%s/fixtures/multi@%s", upstream, digest) + tagRef := fmt.Sprintf("%s/fixtures/multi:v1", upstream) + + digestRefInfo, err := transform.ParseImageRef(digestRef) + require.NoError(t, err) + + layoutDir := t.TempDir() + _, err = Pull(ctx, []transform.Image{digestRefInfo}, layoutDir, PullOptions{ + Arch: "amd64", + CacheDirectory: t.TempDir(), + PlainHTTP: true, + }) + require.NoError(t, err) + + store, err := oci.NewWithContext(ctx, layoutDir) + require.NoError(t, err) + desc, err := store.Resolve(ctx, digestRef) + require.NoError(t, err) + require.NoError(t, store.Untag(ctx, digestRef)) + require.NoError(t, store.Tag(ctx, desc, tagRef)) + + tarFile := filepath.Join(t.TempDir(), "images.tar") + require.NoError(t, archive.Compress(ctx, []string{layoutDir}, tarFile, archive.CompressOpts{})) + + dstDir := t.TempDir() + unpacked, err := Unpack(ctx, v1alpha1.ImageArchive{ + Path: tarFile, + Images: []string{tagRef}, + }, dstDir, "amd64") + require.NoError(t, err) + require.Len(t, unpacked, 1) + require.Equal(t, tagRef, unpacked[0].Image.Reference) + + dstIdx, err := getIndexFromOCILayout(dstDir) + require.NoError(t, err) + var top *ocispec.Descriptor + for i := range dstIdx.Manifests { + if dstIdx.Manifests[i].Annotations[ocispec.AnnotationRefName] == tagRef { + top = &dstIdx.Manifests[i] + break + } } + require.NotNil(t, top, "no manifest tagged with ref %s in %v", tagRef, dstIdx.Manifests) + require.True(t, IsManifest(top.MediaType), "expected platform-filtered manifest, got %s", top.MediaType) + manifest := requireManifestBlobs(t, dstDir, top.Digest.String()) + cfgBytes, err := os.ReadFile(filepath.Join(dstDir, "blobs", "sha256", manifest.Config.Digest.Hex())) + require.NoError(t, err) + var cfg ocispec.Image + require.NoError(t, json.Unmarshal(cfgBytes, &cfg)) + require.Equal(t, "amd64", cfg.Architecture) } From b8cf9dbad42c54db349fca1ce261576c9ad8430b Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 09:43:39 -0400 Subject: [PATCH 19/27] simplify unpack greatly Signed-off-by: Austin Abro --- src/pkg/images/unpack.go | 63 +++++----------------------------------- 1 file changed, 7 insertions(+), 56 deletions(-) diff --git a/src/pkg/images/unpack.go b/src/pkg/images/unpack.go index 1fe952267a..68cd235e75 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -6,7 +6,6 @@ package images import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -21,7 +20,6 @@ import ( "github.com/zarf-dev/zarf/src/pkg/transform" "github.com/zarf-dev/zarf/src/pkg/utils" "oras.land/oras-go/v2" - "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/content/oci" ) @@ -129,19 +127,16 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } logger.From(ctx).Info("pulling image from archive", "image", manifestImg.Reference, "archive", imageArchive.Path) - // Mirror images.Pull: an index-digest reference preserves the full index (all platforms), - // while a tag or manifest-digest reference resolves to a single platform manifest. For - // indexes that's a recursive walk so nested indexes work. - copyDesc := manifestDesc + // Mirror images.Pull: an index-digest reference preserves the full index, while a tag or + // manifest-digest reference is filtered down to a single platform manifest by oras.Copy. + var platform *ocispec.Platform isIndexSha := manifestImg.Digest != "" && IsIndex(foundDesc.MediaType) if IsIndex(foundDesc.MediaType) && !isIndexSha { - target := &ocispec.Platform{Architecture: arch, OS: "linux"} - copyDesc, err = resolvePlatformManifest(ctx, srcStore, manifestDesc, target) - if err != nil { - return nil, fmt.Errorf("failed to resolve %s/%s manifest for %s: %w", target.OS, target.Architecture, manifestImg.Reference, err) - } + platform = &ocispec.Platform{Architecture: arch, OS: "linux"} } - desc, err := oras.Copy(ctx, srcStore, copyDesc.Digest.String(), dstStore, manifestImg.Reference, oras.DefaultCopyOptions) + copyOpts := oras.DefaultCopyOptions + copyOpts.WithTargetPlatform(platform) + desc, err := oras.Copy(ctx, srcStore, manifestDesc.Digest.String(), dstStore, manifestImg.Reference, copyOpts) if err != nil { return nil, fmt.Errorf("failed to copy image %s from archive %s: %w", manifestImg.Reference, imageArchive.Path, err) } @@ -166,50 +161,6 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str return pulledImages, nil } -// resolvePlatformManifest walks an index (recursing into nested indexes) and returns the first -// leaf manifest descriptor whose platform matches target. If root is already a manifest, it is -// returned unchanged. -func resolvePlatformManifest(ctx context.Context, src content.ReadOnlyStorage, root ocispec.Descriptor, target *ocispec.Platform) (ocispec.Descriptor, error) { - if !IsIndex(root.MediaType) { - return root, nil - } - body, err := content.FetchAll(ctx, src, root) - if err != nil { - return ocispec.Descriptor{}, fmt.Errorf("failed to fetch index %s: %w", root.Digest, err) - } - var idx ocispec.Index - if err := json.Unmarshal(body, &idx); err != nil { - return ocispec.Descriptor{}, fmt.Errorf("failed to unmarshal index %s: %w", root.Digest, err) - } - for _, child := range idx.Manifests { - if IsManifest(child.MediaType) && platformMatches(child.Platform, target) { - return child, nil - } - } - for _, child := range idx.Manifests { - if !IsIndex(child.MediaType) { - continue - } - if desc, err := resolvePlatformManifest(ctx, src, child, target); err == nil { - return desc, nil - } - } - return ocispec.Descriptor{}, fmt.Errorf("no manifest matched platform %s/%s in index %s", target.OS, target.Architecture, root.Digest) -} - -func platformMatches(got, want *ocispec.Platform) bool { - if got == nil || want == nil { - return false - } - if want.Architecture != "" && got.Architecture != want.Architecture { - return false - } - if want.OS != "" && got.OS != want.OS { - return false - } - return true -} - // getRefFromManifest extracts the image reference from a manifest descriptor. func getRefFromManifest(manifestDesc ocispec.Descriptor) string { if manifestDesc.Annotations == nil { From 89e764b4d1dfafad09dc47c82944f48c74d7f356 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 11:09:52 -0400 Subject: [PATCH 20/27] fix annotation ref Signed-off-by: Austin Abro --- src/pkg/images/unpack.go | 17 ++++++++++++----- src/pkg/images/unpack_test.go | 12 +++++++++++- src/test/e2e/48_multi_platform_image_test.go | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/pkg/images/unpack.go b/src/pkg/images/unpack.go index 68cd235e75..afa803fdbd 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/defenseunicorns/pkg/helpers/v2" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -31,8 +32,10 @@ type PulledImage struct { const ( // This is the default docker annotation for the image name dockerRefAnnotation = "io.containerd.image.name" - // When the Docker engine containerd image store is used only this annotation is used for sha referenced images - dockerContainerdImageStoreAnnotation = "containerd.io/distribution.source.docker.io" + // Prefix used by the Docker containerd image store to identify the registry an image + // was pulled from. The suffix after the prefix is the registry host (e.g. "docker.io", + // "ghcr.io"); the value is the repository path within that registry. + containerdDistributionSourcePrefix = "containerd.io/distribution.source." ) // Unpack extracts an image tar and loads it into an OCI layout directory. @@ -151,7 +154,7 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } explainErr := fmt.Sprintf("image references are determined by the inclusion of one of the following "+ - "annotations in the index.json: %s, %s, %s", dockerRefAnnotation, dockerContainerdImageStoreAnnotation, ocispec.AnnotationRefName) + "annotations in the index.json: %s, %s, %s", dockerRefAnnotation, containerdDistributionSourcePrefix, ocispec.AnnotationRefName) for img, found := range requestedImages { if !found { return nil, fmt.Errorf("could not find image %s: found images %s: %s", img, foundImages, explainErr) @@ -171,8 +174,12 @@ func getRefFromManifest(manifestDesc ocispec.Descriptor) string { return ref } - if repo, ok := manifestDesc.Annotations[dockerContainerdImageStoreAnnotation]; ok && repo != "" { - return fmt.Sprintf("%s@%s", repo, manifestDesc.Digest.String()) + for k, v := range manifestDesc.Annotations { + registry, ok := strings.CutPrefix(k, containerdDistributionSourcePrefix) + if !ok || registry == "" || v == "" { + continue + } + return fmt.Sprintf("%s/%s@%s", registry, v, manifestDesc.Digest.String()) } // This is the annotation oras-go uses to check for the name during oras.copy diff --git a/src/pkg/images/unpack_test.go b/src/pkg/images/unpack_test.go index 43fba67910..34eb76684c 100644 --- a/src/pkg/images/unpack_test.go +++ b/src/pkg/images/unpack_test.go @@ -49,7 +49,17 @@ func TestGetRefFromManifest(t *testing.T) { "containerd.io/distribution.source.docker.io": "library/nginx", }, }, - expected: "library/nginx@sha256:b20377b80653db287c2047b8effbd2458d045ee9c43098cf57d769fd6fc1a110", + expected: "docker.io/library/nginx@sha256:b20377b80653db287c2047b8effbd2458d045ee9c43098cf57d769fd6fc1a110", + }, + { + name: "non-docker.io distribution source uses its registry", + desc: ocispec.Descriptor{ + Digest: "sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c", + Annotations: map[string]string{ + "containerd.io/distribution.source.ghcr.io": "zarf-dev/images/alpine", + }, + }, + expected: "ghcr.io/zarf-dev/images/alpine@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c", }, { name: "org.opencontainers.image.ref.name present", diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go index 29bb7cef26..841bc1b798 100644 --- a/src/test/e2e/48_multi_platform_image_test.go +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -47,7 +47,7 @@ func TestIndexImage(t *testing.T) { require.NoError(t, err, stdOut, stdErr) pullDir := t.TempDir() - stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir) + stdOut, stdErr, err = e2e.Zarf(t, "package", "pull", "oci://"+ref.String(), "--plain-http", "-o", pullDir, "-a", "amd64") require.NoError(t, err, stdOut, stdErr) pulledPkgPath := filepath.Join(pullDir, "zarf-package-index-image-amd64-0.0.1.tar.zst") From 4465ebb91f4c61edbc8ddd88f90d1c7b00c63798 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 11:28:27 -0400 Subject: [PATCH 21/27] add dot Signed-off-by: Austin Abro --- src/pkg/images/unpack.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/images/unpack.go b/src/pkg/images/unpack.go index afa803fdbd..7cfb1225a4 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -154,7 +154,7 @@ func Unpack(ctx context.Context, imageArchive v1alpha1.ImageArchive, destDir str } explainErr := fmt.Sprintf("image references are determined by the inclusion of one of the following "+ - "annotations in the index.json: %s, %s, %s", dockerRefAnnotation, containerdDistributionSourcePrefix, ocispec.AnnotationRefName) + "annotations in the index.json: %s, %s., %s", dockerRefAnnotation, containerdDistributionSourcePrefix, ocispec.AnnotationRefName) for img, found := range requestedImages { if !found { return nil, fmt.Errorf("could not find image %s: found images %s: %s", img, foundImages, explainErr) From 7edcc69ae807ba9f92ff6ba79c29b01ce32fe3ef Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 11:40:04 -0400 Subject: [PATCH 22/27] add test Signed-off-by: Austin Abro --- src/pkg/packager/layout/assemble.go | 9 +-- src/pkg/packager/layout/assemble_test.go | 71 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index e19891e184..1f02fccc34 100644 --- a/src/pkg/packager/layout/assemble.go +++ b/src/pkg/packager/layout/assemble.go @@ -155,8 +155,6 @@ func AssemblePackage(ctx context.Context, pkg v1alpha1.ZarfPackage, packagePath } for _, pulled := range manifests { - // Hand every pulled image to the SBOM step; the per-platform manifest filter (skip helm - // charts, etc.) lives in generateSBOM where each platform manifest is inspected directly. sbomImageList = append(sbomImageList, pulled.Image) // Sort images index to make build reproducible. @@ -854,9 +852,6 @@ func recordPackageMetadata(pkg v1alpha1.ZarfPackage, flavor string, registryOver return pkg, nil } -// collectVersionRequirements returns the minimum-Zarf-version requirements implied by a package's -// contents. hasIndex reports whether the assembled image layout preserves a multi-platform image -// index — that information lives on disk, so callers compute it before invoking this. func collectVersionRequirements(pkg v1alpha1.ZarfPackage, hasIndex bool) []v1alpha1.VersionRequirement { var reqs []v1alpha1.VersionRequirement for _, comp := range pkg.Components { @@ -1110,10 +1105,8 @@ func createDocumentationTar(pkg v1alpha1.ZarfPackage, packagePath, buildPath str return nil } -// imageLayoutHasIndex reports whether any top-level entry in the OCI layout's index.json is an -// image index — i.e. the layout preserves a multi-platform image graph. func imageLayoutHasIndex(imageDir string) (bool, error) { - idxPath := filepath.Join(imageDir, "index.json") + idxPath := filepath.Join(imageDir, IndexJSON) b, err := os.ReadFile(idxPath) if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/src/pkg/packager/layout/assemble_test.go b/src/pkg/packager/layout/assemble_test.go index a9854ac011..4a3d372691 100644 --- a/src/pkg/packager/layout/assemble_test.go +++ b/src/pkg/packager/layout/assemble_test.go @@ -9,8 +9,10 @@ import ( "testing" "github.com/defenseunicorns/pkg/helpers/v2" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/require" "github.com/zarf-dev/zarf/src/api/v1alpha1" + "github.com/zarf-dev/zarf/src/pkg/images" ) func TestGetChecksum(t *testing.T) { @@ -293,3 +295,72 @@ func TestCollectVersionRequirements(t *testing.T) { }) } } + +func TestImageLayoutHasIndex(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + indexJSON string + writeFile bool + expected bool + errContains string + }{ + { + name: "missing index.json returns false", + writeFile: false, + expected: false, + }, + { + name: "empty manifests returns false", + writeFile: true, + indexJSON: `{"schemaVersion":2,"manifests":[]}`, + expected: false, + }, + { + name: "only image manifests returns false", + writeFile: true, + indexJSON: `{"schemaVersion":2,"manifests":[{"mediaType":"` + ocispec.MediaTypeImageManifest + `","digest":"sha256:abc","size":1}]}`, + expected: false, + }, + { + name: "OCI image index returns true", + writeFile: true, + indexJSON: `{"schemaVersion":2,"manifests":[{"mediaType":"` + ocispec.MediaTypeImageIndex + `","digest":"sha256:abc","size":1}]}`, + expected: true, + }, + { + name: "docker manifest list returns true", + writeFile: true, + indexJSON: `{"schemaVersion":2,"manifests":[{"mediaType":"` + images.DockerMediaTypeManifestList + `","digest":"sha256:abc","size":1}]}`, + expected: true, + }, + { + name: "malformed JSON returns error", + writeFile: true, + indexJSON: `{not valid json`, + expected: false, + errContains: "failed to parse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if tt.writeFile { + err := os.WriteFile(filepath.Join(dir, IndexJSON), []byte(tt.indexJSON), 0o600) + require.NoError(t, err) + } + + got, err := imageLayoutHasIndex(dir) + if tt.errContains != "" { + require.ErrorContains(t, err, tt.errContains) + return + } + require.NoError(t, err) + require.Equal(t, tt.expected, got) + }) + } +} From 932b8c74694e049e0eaee61ae3c687fe1e5edacc Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 11:42:42 -0400 Subject: [PATCH 23/27] simplify test Signed-off-by: Austin Abro --- src/test/e2e/48_multi_platform_image_test.go | 28 -------------------- 1 file changed, 28 deletions(-) diff --git a/src/test/e2e/48_multi_platform_image_test.go b/src/test/e2e/48_multi_platform_image_test.go index 841bc1b798..2525f8c1d0 100644 --- a/src/test/e2e/48_multi_platform_image_test.go +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -19,9 +19,6 @@ import ( "github.com/zarf-dev/zarf/src/test/testutil" ) -// podinfoIndexDigest is the index digest of ghcr.io/stefanprodan/podinfo:6.4.0. -const podinfoIndexDigest = "sha256:57a654ace69ec02ba8973093b6a786faa15640575fbf0dbb603db55aca2ccec8" - // TestIndexImage exercises digest-pinned multi-platform images end-to-end: // create + publish + pull + deploy of a single-arch package with an image pulled by index digest func TestIndexImage(t *testing.T) { @@ -60,9 +57,6 @@ func TestIndexImage(t *testing.T) { var idx ocispec.Index require.NoError(t, json.Unmarshal(idxBytes, &idx)) - digestedRoot := verifyPreservedIndex(t, pkgLayout, idx, podinfoIndexDigest) - require.Equal(t, podinfoIndexDigest, digestedRoot, "index-pinned image must keep its original index digest in the package layout") - sbomDir := t.TempDir() require.NoError(t, pkgLayout.GetSBOM(t.Context(), sbomDir)) sbomEntries, err := os.ReadDir(sbomDir) @@ -83,25 +77,3 @@ func TestIndexImage(t *testing.T) { require.NoError(t, err) }) } - -// verifyPreservedIndex finds the top-level index.json entry whose ref-name annotation matches -// imageSubstring, asserts it points at an OCI image index with multiple platform manifests -// stored on disk, and returns the underlying index digest. -func verifyPreservedIndex(t *testing.T, pkgLayout *layout.PackageLayout, topIdx ocispec.Index, imageSubstring string) string { - t.Helper() - for _, m := range topIdx.Manifests { - if !strings.Contains(m.Annotations[ocispec.AnnotationRefName], imageSubstring) { - continue - } - require.Equal(t, ocispec.MediaTypeImageIndex, m.MediaType, "image %s must be stored as an OCI index", imageSubstring) - blobPath := filepath.Join(pkgLayout.GetImageDirPath(), "blobs", "sha256", strings.TrimPrefix(m.Digest.String(), "sha256:")) - b, err := os.ReadFile(blobPath) - require.NoError(t, err) - var pulledIdx ocispec.Index - require.NoError(t, json.Unmarshal(b, &pulledIdx)) - require.Greater(t, len(pulledIdx.Manifests), 1, "expected multiple platform manifests under the %s index", imageSubstring) - return m.Digest.String() - } - t.Fatalf("expected to find %s in the package layout", imageSubstring) - return "" -} From ea68c827d86866c6fff5b6e2cca69fad3a411d3b Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 11:45:56 -0400 Subject: [PATCH 24/27] unnecessary comment Signed-off-by: Austin Abro --- src/pkg/zoci/pull.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/pkg/zoci/pull.go b/src/pkg/zoci/pull.go index 1a4f932557..903b8e9e9b 100644 --- a/src/pkg/zoci/pull.go +++ b/src/pkg/zoci/pull.go @@ -215,10 +215,6 @@ func (r *Remote) LayersFromImages(ctx context.Context, imageList map[string]bool return oci.RemoveDuplicateDescriptors(layers), nil } -// layersFromIndexChildren walks an OCI image index's children and returns every blob -// (child manifests, their configs, and their layers) that must be pulled alongside the index. -// Recurses into nested indexes — the OCI spec allows an index entry to point at either -// an image manifest or another image index. func (r *Remote) layersFromIndexChildren(ctx context.Context, root *oci.Manifest, indexDesc ocispec.Descriptor) ([]ocispec.Descriptor, error) { idx, err := oci.FetchJSONFile[*ocispec.Index](ctx, r.FetchLayer, root, filepath.Join(layout.ImagesBlobsDir, indexDesc.Digest.Encoded())) if err != nil { From 981659ea7f20306374c4a9811401bfc8fb028f4e Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 12:19:06 -0400 Subject: [PATCH 25/27] layers from manifest children Signed-off-by: Austin Abro --- src/pkg/zoci/pull.go | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/pkg/zoci/pull.go b/src/pkg/zoci/pull.go index 903b8e9e9b..e9ae846927 100644 --- a/src/pkg/zoci/pull.go +++ b/src/pkg/zoci/pull.go @@ -197,16 +197,11 @@ func (r *Remote) LayersFromImages(ctx context.Context, imageList map[string]bool } layers = append(layers, childLayers...) case images.IsManifest(entry.MediaType): - // even though these are technically image manifests, we store them as Zarf blobs - entry.MediaType = ZarfLayerMediaTypeBlob - manifest, err := r.FetchManifest(ctx, entry) + manifestLayers, err := r.layersFromManifestChildren(ctx, root, entry) if err != nil { return nil, err } - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifest.Config.Digest.Encoded()))) - for _, layer := range manifest.Layers { - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()))) - } + layers = append(layers, manifestLayers...) default: return nil, fmt.Errorf("unexpected media type %q for image %s", entry.MediaType, entry.Digest) } @@ -215,6 +210,21 @@ func (r *Remote) LayersFromImages(ctx context.Context, imageList map[string]bool return oci.RemoveDuplicateDescriptors(layers), nil } +func (r *Remote) layersFromManifestChildren(ctx context.Context, root *oci.Manifest, manifestDesc ocispec.Descriptor) ([]ocispec.Descriptor, error) { + manifest, err := oci.FetchJSONFile[*ocispec.Manifest](ctx, r.FetchLayer, root, filepath.Join(layout.ImagesBlobsDir, manifestDesc.Digest.Encoded())) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest %s: %w", manifestDesc.Digest, err) + } + layers := make([]ocispec.Descriptor, 0, len(manifest.Layers)+1) + if manifest.Config.Digest != "" { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifest.Config.Digest.Encoded()))) + } + for _, layer := range manifest.Layers { + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()))) + } + return layers, nil +} + func (r *Remote) layersFromIndexChildren(ctx context.Context, root *oci.Manifest, indexDesc ocispec.Descriptor) ([]ocispec.Descriptor, error) { idx, err := oci.FetchJSONFile[*ocispec.Index](ctx, r.FetchLayer, root, filepath.Join(layout.ImagesBlobsDir, indexDesc.Digest.Encoded())) if err != nil { @@ -231,18 +241,11 @@ func (r *Remote) layersFromIndexChildren(ctx context.Context, root *oci.Manifest } layers = append(layers, nestedLayers...) case images.IsManifest(child.MediaType): - childWithBlobType := child - childWithBlobType.MediaType = ZarfLayerMediaTypeBlob - childManifest, err := r.FetchManifest(ctx, childWithBlobType) + manifestLayers, err := r.layersFromManifestChildren(ctx, root, child) if err != nil { - return nil, fmt.Errorf("failed to fetch child manifest %s: %w", child.Digest, err) - } - if childManifest.Config.Digest != "" { - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, childManifest.Config.Digest.Encoded()))) - } - for _, layer := range childManifest.Layers { - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, layer.Digest.Encoded()))) + return nil, err } + layers = append(layers, manifestLayers...) default: return nil, fmt.Errorf("unexpected media type %q for index child %s", child.MediaType, child.Digest) } From d53e25f19995297eb672967e5556baf4bea892ad Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Fri, 8 May 2026 14:03:44 -0400 Subject: [PATCH 26/27] add entry check Signed-off-by: Austin Abro --- src/pkg/zoci/pull.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pkg/zoci/pull.go b/src/pkg/zoci/pull.go index e9ae846927..8031284e8a 100644 --- a/src/pkg/zoci/pull.go +++ b/src/pkg/zoci/pull.go @@ -187,6 +187,10 @@ func (r *Remote) LayersFromImages(ctx context.Context, imageList map[string]bool (layer.Annotations[ocispec.AnnotationBaseImageName] == refInfo.Path+refInfo.TagOrDigest && refInfo.Host == "docker.io") }) + if entry.Digest == "" { + return nil, fmt.Errorf("image %q not found in package index", refInfo.Reference) + } + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, entry.Digest.Encoded()))) switch { From e939b27c274f21c113e0216816f89f9740192628 Mon Sep 17 00:00:00 2001 From: Austin Abro Date: Wed, 13 May 2026 14:32:41 -0400 Subject: [PATCH 27/27] avoid counting duplicates Signed-off-by: Austin Abro --- src/pkg/images/common.go | 57 ++++++++++---- src/pkg/images/common_test.go | 123 +++++++++++++++++++++++++++++++ src/test/testutil/ociregistry.go | 12 ++- 3 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 src/pkg/images/common_test.go diff --git a/src/pkg/images/common.go b/src/pkg/images/common.go index f62c2b1d27..75e49cc469 100644 --- a/src/pkg/images/common.go +++ b/src/pkg/images/common.go @@ -18,6 +18,7 @@ import ( "github.com/defenseunicorns/pkg/helpers/v2" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/crane" + "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/zarf-dev/zarf/src/pkg/logger" "github.com/zarf-dev/zarf/src/pkg/state" @@ -244,43 +245,45 @@ func getSizeOfManifest(manifestDesc ocispec.Descriptor, manifestBytes []byte) (i } // inspectIndex walks an OCI image index (recursing into nested indexes) and returns the total -// byte size of every referenced blob and one "arch[/variant]" string per leaf manifest. +// byte size of every uniquely-referenced blob and one "arch[/variant]" string per leaf manifest. func inspectIndex(ctx context.Context, fetcher content.Fetcher, indexDesc ocispec.Descriptor, indexBytes []byte) (int64, []string, error) { + return walkIndex(ctx, fetcher, indexDesc, indexBytes, map[digest.Digest]struct{}{}) +} + +func walkIndex(ctx context.Context, fetcher content.Fetcher, indexDesc ocispec.Descriptor, indexBytes []byte, seen map[digest.Digest]struct{}) (int64, []string, error) { + if _, ok := seen[indexDesc.Digest]; ok { + return 0, nil, nil + } + seen[indexDesc.Digest] = struct{}{} var idx ocispec.Index if err := json.Unmarshal(indexBytes, &idx); err != nil { return 0, nil, fmt.Errorf("unable to unmarshal index: %w", err) } - childSize, platforms, err := sumManifestsSize(ctx, fetcher, idx.Manifests) - if err != nil { - return 0, nil, err - } - return indexDesc.Size + childSize, platforms, nil -} - -// sumManifestsSize walks each descriptor (recursing into nested indexes) and totals up the byte -// size of every referenced blob plus one "arch[/variant]" string per leaf manifest. -func sumManifestsSize(ctx context.Context, fetcher content.Fetcher, manifests []ocispec.Descriptor) (int64, []string, error) { - var totalSize int64 + totalSize := indexDesc.Size var platforms []string - for _, child := range manifests { + for _, child := range idx.Manifests { + if _, ok := seen[child.Digest]; ok { + continue + } switch { case IsIndex(child.MediaType): b, err := content.FetchAll(ctx, fetcher, child) if err != nil { return 0, nil, fmt.Errorf("failed to fetch nested index %s: %w", child.Digest, err) } - size, childPlatforms, err := inspectIndex(ctx, fetcher, child, b) + size, childPlatforms, err := walkIndex(ctx, fetcher, child, b, seen) if err != nil { return 0, nil, err } totalSize += size platforms = append(platforms, childPlatforms...) case IsManifest(child.MediaType): + seen[child.Digest] = struct{}{} b, err := content.FetchAll(ctx, fetcher, child) if err != nil { return 0, nil, fmt.Errorf("failed to fetch child manifest %s: %w", child.Digest, err) } - size, err := getSizeOfManifest(child, b) + size, err := uniqueManifestSize(child, b, seen) if err != nil { return 0, nil, err } @@ -289,8 +292,32 @@ func sumManifestsSize(ctx context.Context, fetcher content.Fetcher, manifests [] platforms = append(platforms, s) } default: + seen[child.Digest] = struct{}{} totalSize += child.Size } } return totalSize, platforms, nil } + +// uniqueManifestSize returns manifestDesc.Size plus the size of any config/layer blobs whose +// digests have not yet been seen. The seen set is mutated to include every newly-counted digest. +// The manifest's own digest must already be in seen. +func uniqueManifestSize(manifestDesc ocispec.Descriptor, manifestBytes []byte, seen map[digest.Digest]struct{}) (int64, error) { + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return 0, fmt.Errorf("unable to unmarshal manifest %s: %w", manifestDesc.Digest, err) + } + total := manifestDesc.Size + if _, ok := seen[manifest.Config.Digest]; !ok { + seen[manifest.Config.Digest] = struct{}{} + total += manifest.Config.Size + } + for _, layer := range manifest.Layers { + if _, ok := seen[layer.Digest]; ok { + continue + } + seen[layer.Digest] = struct{}{} + total += layer.Size + } + return total, nil +} diff --git a/src/pkg/images/common_test.go b/src/pkg/images/common_test.go new file mode 100644 index 0000000000..5b5c6d5e45 --- /dev/null +++ b/src/pkg/images/common_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2021-Present The Zarf Authors + +package images + +import ( + "context" + "encoding/json" + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + "github.com/zarf-dev/zarf/src/test/testutil" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" +) + +func fetchAll(ctx context.Context, t *testing.T, fetcher content.Fetcher, desc ocispec.Descriptor) []byte { + t.Helper() + b, err := content.FetchAll(ctx, fetcher, desc) + require.NoError(t, err) + return b +} + +// pushImageWithLayer pushes a single-platform image referencing the given layer, tags the manifest +// with linux/arch, and returns its descriptor plus the naive (non-deduplicated) total of every +// blob it references. +func pushImageWithLayer(ctx context.Context, t *testing.T, repo *remote.Repository, arch string, layer ocispec.Descriptor) (ocispec.Descriptor, int64) { + t.Helper() + desc := testutil.PushSinglePlatformImageWithLayer(ctx, t, repo, arch, layer) + desc.Platform = &ocispec.Platform{OS: "linux", Architecture: arch} + config := manifestConfig(ctx, t, repo, desc) + return desc, desc.Size + config.Size + layer.Size +} + +// manifestConfig fetches a manifest and returns its config descriptor. +func manifestConfig(ctx context.Context, t *testing.T, fetcher content.Fetcher, manDesc ocispec.Descriptor) ocispec.Descriptor { + t.Helper() + b := fetchAll(ctx, t, fetcher, manDesc) + var m ocispec.Manifest + require.NoError(t, json.Unmarshal(b, &m)) + return m.Config +} + +func TestInspectIndex(t *testing.T) { + t.Parallel() + ctx := testutil.TestContext(t) + upstream := testutil.SetupInMemoryRegistryDynamic(ctx, t) + + t.Run("flat oci index sums every blob and reports each leaf platform", func(t *testing.T) { + t.Parallel() + repo := testutil.NewRepo(t, upstream+"/inspect/flat") + layerA := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 128)) + layerB := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 256)) + manA, sizeA := pushImageWithLayer(ctx, t, repo, "amd64", layerA) + manB, sizeB := pushImageWithLayer(ctx, t, repo, "arm64", layerB) + idx := testutil.PushIndex(ctx, t, repo, []ocispec.Descriptor{manA, manB}) + + size, platforms, err := inspectIndex(ctx, repo, idx, fetchAll(ctx, t, repo, idx)) + require.NoError(t, err) + require.Equal(t, idx.Size+sizeA+sizeB, size) + require.ElementsMatch(t, []string{"amd64", "arm64"}, platforms) + }) + + t.Run("nested oci index recurses into inner index", func(t *testing.T) { + t.Parallel() + repo := testutil.NewRepo(t, upstream+"/inspect/nested") + layerA := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 128)) + layerB := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 256)) + manA, sizeA := pushImageWithLayer(ctx, t, repo, "amd64", layerA) + manB, sizeB := pushImageWithLayer(ctx, t, repo, "arm64", layerB) + innerIdx := testutil.PushIndex(ctx, t, repo, []ocispec.Descriptor{manA, manB}) + outerIdx := testutil.PushIndex(ctx, t, repo, []ocispec.Descriptor{innerIdx}) + + size, platforms, err := inspectIndex(ctx, repo, outerIdx, fetchAll(ctx, t, repo, outerIdx)) + require.NoError(t, err) + require.Equal(t, outerIdx.Size+innerIdx.Size+sizeA+sizeB, size) + require.ElementsMatch(t, []string{"amd64", "arm64"}, platforms) + }) + + t.Run("docker manifest list is treated as an index", func(t *testing.T) { + t.Parallel() + repo := testutil.NewRepo(t, upstream+"/inspect/docker") + layerA := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 128)) + manA, sizeA := pushImageWithLayer(ctx, t, repo, "amd64", layerA) + list := pushDockerManifestList(ctx, t, repo, []ocispec.Descriptor{manA}) + + size, platforms, err := inspectIndex(ctx, repo, list, fetchAll(ctx, t, repo, list)) + require.NoError(t, err) + require.Equal(t, list.Size+sizeA, size) + require.ElementsMatch(t, []string{"amd64"}, platforms) + }) + + t.Run("dedups a layer referenced by sibling manifests", func(t *testing.T) { + t.Parallel() + repo := testutil.NewRepo(t, upstream+"/inspect/dedup-layer") + shared := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 512)) + manA, _ := pushImageWithLayer(ctx, t, repo, "amd64", shared) + manB, _ := pushImageWithLayer(ctx, t, repo, "arm64", shared) + idx := testutil.PushIndex(ctx, t, repo, []ocispec.Descriptor{manA, manB}) + + size, _, err := inspectIndex(ctx, repo, idx, fetchAll(ctx, t, repo, idx)) + require.NoError(t, err) + + configA := manifestConfig(ctx, t, repo, manA) + configB := manifestConfig(ctx, t, repo, manB) + expected := idx.Size + manA.Size + configA.Size + shared.Size + manB.Size + configB.Size + require.Equal(t, expected, size) + }) + + t.Run("dedups a manifest listed twice in an index", func(t *testing.T) { + t.Parallel() + repo := testutil.NewRepo(t, upstream+"/inspect/dup-manifest") + layer := testutil.PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, testutil.RandomBytes(t, 256)) + man, manSize := pushImageWithLayer(ctx, t, repo, "amd64", layer) + idx := testutil.PushIndex(ctx, t, repo, []ocispec.Descriptor{man, man}) + + size, platforms, err := inspectIndex(ctx, repo, idx, fetchAll(ctx, t, repo, idx)) + require.NoError(t, err) + require.Equal(t, idx.Size+manSize, size) + require.ElementsMatch(t, []string{"amd64"}, platforms) + }) +} diff --git a/src/test/testutil/ociregistry.go b/src/test/testutil/ociregistry.go index f43b2a7ab4..2216b693ad 100644 --- a/src/test/testutil/ociregistry.go +++ b/src/test/testutil/ociregistry.go @@ -94,14 +94,20 @@ func PushIndex(ctx context.Context, t *testing.T, repo *remote.Repository, child return desc } +// PushSinglePlatformImageWithLayer pushes a single platform image with a defined layer +func PushSinglePlatformImageWithLayer(ctx context.Context, t *testing.T, repo *remote.Repository, arch string, layer ocispec.Descriptor) ocispec.Descriptor { + t.Helper() + configJSON := fmt.Sprintf(`{"architecture":%q}`, arch) + config := PushBlob(ctx, t, repo, ocispec.MediaTypeImageConfig, []byte(configJSON)) + return PushManifest(ctx, t, repo, config, []ocispec.Descriptor{layer}) +} + // PushSinglePlatformImage creates a config blob, a random layer, and a manifest that references // both. The config embeds arch so distinct architectures produce distinct config blobs. func PushSinglePlatformImage(ctx context.Context, t *testing.T, repo *remote.Repository, arch string) ocispec.Descriptor { t.Helper() layer := PushBlob(ctx, t, repo, ocispec.MediaTypeImageLayer, RandomBytes(t, 64)) - configJSON := fmt.Sprintf(`{"architecture":%q}`, arch) - config := PushBlob(ctx, t, repo, ocispec.MediaTypeImageConfig, []byte(configJSON)) - return PushManifest(ctx, t, repo, config, []ocispec.Descriptor{layer}) + return PushSinglePlatformImageWithLayer(ctx, t, repo, arch, layer) } // PushImage pushes a single-manifest image and tags it; returns the manifest digest.