Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

image: multi-manifest index support #176

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 48 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,42 @@ func GetImageFromSource(ctx context.Context, imgStr string, source image.Source,
return img, nil
}

// GetImageIndexFromSource returns an image index from the explicitly provided source.
func GetImageIndexFromSource(ctx context.Context, imgStr string, source image.Source, options ...Option) (*image.Index, error) {
log.Debugf("image index: source=%+v location=%+v", source, imgStr)

var cfg config
for _, option := range options {
if option == nil {
continue
}
if err := option(&cfg); err != nil {
return nil, fmt.Errorf("unable to parse option: %w", err)
}
}

provider, cleanup, err := selectImageProvider(imgStr, source, cfg)
if cleanup != nil {
defer cleanup()
}
if err != nil {
return nil, err
}

var indexProvider image.IndexProvider
var ok bool
if indexProvider, ok = provider.(image.IndexProvider); !ok {
return nil, fmt.Errorf("provider doesn't support image indexes")
}

index, err := indexProvider.ProvideIndex(ctx, cfg.AdditionalMetadata...)
if err != nil {
return nil, fmt.Errorf("unable to use %s source: %w", source, err)
}

return index, nil
}

// nolint:funlen
func selectImageProvider(imgStr string, source image.Source, cfg config) (image.Provider, func(), error) {
var provider image.Provider
Expand Down Expand Up @@ -168,15 +204,9 @@ func selectImageProvider(imgStr string, source image.Source, cfg config) (image.
return nil, cleanup, err
}
case image.OciDirectorySource:
if cfg.Platform != nil {
return nil, cleanup, platformSelectionUnsupported
}
provider = oci.NewProviderFromPath(imgStr, tempDirGenerator)
provider = oci.NewProviderFromPath(imgStr, tempDirGenerator, cfg.Platform)
case image.OciTarballSource:
if cfg.Platform != nil {
return nil, cleanup, platformSelectionUnsupported
}
provider = oci.NewProviderFromTarball(imgStr, tempDirGenerator)
provider = oci.NewProviderFromTarball(imgStr, tempDirGenerator, cfg.Platform)
case image.OciRegistrySource:
defaultPlatformIfNil(&cfg)
provider = oci.NewProviderFromRegistry(imgStr, tempDirGenerator, cfg.Registry, cfg.Platform)
Expand Down Expand Up @@ -216,6 +246,16 @@ func GetImage(ctx context.Context, userStr string, options ...Option) (*image.Im
return GetImageFromSource(ctx, imgStr, source, options...)
}

// GetImageIndex parses the user provided image string and provides an index object;
// note: the source where the image should be referenced from is automatically inferred.
func GetImageIndex(ctx context.Context, userStr string, options ...Option) (*image.Index, error) {
source, imgStr, err := image.DetectSource(userStr)
if err != nil {
return nil, err
}
return GetImageIndexFromSource(ctx, imgStr, source, options...)
}

func SetLogger(logger logger.Logger) {
log.Log = logger
}
Expand Down
42 changes: 42 additions & 0 deletions pkg/image/image_index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package image

import (
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/hashicorp/go-multierror"
)

// Index represents a container image index.
type Index struct {
// index is the raw index manifest and content provider from the GCR lib
index v1.ImageIndex
// images is a list of images associated with an index.
images []*Image
}

// NewIndex provides a new image index object.
func NewIndex(index v1.ImageIndex, images []*Image) *Index {
return &Index{
index: index,
images: images,
}
}

// Images returns a list of images associated with an index.
func (i *Index) Images() []*Image {
return i.images
}

// Cleanup removes all temporary files created from parsing the index and associated images.
// Future calls to image will not function correctly after this call.
func (i *Index) Cleanup() error {
if i == nil {
return nil
}
var errs error
for _, img := range i.images {
if err := img.Cleanup(); err != nil {
errs = multierror.Append(errs, err)
}
}
return errs
}
127 changes: 116 additions & 11 deletions pkg/image/oci/directory_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package oci

import (
"context"
"errors"
"fmt"
"strconv"

v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/layout"
Expand All @@ -15,13 +17,15 @@ import (
type DirectoryImageProvider struct {
path string
tmpDirGen *file.TempDirGenerator
platform *image.Platform
}

// NewProviderFromPath creates a new provider instance for the specific image already at the given path.
func NewProviderFromPath(path string, tmpDirGen *file.TempDirGenerator) *DirectoryImageProvider {
func NewProviderFromPath(path string, tmpDirGen *file.TempDirGenerator, platform *image.Platform) *DirectoryImageProvider {
return &DirectoryImageProvider{
path: path,
tmpDirGen: tmpDirGen,
platform: platform,
}
}

Expand All @@ -42,24 +46,65 @@ func (p *DirectoryImageProvider) Provide(_ context.Context, userMetadata ...imag
return nil, fmt.Errorf("unable to parse OCI directory indexManifest: %w", err)
}

// for now, lets only support one image indexManifest (it is not clear how to handle multiple manifests)
if len(indexManifest.Manifests) != 1 {
if len(indexManifest.Manifests) == 0 {
return nil, fmt.Errorf("unexpected number of OCI directory manifests (found %d)", len(indexManifest.Manifests))
}
// if all the manifests have the same digest, then we can treat this as a single image
if !checkManifestDigestsEqual(indexManifest.Manifests) {
return nil, fmt.Errorf("unexpected number of OCI directory manifests (found %d)", len(indexManifest.Manifests))
manifestLen := len(indexManifest.Manifests)
if manifestLen < 1 {
return nil, errors.New("expected at least one OCI manifest (found 0)")
}

var manifest v1.Descriptor
if manifestLen > 1 {
if p.platform == nil {
// if all the manifests have the same digest, then we can treat this as a single image
if !checkManifestDigestsEqual(indexManifest.Manifests) {
// TODO: default to the current OS?
return nil, errors.New("when a OCI manifest contains multiple references, a platform selector is required")
}
manifest = indexManifest.Manifests[0]
} else {
var found bool
for _, m := range indexManifest.Manifests {
if m.Platform == nil {
continue
}

// Check if the manifest's platform matches our selector.
if m.Platform.OS != p.platform.OS {
continue
}

// Check if the manifest's architecture matches our selector.
if m.Platform.Architecture != p.platform.Architecture {
continue
}

// Check if the manifest's variant matches our selector.
if m.Platform.Variant != p.platform.Variant {
continue
}

// TODO: there is the possibility that multiple manifests may match.
// Do we continue iterating all of them and check if multiple matches
// exist then throw an error?
manifest = m
found = true
break
}

if !found {
return nil, fmt.Errorf("unable to find a OCI manifest matching the given platform (platform: %s)", p.platform)
}
}
} else {
// Only one manifest exists, so use it.
manifest = indexManifest.Manifests[0]
}

manifest := indexManifest.Manifests[0]
img, err := pathObj.Image(manifest.Digest)
if err != nil {
return nil, fmt.Errorf("unable to parse OCI directory as an image: %w", err)
}

var metadata = []image.AdditionalMetadata{
metadata := []image.AdditionalMetadata{
image.WithManifestDigest(manifest.Digest.String()),
}

Expand All @@ -80,6 +125,66 @@ func (p *DirectoryImageProvider) Provide(_ context.Context, userMetadata ...imag
return image.New(img, p.tmpDirGen, contentTempDir, metadata...), nil
}

// ProvideIndex provides an image index that represents the OCI image as a directory.
func (p *DirectoryImageProvider) ProvideIndex(_ context.Context, userMetadata ...image.AdditionalMetadata) (*image.Index, error) {
pathObj, err := layout.FromPath(p.path)
if err != nil {
return nil, fmt.Errorf("unable to read image from OCI directory path %q: %w", p.path, err)
}

index, err := layout.ImageIndexFromPath(p.path)
if err != nil {
return nil, fmt.Errorf("unable to parse OCI directory index: %w", err)
}

indexManifest, err := index.IndexManifest()
if err != nil {
return nil, fmt.Errorf("unable to parse OCI directory indexManifest: %w", err)
}

if len(indexManifest.Manifests) < 1 {
return nil, fmt.Errorf("expected at least one OCI directory manifests (found %d)", len(indexManifest.Manifests))
}

images := make([]*image.Image, len(indexManifest.Manifests))
for i, manifest := range indexManifest.Manifests {
img, err := pathObj.Image(manifest.Digest)
if err != nil {
return nil, fmt.Errorf("unable to parse OCI directory as an image: %w", err)
}

metadata := []image.AdditionalMetadata{
image.WithManifestDigest(manifest.Digest.String()),
}
if manifest.Platform != nil {
if manifest.Platform.Architecture != "" {
metadata = append(metadata, image.WithArchitecture(manifest.Platform.Architecture, manifest.Platform.Variant))
}
if manifest.Platform.OS != "" {
metadata = append(metadata, image.WithOS(manifest.Platform.OS))
}
}

// make a best-effort attempt at getting the raw indexManifest
rawManifest, err := img.RawManifest()
if err == nil {
metadata = append(metadata, image.WithManifest(rawManifest))
}

// apply user-supplied metadata last to override any default behavior
metadata = append(metadata, userMetadata...)

contentTempDir, err := p.tmpDirGen.NewDirectory("oci-dir-image-" + strconv.Itoa(i))
if err != nil {
return nil, err
}

images[i] = image.New(img, p.tmpDirGen, contentTempDir, metadata...)
}

return image.NewIndex(index, images), nil
}

func checkManifestDigestsEqual(manifests []v1.Descriptor) bool {
if len(manifests) < 1 {
return false
Expand Down
38 changes: 36 additions & 2 deletions pkg/image/oci/directory_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func Test_NewProviderFromPath(t *testing.T) {
generator := file.TempDirGenerator{}

//WHEN
provider := NewProviderFromPath(path, &generator)
provider := NewProviderFromPath(path, &generator, nil)

//THEN
assert.NotNil(t, provider.path)
Expand All @@ -33,10 +33,11 @@ func Test_Directory_Provide(t *testing.T) {
{"reads valid oci manifest with no images", "test-fixtures/no_manifests", true},
{"reads a fully correct manifest", "test-fixtures/valid_manifest", false},
{"reads a fully correct manifest with equal digests", "test-fixtures/valid_manifest", false},
{"fails to read a fully correct manifest index with more than one manifest", "test-fixtures/valid_manifest_index", true},
}

for _, tc := range tests {
provider := NewProviderFromPath(tc.path, file.NewTempDirGenerator("tempDir"))
provider := NewProviderFromPath(tc.path, file.NewTempDirGenerator("tempDir"), nil)
t.Run(tc.name, func(t *testing.T) {
//WHEN
image, err := provider.Provide(nil)
Expand All @@ -53,3 +54,36 @@ func Test_Directory_Provide(t *testing.T) {
})
}
}

func Test_Directory_ProvideIndex(t *testing.T) {
//GIVEN
tests := []struct {
name string
path string
expectedErr bool
}{
{"fails to read from path", "", true},
{"reads invalid oci manifest", "test-fixtures/invalid_file", true},
{"reads valid oci manifest with no images", "test-fixtures/no_manifests", true},
{"reads a fully correct manifest", "test-fixtures/valid_manifest", false},
{"reads a fully correct manifest index with more than one manifest", "test-fixtures/valid_manifest_index", false},
}

for _, tc := range tests {
provider := NewProviderFromPath(tc.path, file.NewTempDirGenerator("tempDir"), nil)
t.Run(tc.name, func(t *testing.T) {
//WHEN
image, err := provider.ProvideIndex(nil)

//THEN
if tc.expectedErr {
assert.Error(t, err)
assert.Nil(t, image)
} else {
assert.NoError(t, err)
assert.NotNil(t, image)
}

})
}
}
27 changes: 25 additions & 2 deletions pkg/image/oci/tarball_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import (
type TarballImageProvider struct {
path string
tmpDirGen *file.TempDirGenerator
platform *image.Platform
}

// NewProviderFromTarball creates a new provider instance for the specific image tarball already at the given path.
func NewProviderFromTarball(path string, tmpDirGen *file.TempDirGenerator) *TarballImageProvider {
func NewProviderFromTarball(path string, tmpDirGen *file.TempDirGenerator, platform *image.Platform) *TarballImageProvider {
return &TarballImageProvider{
path: path,
tmpDirGen: tmpDirGen,
platform: platform,
}
}

Expand All @@ -41,5 +43,26 @@ func (p *TarballImageProvider) Provide(ctx context.Context, metadata ...image.Ad
return nil, err
}

return NewProviderFromPath(tempDir, p.tmpDirGen).Provide(ctx, metadata...)
return NewProviderFromPath(tempDir, p.tmpDirGen, p.platform).Provide(ctx, metadata...)
}

// ProvideIndex provides an image index that represents the OCI image index from a tarball.
func (p *TarballImageProvider) ProvideIndex(ctx context.Context, metadata ...image.AdditionalMetadata) (*image.Index, error) {
// note: we are untaring the image and using the existing directory provider, we could probably enhance the google
// container registry lib to do this without needing to untar to a temp dir (https://github.com/google/go-containerregistry/issues/726)
f, err := os.Open(p.path)
if err != nil {
return nil, fmt.Errorf("unable to open OCI tarball: %w", err)
}

tempDir, err := p.tmpDirGen.NewDirectory("oci-tarball-image")
if err != nil {
return nil, err
}

if err = file.UntarToDirectory(f, tempDir); err != nil {
return nil, err
}

return NewProviderFromPath(tempDir, p.tmpDirGen, p.platform).ProvideIndex(ctx, metadata...)
}
Loading