diff --git a/pkg/sif/image.go b/pkg/sif/image.go index 2122df9..3835ac3 100644 --- a/pkg/sif/image.go +++ b/pkg/sif/image.go @@ -10,6 +10,7 @@ import ( "fmt" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/match" "github.com/google/go-containerregistry/pkg/v1/partial" "github.com/google/go-containerregistry/pkg/v1/types" ) @@ -22,6 +23,38 @@ type image struct { rawManifest []byte } +var ( + errNoMatchingImage = errors.New("no image found matching criteria") + errMultipleMatchingImages = errors.New("multiple images match criteria") +) + +// Image returns a single Image stored in f, that is selected by the provided +// Matcher. If more than one image matches, or no image matches, an error is +// returned. +func (f *OCIFileImage) Image(m match.Matcher, _ ...Option) (v1.Image, error) { + ri, err := f.RootIndex() + if err != nil { + return nil, err + } + + matches, err := partial.FindImages(ri, m) + if err != nil { + return nil, err + } + if len(matches) > 1 { + return nil, errMultipleMatchingImages + } + if len(matches) == 0 { + return nil, errNoMatchingImage + } + + d, err := matches[0].Digest() + if err != nil { + return nil, err + } + return ri.Image(d) +} + // Layers returns the ordered collection of filesystem layers that comprise this image. The order // of the list is oldest/base layer first, and most-recent/top layer last. func (im *image) Layers() ([]v1.Layer, error) { diff --git a/pkg/sif/image_test.go b/pkg/sif/image_test.go index 83ffc64..6d3beb0 100644 --- a/pkg/sif/image_test.go +++ b/pkg/sif/image_test.go @@ -5,12 +5,20 @@ package sif_test import ( + "math/rand" + "path/filepath" "reflect" "testing" + "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/google/go-containerregistry/pkg/v1/validate" "github.com/sylabs/oci-tools/pkg/sif" + ssif "github.com/sylabs/sif/v2/pkg/sif" ) // imageIndexFromPath returns an ImageIndex for the test to use, populated from the OCI Image @@ -26,6 +34,109 @@ func imageIndexFromPath(t *testing.T, path string) v1.ImageIndex { return ii } +func Test_OCIFileImage_Image(t *testing.T) { + tmpDir := t.TempDir() + sifPath := filepath.Join(tmpDir, "test.sif") + if err := sif.Write(sifPath, empty.Index, sif.OptWriteWithSpareDescriptorCapacity(16)); err != nil { + t.Fatal(err) + } + fi, err := ssif.LoadContainerFromPath(sifPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = fi.UnloadContainer() }) + ofi, err := sif.FromFileImage(fi) + if err != nil { + t.Fatal(err) + } + + imgRef := name.MustParseReference("myimage:v1", name.WithDefaultRegistry("")) + r := rand.NewSource(randomSeed) + img, err := random.Image(64, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendImage(img, sif.OptAppendReference(imgRef)); err != nil { + t.Fatal(err) + } + img2, err := random.Image(64, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendImage(img2); err != nil { + t.Fatal(err) + } + + idxRef := name.MustParseReference("myindex:v1", name.WithDefaultRegistry("")) + idx, err := random.Index(64, 1, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendIndex(idx, sif.OptAppendReference(idxRef)); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + matcher match.Matcher + wantImage v1.Image + wantErr bool + }{ + { + name: "MatchingRef", + matcher: match.Name(imgRef.Name()), + wantImage: img, + wantErr: false, + }, + { + name: "NotImage", + matcher: match.Name(idxRef.Name()), + wantImage: nil, + wantErr: true, + }, + { + name: "NonMatchingRef", + matcher: match.Name("not-present:latest"), + wantImage: nil, + wantErr: true, + }, + { + name: "MultipleMatches", + matcher: match.MediaTypes(string(types.DockerManifestSchema2)), + wantImage: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotImg, err := ofi.Image(tt.matcher) + + if err != nil && !tt.wantErr { + t.Errorf("Unexpected error: %v", err) + } + if err == nil && tt.wantErr { + t.Errorf("Error expected, but nil returned.") + } + + if tt.wantImage == nil { + return + } + + gotDigest, err := gotImg.Digest() + if err != nil { + t.Fatal(err) + } + wantDigest, err := tt.wantImage.Digest() + if err != nil { + t.Fatal(err) + } + if gotDigest != wantDigest { + t.Errorf("Expected image with digest %q, got %q", wantDigest, gotDigest) + } + }) + } +} + func Test_imageIndex_Image(t *testing.T) { imageDigest := v1.Hash{ Algorithm: "sha256", diff --git a/pkg/sif/index.go b/pkg/sif/index.go index 7490d46..ee17ae2 100644 --- a/pkg/sif/index.go +++ b/pkg/sif/index.go @@ -11,6 +11,8 @@ import ( "fmt" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/partial" "github.com/google/go-containerregistry/pkg/v1/types" "github.com/sylabs/sif/v2/pkg/sif" ) @@ -65,6 +67,38 @@ func (f *OCIFileImage) RootIndex() (v1.ImageIndex, error) { }, nil } +var ( + errNoMatchingIndex = errors.New("no index found matching criteria") + errMultipleMatchingIndices = errors.New("multiple indices match criteria") +) + +// Index returns a single ImageIndex stored in f, that is selected by the provided +// Matcher. If more than one index matches, or no index matches, an error is +// returned. +func (f *OCIFileImage) Index(m match.Matcher, _ ...Option) (v1.ImageIndex, error) { + ri, err := f.RootIndex() + if err != nil { + return nil, err + } + + matches, err := partial.FindIndexes(ri, m) + if err != nil { + return nil, err + } + if len(matches) > 1 { + return nil, errMultipleMatchingIndices + } + if len(matches) == 0 { + return nil, errNoMatchingIndex + } + + d, err := matches[0].Digest() + if err != nil { + return nil, err + } + return ri.ImageIndex(d) +} + // MediaType of this image's manifest. func (ix *imageIndex) MediaType() (types.MediaType, error) { return ix.desc.MediaType, nil diff --git a/pkg/sif/index_test.go b/pkg/sif/index_test.go index 6b4b451..5d664c1 100644 --- a/pkg/sif/index_test.go +++ b/pkg/sif/index_test.go @@ -5,10 +5,17 @@ package sif_test import ( + "math/rand" + "path/filepath" "reflect" "testing" + "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/google/go-containerregistry/pkg/v1/validate" "github.com/sylabs/oci-tools/pkg/sif" ssif "github.com/sylabs/sif/v2/pkg/sif" @@ -83,3 +90,106 @@ func TestImageIndexFromFileImage(t *testing.T) { }) } } + +func Test_OCIFileImage_Index(t *testing.T) { + tmpDir := t.TempDir() + sifPath := filepath.Join(tmpDir, "test.sif") + if err := sif.Write(sifPath, empty.Index, sif.OptWriteWithSpareDescriptorCapacity(16)); err != nil { + t.Fatal(err) + } + fi, err := ssif.LoadContainerFromPath(sifPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = fi.UnloadContainer() }) + ofi, err := sif.FromFileImage(fi) + if err != nil { + t.Fatal(err) + } + + imgRef := name.MustParseReference("myimage:v1", name.WithDefaultRegistry("")) + r := rand.NewSource(randomSeed) + img, err := random.Image(64, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendImage(img, sif.OptAppendReference(imgRef)); err != nil { + t.Fatal(err) + } + + idxRef := name.MustParseReference("myindex:v1", name.WithDefaultRegistry("")) + idx, err := random.Index(64, 1, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendIndex(idx, sif.OptAppendReference(idxRef)); err != nil { + t.Fatal(err) + } + idx2, err := random.Index(64, 1, 1, random.WithSource(r)) + if err != nil { + t.Fatal(err) + } + if err := ofi.AppendIndex(idx2); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + matcher match.Matcher + wantIndex v1.ImageIndex + wantErr bool + }{ + { + name: "MatchingRef", + matcher: match.Name(idxRef.Name()), + wantIndex: idx, + wantErr: false, + }, + { + name: "NotIndex", + matcher: match.Name(imgRef.Name()), + wantIndex: nil, + wantErr: true, + }, + { + name: "NonMatchingRef", + matcher: match.Name("not-present:latest"), + wantIndex: nil, + wantErr: true, + }, + { + name: "MultipleMatches", + matcher: match.MediaTypes(string(types.OCIImageIndex)), + wantIndex: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIndex, err := ofi.Index(tt.matcher) + + if err != nil && !tt.wantErr { + t.Errorf("Unexpected error: %v", err) + } + if err == nil && tt.wantErr { + t.Errorf("Error expected, but nil returned.") + } + + if tt.wantIndex == nil { + return + } + + gotDigest, err := gotIndex.Digest() + if err != nil { + t.Fatal(err) + } + wantDigest, err := tt.wantIndex.Digest() + if err != nil { + t.Fatal(err) + } + if gotDigest != wantDigest { + t.Errorf("Expected index with digest %q, got %q", wantDigest, gotDigest) + } + }) + } +} diff --git a/pkg/sif/options.go b/pkg/sif/options.go new file mode 100644 index 0000000..f980686 --- /dev/null +++ b/pkg/sif/options.go @@ -0,0 +1,10 @@ +// Copyright 2024 Sylabs Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +package sif + +// Option is a functional option for OCIFileImage operations. +type Option func(*options) error + +type options struct{}