diff --git a/src/pkg/images/common.go b/src/pkg/images/common.go index ff479466df..75e49cc469 100644 --- a/src/pkg/images/common.go +++ b/src/pkg/images/common.go @@ -18,9 +18,11 @@ 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" + "oras.land/oras-go/v2/content" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/retry" ) @@ -119,7 +121,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 +130,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 +218,106 @@ 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 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) + } + totalSize := indexDesc.Size + var platforms []string + 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 := 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 := uniqueManifestSize(child, b, seen) + if err != nil { + return 0, nil, err + } + totalSize += size + if s := formatPlatform(child.Platform); s != "" { + 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/pkg/images/pull.go b/src/pkg/images/pull.go index c7e1da8a08..c656f81ff5 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" @@ -60,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 { @@ -68,7 +70,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,14 +156,13 @@ 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 // 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) @@ -203,19 +204,10 @@ 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) { - // 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) - } + 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) { + if !IsManifest(desc.MediaType) && !isIndexSha { fetchOpts.FetchOptions.TargetPlatform = platform desc, b, err = oras.FetchBytes(ectx, repo, image.overridden.Reference, fetchOpts) if err != nil { @@ -223,17 +215,22 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory } } - // extra validation before we marshall, this should never be true - if !isManifest(desc.MediaType) { + var size int64 + var platforms []string + switch { + case IsIndex(desc.MediaType): + size, platforms, err = inspectIndex(ectx, repo, desc, b) + if err != nil { + return fmt.Errorf("failed to inspect index %s: %w", image.overridden.Reference, err) + } + case IsManifest(desc.MediaType): + size, err = getSizeOfManifest(desc, b) + if err != nil { + return err + } + default: 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 - } - size := getSizeOfImage(desc, manifest) imageListLock.Lock() defer imageListLock.Unlock() imagesInfo = append(imagesInfo, imagePullInfo{ @@ -241,12 +238,10 @@ func Pull(ctx context.Context, imageList []transform.Image, destinationDirectory ref: image.original.Reference, byteSize: size, manifestDesc: desc, + platforms: platforms, }) - 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 +262,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,20 +274,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 -} - -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) + return pulledImages, nil } func getDockerEndpointHost() (string, error) { @@ -317,9 +299,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 +396,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 +419,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 { @@ -465,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) diff --git a/src/pkg/images/pull_test.go b/src/pkg/images/pull_test.go index a667b741d9..06fc2508c1 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() @@ -76,31 +114,25 @@ 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 { - 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: "nested oci index sha", + ref: fmt.Sprintf("%s/fixtures/nested-idx@%s", upstream, nestedIdxDigest), }, { name: "image manifest by tag", @@ -126,14 +158,23 @@ 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) + 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 } - require.NoError(t, err) + requireManifestBlobs(t, dstDir, top.Digest.String()) }) } } @@ -206,12 +247,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,9 +271,10 @@ func TestPull(t *testing.T) { } require.ElementsMatch(t, expectedImageAnnotations, actualImageAnnotations) - for _, imageWithManifest := range imageManifests { - for _, layer := range imageWithManifest.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()))) } } 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..7cfb1225a4 100644 --- a/src/pkg/images/unpack.go +++ b/src/pkg/images/unpack.go @@ -6,11 +6,11 @@ package images import ( "context" - "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "github.com/defenseunicorns/pkg/helpers/v2" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -24,22 +24,23 @@ 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 ( // 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. -// 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 +106,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 +124,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) + // 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 { + 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,21 +150,18 @@ 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 "+ - "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) } } - return imagesWithManifest, nil + return pulledImages, nil } // getRefFromManifest extracts the image reference from a manifest descriptor. @@ -188,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 c73b448407..34eb76684c 100644 --- a/src/pkg/images/unpack_test.go +++ b/src/pkg/images/unpack_test.go @@ -5,7 +5,10 @@ package images import ( + "encoding/json" "errors" + "fmt" + "os" "path/filepath" "testing" @@ -13,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) { @@ -44,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", @@ -141,15 +156,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,13 +174,152 @@ 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())) + } + } + }) + } +} + +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) + + testCases := []struct { + name string + ref string + }{ + { + name: "multi-arch index by digest preserves index", + ref: fmt.Sprintf("%s/fixtures/multi@%s", upstream, multiArchDigest), + }, + { + name: "nested index by digest preserves nested structure", + ref: fmt.Sprintf("%s/fixtures/nested@%s", upstream, nestedDigest), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + refInfo, err := transform.ParseImageRef(tc.ref) + require.NoError(t, err) + + layoutDir := t.TempDir() + _, err = Pull(ctx, []transform.Image{refInfo}, layoutDir, PullOptions{ + Arch: "amd64", + CacheDirectory: t.TempDir(), + PlainHTTP: true, + }) + require.NoError(t, err) + + 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.ref}, + }, dstDir, "amd64") + require.NoError(t, err) + require.Len(t, unpacked, 1) + 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.ref { + top = &dstIdx.Manifests[i] + break } } + 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) }) } } + +func TestUnpackTaggedIndexFiltersToPlatform(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"}, + } + 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) +} diff --git a/src/pkg/packager/deploy.go b/src/pkg/packager/deploy.go index c7d585fb6d..7bab266204 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) } } @@ -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, @@ -715,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 { @@ -761,12 +760,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/deploy_test.go b/src/pkg/packager/deploy_test.go index 1a1bad7cab..023a99ac3b 100644 --- a/src/pkg/packager/deploy_test.go +++ b/src/pkg/packager/deploy_test.go @@ -11,6 +11,7 @@ import ( "github.com/zarf-dev/zarf/src/api/v1alpha1" "github.com/zarf-dev/zarf/src/internal/healthchecks" "github.com/zarf-dev/zarf/src/pkg/cluster" + "github.com/zarf-dev/zarf/src/pkg/packager/layout" "github.com/zarf-dev/zarf/src/pkg/state" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -112,6 +113,6 @@ func TestVerifyPackageIsDeployableSkipsAgentCertCheckWhenAgentIsNotConfigured(t require.NoError(t, c.SaveState(ctx, &state.State{})) d := deployer{c: c} - err = d.verifyPackageIsDeployable(ctx, v1alpha1.ZarfPackage{}) + err = d.verifyPackageIsDeployable(ctx, &layout.PackageLayout{}) require.NoError(t, err) } diff --git a/src/pkg/packager/layout/assemble.go b/src/pkg/packager/layout/assemble.go index bbd41310db..1f02fccc34 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,8 @@ 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 { + sbomImageList = append(sbomImageList, pulled.Image) // Sort images index to make build reproducible. err = utils.SortImagesIndex(filepath.Join(buildPath, ImagesDir)) @@ -202,7 +201,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 { @@ -281,7 +283,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 { @@ -785,7 +790,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. @@ -818,17 +823,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)) @@ -846,7 +849,27 @@ 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 +} + +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) { @@ -1081,3 +1104,24 @@ func createDocumentationTar(pkg v1alpha1.ZarfPackage, packagePath, buildPath str return nil } + +func imageLayoutHasIndex(imageDir string) (bool, error) { + idxPath := filepath.Join(imageDir, IndexJSON) + 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/assemble_test.go b/src/pkg/packager/layout/assemble_test.go index 7fab8bc464..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) { @@ -217,3 +219,148 @@ 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)) + }) + } +} + +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) + }) + } +} 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() 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/pkg/zoci/pull.go b/src/pkg/zoci/pull.go index e98d43988a..8031284e8a 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,78 @@ 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 - - manifest, err := r.FetchManifest(ctx, manifestDescriptor) - if err != nil { - return nil, err + 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, manifestDescriptor.Digest.Encoded()))) - layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, manifest.Config.Digest.Encoded()))) + layers = append(layers, root.Locate(filepath.Join(layout.ImagesBlobsDir, entry.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): + manifestLayers, err := r.layersFromManifestChildren(ctx, root, entry) + if err != nil { + return nil, err + } + layers = append(layers, manifestLayers...) + 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 } + +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 { + 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): + manifestLayers, err := r.layersFromManifestChildren(ctx, root, child) + if err != nil { + return nil, err + } + layers = append(layers, manifestLayers...) + 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..e21d005697 100644 --- a/src/pkg/zoci/pull_test.go +++ b/src/pkg/zoci/pull_test.go @@ -6,22 +6,28 @@ 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/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 +124,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 +169,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 +181,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 +246,116 @@ 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) { + 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) { + 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) +} 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..2525f8c1d0 --- /dev/null +++ b/src/test/e2e/48_multi_platform_image_test.go @@ -0,0 +1,79 @@ +// 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" +) + +// 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") + createDir := t.TempDir() + + stdOut, stdErr, err := e2e.Zarf(t, "package", "create", pkgDefinitionPath, "-o", createDir, "--confirm") + require.NoError(t, err, stdOut, stdErr) + + // 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{ + Registry: registryURL, + Repository: "index-image", + Reference: "0.0.1", + } + + 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, "-a", "amd64") + require.NoError(t, err, stdOut, stdErr) + + 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) + + 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)) + + 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") + require.NoError(t, err, stdOut, stdErr) + t.Cleanup(func() { + _, _, 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/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..72a1067404 --- /dev/null +++ b/src/test/packages/48-multi-platform-image/zarf.yaml @@ -0,0 +1,17 @@ +kind: ZarfPackageConfig +metadata: + 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 + +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 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.