Skip to content

Commit

Permalink
feat: Image / Index by Matcher
Browse files Browse the repository at this point in the history
Allow an Image or ImageIndex to be retrieved from a SIF file by
Matcher - e.g. match.Name(..) to match by ref.name annotation.

Fixes #82
  • Loading branch information
dtrudg committed Oct 1, 2024
1 parent 166c3c1 commit dd2f43b
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 0 deletions.
33 changes: 33 additions & 0 deletions pkg/sif/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand Down
111 changes: 111 additions & 0 deletions pkg/sif/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions pkg/sif/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions pkg/sif/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
})
}
}
10 changes: 10 additions & 0 deletions pkg/sif/options.go
Original file line number Diff line number Diff line change
@@ -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{}

0 comments on commit dd2f43b

Please sign in to comment.