From ae35631a6f09c81c271daaa5f5917a0472c31e06 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Thu, 16 May 2024 19:24:27 -0400 Subject: [PATCH] refactor: use container image repo client from go-containerregistry (#2018) Signed-off-by: Kent Rancourt Co-authored-by: Hidde Beydals --- go.mod | 13 +- go.sum | 8 + internal/controller/warehouses/images.go | 2 +- internal/image/creds.go | 26 +- internal/image/digest_selector.go | 51 +- internal/image/image.go | 7 +- internal/image/image_test.go | 5 +- internal/image/lexical_selector.go | 4 +- internal/image/newest_build_selector.go | 10 +- internal/image/registry.go | 24 +- internal/image/registry_test.go | 52 - internal/image/repository_client.go | 628 +++-------- .../repository_client_docker_hub_test.go | 43 +- internal/image/repository_client_test.go | 996 +++++++----------- internal/image/selector_docker_hub_test.go | 96 +- internal/image/selector_ghcr_test.go | 36 +- internal/image/selector_test.go | 13 - internal/image/semver_selector.go | 4 +- internal/image/semver_selector_test.go | 32 +- 19 files changed, 685 insertions(+), 1365 deletions(-) diff --git a/go.mod b/go.mod index 3b9400835..df3a0b37f 100644 --- a/go.mod +++ b/go.mod @@ -13,19 +13,17 @@ require ( github.com/bacongobbler/browser v1.1.0 github.com/bombsimon/logrusr/v4 v4.1.0 github.com/coreos/go-oidc/v3 v3.10.0 - github.com/distribution/distribution/v3 v3.0.0-20230722181636-7b502560cad4 github.com/evanphx/json-patch/v5 v5.9.0 github.com/fatih/structtag v1.2.0 github.com/gobwas/glob v0.2.3 github.com/gogo/protobuf v1.3.2 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/go-containerregistry v0.14.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-cleanhttp v0.5.2 github.com/kelseyhightower/envconfig v1.4.0 github.com/klauspost/compress v1.17.8 github.com/oklog/ulid/v2 v2.1.0 - github.com/opencontainers/go-digest v1.0.0 - github.com/opencontainers/image-spec v1.1.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rs/cors v1.11.0 github.com/sirupsen/logrus v1.9.3 @@ -99,7 +97,6 @@ require ( github.com/gorilla/mux v1.8.0 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/hashicorp/go-retryablehttp v0.7.2 // indirect - github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -152,4 +149,10 @@ require ( sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect ) -require github.com/go-jose/go-jose/v4 v4.0.1 // indirect +require ( + github.com/distribution/distribution/v3 v3.0.0-20230722181636-7b502560cad4 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect +) diff --git a/go.sum b/go.sum index 68a4e31df..981fa80a6 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k= +github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o= github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -167,6 +169,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-containerregistry v0.14.0 h1:z58vMqHxuwvAsVwvKEkmVBz2TlgBgH5k6koEXBtlYkw= +github.com/google/go-containerregistry v0.14.0/go.mod h1:aiJ2fp/SXvkWgmYHioXnbMdlgB8eXiiYOY55gfN91Wk= github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4= github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -242,6 +246,8 @@ github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvls github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= @@ -336,6 +342,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/technosophos/moniker v0.0.0-20210218184952-3ea787d3943b h1:fo0GUa0B+vxSZ8bgnL3fpCPHReM/QPlALdak9T/Zw5Y= github.com/technosophos/moniker v0.0.0-20210218184952-3ea787d3943b/go.mod h1:O1c8HleITsZqzNZDjSNzirUGsMT0oGu9LhHKoJrqO+A= +github.com/vbatts/tar-split v0.11.2 h1:Via6XqJr0hceW4wff3QRzD5gAk/tatMw/4ZA7cTlIME= +github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI= github.com/withfig/autocomplete-tools/integrations/cobra v1.2.1 h1:+dBg5k7nuTE38VVdoroRsT0Z88fmvdYrI2EjzJst35I= github.com/withfig/autocomplete-tools/integrations/cobra v1.2.1/go.mod h1:nmuySobZb4kFgFy6BptpXp/BBw+xFSyvVPP6auoJB4k= github.com/xanzy/go-gitlab v0.105.0 h1:3nyLq0ESez0crcaM19o5S//SvezOQguuIHZ3wgX64hM= diff --git a/internal/controller/warehouses/images.go b/internal/controller/warehouses/images.go index 12b1e6f8f..26021628e 100644 --- a/internal/controller/warehouses/images.go +++ b/internal/controller/warehouses/images.go @@ -70,7 +70,7 @@ func (r *reconciler) discoverImages( for _, img := range images { discovery := kargoapi.DiscoveredImageReference{ Tag: img.Tag, - Digest: img.Digest.String(), + Digest: img.Digest, GitRepoURL: r.getImageSourceURL(sub.GitRepoURL, img.Tag), } if img.CreatedAt != nil { diff --git a/internal/image/creds.go b/internal/image/creds.go index 5696a18c3..f8ecb83bc 100644 --- a/internal/image/creds.go +++ b/internal/image/creds.go @@ -1,34 +1,12 @@ package image -import "net/url" - // Credentials represents the credentials for connecting to a private image -// repository. It implements the -// distribution/V3/registry/client/auth.CredentialStore interface. +// repository. type Credentials struct { // Username identifies a principal, which combined with the value of the // Password field, can be used for reading from some image repository. Username string // Password, when combined with the principal identified by the Username // field, can be used for reading from some image repository. - Password string - refreshTokens map[string]string -} - -// Basic implements distribution/V3/registry/client/auth.CredentialStore. -func (c Credentials) Basic(*url.URL) (string, string) { - return c.Username, c.Password -} - -// RefreshToken implements distribution/V3/registry/client/auth.CredentialStore. -func (c Credentials) RefreshToken(_ *url.URL, service string) string { - return c.refreshTokens[service] -} - -// SetRefreshToken implements -// distribution/V3/registry/client/auth.CredentialStore. -func (c Credentials) SetRefreshToken(_ *url.URL, service, token string) { - if c.refreshTokens != nil { - c.refreshTokens[service] = token - } + Password string } diff --git a/internal/image/digest_selector.go b/internal/image/digest_selector.go index d4a7cd6f6..bf20a2225 100644 --- a/internal/image/digest_selector.go +++ b/internal/image/digest_selector.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "net/http" + "github.com/google/go-containerregistry/pkg/v1/remote/transport" log "github.com/sirupsen/logrus" "github.com/akuity/kargo/internal/logging" @@ -36,52 +38,33 @@ func newDigestSelector( // Select implements the Selector interface. func (d *digestSelector) Select(ctx context.Context) ([]Image, error) { + tag := d.constraint logger := logging.LoggerFromContext(ctx).WithFields(log.Fields{ "registry": d.repoClient.registry.name, - "image": d.repoClient.image, + "image": d.repoClient.repoURL, "selectionStrategy": SelectionStrategyDigest, + "tag": tag, "platformConstrained": d.platform != nil, }) logger.Trace("selecting image") ctx = logging.ContextWithLogger(ctx, logger) - // TODO(hidde): it would be much more efficient to directly attempt - // to retrieve the image for the tag, while gracefully handling the - // case where it does not exist. This would avoid the need to list - // all tags and then iterate over them. - tags, err := d.repoClient.getTags(ctx) + image, err := d.repoClient.getImageByTag(ctx, tag, d.platform) if err != nil { - return nil, fmt.Errorf("error listing tags: %w", err) - } - if len(tags) == 0 { - logger.Trace("found no tags") - return nil, nil - } - logger.Trace("got all tags") - - for _, tag := range tags { - if tag != d.constraint { - continue - } - image, err := d.repoClient.getImageByTag(ctx, tag, d.platform) - if err != nil { - return nil, fmt.Errorf("error retrieving image with tag %q: %w", tag, err) - } - if image == nil { - logger.Tracef( - "image with tag %q was found, but did not match platform constraint", - tag, - ) + var te *transport.Error + if errors.As(err, &te) && te.StatusCode == http.StatusNotFound { + logger.Trace("found no image with tag") return nil, nil } - logger.WithFields(log.Fields{ - "tag": image.Tag, - "digest": image.Digest.String(), - }).Trace("found image") - return []Image{*image}, nil + return nil, fmt.Errorf("error retrieving image with tag %q: %w", tag, err) + } + + if image == nil { + logger.Trace("image with tag did not match platform constraints") + return nil, nil } - logger.Trace("no images matched criteria") - return nil, nil + logger.Trace("found image with tag") + return []Image{*image}, nil } diff --git a/internal/image/image.go b/internal/image/image.go index 1e6323fc5..9ac816b8a 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -4,23 +4,22 @@ import ( "time" "github.com/Masterminds/semver/v3" - "github.com/opencontainers/go-digest" ) // Image is a representation of a container image. type Image struct { Tag string - Digest digest.Digest + Digest string CreatedAt *time.Time semVer *semver.Version } // newImage initializes and returns an Image. -func newImage(tag string, date *time.Time, digest digest.Digest) Image { +func newImage(tag, digest string, date *time.Time) Image { t := Image{ Tag: tag, - CreatedAt: date, Digest: digest, + CreatedAt: date, } // It's ok if the tag doesn't parse as semver, but if it does, store it if sv, err := semver.NewVersion(tag); err == nil { diff --git a/internal/image/image_test.go b/internal/image/image_test.go index 31e4a32f8..f0e0fa53c 100644 --- a/internal/image/image_test.go +++ b/internal/image/image_test.go @@ -4,12 +4,11 @@ import ( "testing" "time" - "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" ) func TestNewImage(t *testing.T) { - testDigest := digest.Digest("fake-digest") + const testDigest = "fake-digest" testDate := time.Now().UTC() testCases := []struct { name string @@ -42,7 +41,7 @@ func TestNewImage(t *testing.T) { } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - testCase.assertions(t, newImage(testCase.tag, &testDate, testDigest)) + testCase.assertions(t, newImage(testCase.tag, testDigest, &testDate)) }) } } diff --git a/internal/image/lexical_selector.go b/internal/image/lexical_selector.go index d8f90ac6a..2c5270f97 100644 --- a/internal/image/lexical_selector.go +++ b/internal/image/lexical_selector.go @@ -43,7 +43,7 @@ func newLexicalSelector( func (l *lexicalSelector) Select(ctx context.Context) ([]Image, error) { logger := logging.LoggerFromContext(ctx).WithFields(log.Fields{ "registry": l.repoClient.registry.name, - "image": l.repoClient.image, + "image": l.repoClient.repoURL, "selectionStrategy": SelectionStrategyLexical, "platformConstrained": l.platform != nil, "discoveryLimit": l.discoveryLimit, @@ -82,7 +82,7 @@ func (l *lexicalSelector) Select(ctx context.Context) ([]Image, error) { logger.WithFields(log.Fields{ "tag": image.Tag, - "digest": image.Digest.String(), + "digest": image.Digest, }).Trace("discovered image") images = append(images, *image) } diff --git a/internal/image/newest_build_selector.go b/internal/image/newest_build_selector.go index d3b8a52b7..24a0a6abe 100644 --- a/internal/image/newest_build_selector.go +++ b/internal/image/newest_build_selector.go @@ -44,7 +44,7 @@ func newNewestBuildSelector( func (n *newestBuildSelector) Select(ctx context.Context) ([]Image, error) { logger := logging.LoggerFromContext(ctx).WithFields(log.Fields{ "registry": n.repoClient.registry.name, - "image": n.repoClient.image, + "image": n.repoClient.repoURL, "selectionStrategy": SelectionStrategyNewestBuild, "platformConstrained": n.platform != nil, "discoveryLimit": n.discoveryLimit, @@ -67,7 +67,7 @@ func (n *newestBuildSelector) Select(ctx context.Context) ([]Image, error) { for _, image := range images[:limit] { logger.WithFields(log.Fields{ "tag": image.Tag, - "digest": image.Digest.String(), + "digest": image.Digest, }).Trace("discovered image") } logger.Tracef("discovered %d images", limit) @@ -86,20 +86,20 @@ func (n *newestBuildSelector) Select(ctx context.Context) ([]Image, error) { discoveredImage, err := n.repoClient.getImageByDigest(ctx, image.Digest, n.platform) if err != nil { - return nil, fmt.Errorf("error retrieving image with digest %q: %w", image.Digest.String(), err) + return nil, fmt.Errorf("error retrieving image with digest %q: %w", image.Digest, err) } if discoveredImage == nil { logger.Tracef( "image with digest %q was found, but did not match platform constraint", - image.Digest.String(), + image.Digest, ) continue } logger.WithFields(log.Fields{ "tag": image.Tag, - "digest": image.Digest.String(), + "digest": image.Digest, }).Trace("discovered image") discoveredImage.Tag = image.Tag diff --git a/internal/image/registry.go b/internal/image/registry.go index c157c5a61..0911adf08 100644 --- a/internal/image/registry.go +++ b/internal/image/registry.go @@ -1,23 +1,18 @@ package image import ( - "fmt" - "strings" "sync" "time" + "github.com/google/go-containerregistry/pkg/name" "github.com/patrickmn/go-cache" "go.uber.org/ratelimit" ) -// dockerRegistry is registry configuration for Docker Hub, whose API endpoint -// cannot be inferred from an image prefix because its API endpoint is at -// https://registry-1.docker.io despite Docker Hub images either lacking a -// prefix entirely or beginning with docker.io +// dockerRegistry is registry configuration for Docker Hub. var dockerRegistry = ®istry{ name: "Docker Hub", - imagePrefix: "docker.io", - apiAddress: "https://registry-1.docker.io", + imagePrefix: name.DefaultRegistry, defaultNamespace: "library", imageCache: cache.New( 30*time.Minute, // Default ttl for each entry @@ -43,7 +38,6 @@ var ( type registry struct { name string imagePrefix string - apiAddress string defaultNamespace string imageCache *cache.Cache rateLimiter ratelimit.Limiter @@ -54,7 +48,6 @@ func newRegistry(imagePrefix string) *registry { return ®istry{ name: imagePrefix, imagePrefix: imagePrefix, - apiAddress: fmt.Sprintf("https://%s", imagePrefix), imageCache: cache.New( 30*time.Minute, // Default ttl for each entry time.Hour, // Cleanup interval @@ -77,14 +70,3 @@ func getRegistry(imagePrefix string) *registry { registries[registry.imagePrefix] = registry return registry } - -// normalizeImageName returns a normalized image name that accounts for the fact -// that some registries have a default namespace that is used when the image -// name doesn't specify one. For example on Docker Hub, "debian" officially -// equates to "library/debian". -func (r *registry) normalizeImageName(image string) string { - if len(strings.Split(image, "/")) == 1 && r.defaultNamespace != "" { - return fmt.Sprintf("%s/%s", r.defaultNamespace, image) - } - return image -} diff --git a/internal/image/registry_test.go b/internal/image/registry_test.go index 6bcd6431c..b6b760fcc 100644 --- a/internal/image/registry_test.go +++ b/internal/image/registry_test.go @@ -1,7 +1,6 @@ package image import ( - "fmt" "testing" "github.com/stretchr/testify/require" @@ -13,11 +12,6 @@ func TestNewRegistry(t *testing.T) { require.NotNil(t, r) require.Equal(t, testPrefix, r.name) require.NotEmpty(t, testPrefix, r.imagePrefix) - require.NotEmpty( - t, - fmt.Sprintf("https://%s", testPrefix), - r.apiAddress, - ) require.Empty(t, r.defaultNamespace) require.NotNil(t, r.imageCache) } @@ -54,49 +48,3 @@ func TestGetRegistry(t *testing.T) { }) } } - -func TestNormalizeImageName(t *testing.T) { - testCases := []struct { - name string - imageName string - registry *registry - assertions func(*testing.T, string) - }{ - { - name: "registry has no default namespace", - imageName: "fake-image", - registry: ®istry{}, - assertions: func(t *testing.T, normalizedName string) { - require.Equal(t, "fake-image", normalizedName) - }, - }, - { - name: "image name does not need default namespace added", - imageName: "fake-namespace/fake-image", - registry: ®istry{ - defaultNamespace: "library", - }, - assertions: func(t *testing.T, normalizedName string) { - require.Equal(t, "fake-namespace/fake-image", normalizedName) - }, - }, - { - name: "image name does needs default namespace added", - imageName: "fake-image", - registry: ®istry{ - defaultNamespace: "library", - }, - assertions: func(t *testing.T, normalizedName string) { - require.Equal(t, "library/fake-image", normalizedName) - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - testCase.assertions( - t, - testCase.registry.normalizeImageName(testCase.imageName), - ) - }) - } -} diff --git a/internal/image/repository_client.go b/internal/image/repository_client.go index f8337e3ed..67baae2de 100644 --- a/internal/image/repository_client.go +++ b/internal/image/repository_client.go @@ -3,25 +3,17 @@ package image import ( "context" "crypto/tls" - "encoding/json" + "errors" "fmt" "net/http" - "strings" "time" - "github.com/distribution/distribution/v3" - "github.com/distribution/distribution/v3/manifest/manifestlist" - "github.com/distribution/distribution/v3/manifest/ocischema" - "github.com/distribution/distribution/v3/manifest/schema1" //nolint: staticcheck - "github.com/distribution/distribution/v3/manifest/schema2" - "github.com/distribution/distribution/v3/reference" - "github.com/distribution/distribution/v3/registry/client" - "github.com/distribution/distribution/v3/registry/client/auth" - "github.com/distribution/distribution/v3/registry/client/auth/challenge" - "github.com/distribution/distribution/v3/registry/client/transport" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/hashicorp/go-cleanhttp" - "github.com/opencontainers/go-digest" - ociv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/patrickmn/go-cache" "go.uber.org/ratelimit" "golang.org/x/sync/semaphore" @@ -42,24 +34,13 @@ const ( var metaSem = semaphore.NewWeighted(maxMetadataConcurrency) -// knownMediaTypes is the list of supported media types. -var knownMediaTypes = []string{ - // V! - schema1.MediaTypeSignedManifest, //nolint: staticcheck - // V2 - schema2.SchemaVersion.MediaType, - manifestlist.SchemaVersion.MediaType, - // OCI - ocischema.SchemaVersion.MediaType, - ociv1.MediaTypeImageIndex, -} - // repositoryClient is a client for retrieving information from a specific image // container repository. type repositoryClient struct { - registry *registry - image string - repo distribution.Repository + registry *registry + repoURL string + repoRef name.Reference + remoteOptions []remote.Option // The following behaviors are overridable for testing purposes: @@ -70,51 +51,33 @@ type repositoryClient struct { ) (*Image, error) getImageByDigestFn func( - context.Context, - digest.Digest, - *platformConstraint, - ) (*Image, error) - - getManifestByTagFn func( context.Context, string, - ) (distribution.Manifest, error) - - getManifestByDigestFn func( - context.Context, - digest.Digest, - ) (distribution.Manifest, error) - - extractImageFromManifestFn func( - context.Context, - distribution.Manifest, - *platformConstraint, - ) (*Image, error) - - extractImageFromV1ManifestFn func( - *schema1.SignedManifest, // nolint: staticcheck *platformConstraint, ) (*Image, error) - extractImageFromV2ManifestFn func( + getImageFromRemoteDescFn func( context.Context, - *schema2.DeserializedManifest, + *remote.Descriptor, *platformConstraint, ) (*Image, error) - extractImageFromOCIManifestFn func( - context.Context, - *ocischema.DeserializedManifest, - *platformConstraint, + getImageFromV1ImageIndexFn func( + ctx context.Context, + digest string, + idx v1.ImageIndex, + platform *platformConstraint, ) (*Image, error) - extractImageFromCollectionFn func( - context.Context, - distribution.Manifest, - *platformConstraint, + getImageFromV1ImageFn func( + digest string, + img v1.Image, + platform *platformConstraint, ) (*Image, error) - getBlobFn func(context.Context, digest.Digest) ([]byte, error) + remoteListFn func(name.Repository, ...remote.Option) ([]string, error) + + remoteGetFn func(name.Reference, ...remote.Option) (*remote.Descriptor, error) } // newRepositoryClient parses the provided repository URL to infer registry @@ -125,14 +88,11 @@ func newRepositoryClient( insecureSkipTLSVerify bool, creds *Credentials, ) (*repositoryClient, error) { - repoRef, err := reference.ParseNormalizedNamed(repoURL) + repoRef, err := name.ParseReference(repoURL) if err != nil { return nil, fmt.Errorf("error parsing image repo URL %s: %w", repoURL, err) } - registryURL := reference.Domain(repoRef) - reg := getRegistry(registryURL) - image := reg.normalizeImageName(reference.Path(repoRef)) - apiAddress := strings.TrimSuffix(reg.apiAddress, "/") + reg := getRegistry(repoRef.Context().RegistryStr()) httpTransport := cleanhttp.DefaultTransport() if insecureSkipTLSVerify { @@ -141,114 +101,43 @@ func newRepositoryClient( } } - challengeManager, err := getChallengeManager( - apiAddress, - &rateLimitedRoundTripper{ - limiter: reg.rateLimiter, - internalRoundTripper: httpTransport, - }, - ) - if err != nil { - return nil, fmt.Errorf("error getting challenge manager for %s: %w", apiAddress, err) - } - if creds == nil { creds = &Credentials{} } - - rlt := &rateLimitedRoundTripper{ - limiter: reg.rateLimiter, - internalRoundTripper: transport.NewTransport( - httpTransport, - auth.NewAuthorizer( - challengeManager, - auth.NewTokenHandler( - httpTransport, - creds, - image, - "pull", - ), - auth.NewBasicHandler(creds), - ), - ), - } - - imageRef, err := reference.WithName(image) - if err != nil { - return nil, fmt.Errorf("error getting reference for image %q: %w", image, err) - } - repo, err := client.NewRepository(imageRef, apiAddress, rlt) - if err != nil { - return nil, fmt.Errorf( - "error creating internal repository for image %q in registry %s: %w", - image, - apiAddress, - err, - ) + var auth authn.Authenticator = &authn.Basic{ + Username: creds.Username, + Password: creds.Password, } r := &repositoryClient{ registry: reg, - image: image, - repo: repo, + repoURL: repoURL, + repoRef: repoRef, + remoteOptions: []remote.Option{ + remote.WithTransport(&rateLimitedRoundTripper{ + limiter: reg.rateLimiter, + internalRoundTripper: httpTransport, + }), + remote.WithAuth(auth), + }, } r.getImageByTagFn = r.getImageByTag r.getImageByDigestFn = r.getImageByDigest - r.getManifestByTagFn = r.getManifestByTag - r.getManifestByDigestFn = r.getManifestByDigest - r.extractImageFromManifestFn = r.extractImageFromManifest - r.extractImageFromV1ManifestFn = r.extractImageFromV1Manifest - r.extractImageFromV2ManifestFn = r.extractImageFromV2Manifest - r.extractImageFromOCIManifestFn = r.extractImageFromOCIManifest - r.extractImageFromCollectionFn = r.extractImageFromCollection - r.getBlobFn = r.getBlob + r.getImageFromRemoteDescFn = r.getImageFromRemoteDesc + r.getImageFromV1ImageIndexFn = r.getImageFromV1ImageIndex + r.getImageFromV1ImageFn = r.getImageFromV1Image + r.remoteListFn = remote.List + r.remoteGetFn = remote.Get return r, nil } -// getChallengeManager makes an initial request to a registry's API v2 endpoint. -// The response is used to configure a challenge manager, which is returned. -// -// Defining it this way makes it easy to override for testing purposes. -var getChallengeManager = func( - apiAddress string, - roundTripper http.RoundTripper, -) (challenge.Manager, error) { - httpClient := &http.Client{ - Transport: roundTripper, - } - apiAddress = fmt.Sprintf("%s/v2/", apiAddress) - resp, err := httpClient.Get(apiAddress) - if err != nil { - return nil, fmt.Errorf("error requesting %s: %w", apiAddress, err) - } - defer resp.Body.Close() - // Consider only HTTP 200 and 401 to be valid responses - if resp.StatusCode != http.StatusOK && - resp.StatusCode != http.StatusUnauthorized { - return nil, fmt.Errorf( - "GET %s returned an HTTP %d status code; this address may not "+ - "be a valid v2 Registry endpoint", - apiAddress, - resp.StatusCode, - ) - } - challengeManager := challenge.NewSimpleManager() - if err = challengeManager.AddResponse(resp); err != nil { - err = fmt.Errorf("error configuring challenge manager: %w", err) - } - return challengeManager, err -} - -// getTags retrieves a list of all tags from the repository. func (r *repositoryClient) getTags(ctx context.Context) ([]string, error) { - logger := logging.LoggerFromContext(ctx) - logger.Trace("retrieving tags for image") - tagSvc := r.repo.Tags(ctx) - tags, err := tagSvc.All(ctx) + opts := append(r.remoteOptions, remote.WithContext(ctx)) + tags, err := r.remoteListFn(r.repoRef.Context(), opts...) if err != nil { - return nil, fmt.Errorf("error retrieving tags from repository: %w", err) + return nil, fmt.Errorf("error listing tags for repo URL %s: %w", r.repoURL, err) } return tags, nil } @@ -260,330 +149,120 @@ func (r *repositoryClient) getImageByTag( tag string, platform *platformConstraint, ) (*Image, error) { - manifest, err := r.getManifestByTagFn(ctx, tag) + repoRef := r.repoRef.Context().Tag(tag) + opts := append(r.remoteOptions, remote.WithContext(ctx)) + desc, err := r.remoteGetFn(repoRef, opts...) if err != nil { - return nil, fmt.Errorf("error retrieving manifest for tag %s: %w", tag, err) + return nil, fmt.Errorf( + "error getting image descriptor for tag %q from repo URL %s: %w", + tag, r.repoURL, err, + ) } - image, err := r.extractImageFromManifestFn(ctx, manifest, platform) + img, err := r.getImageFromRemoteDescFn(ctx, desc, platform) if err != nil { - return nil, fmt.Errorf("error extracting image from manifest for tag %q: %w", tag, err) + return nil, fmt.Errorf( + "error getting image from descriptor for tag %q from repo URL %s: %w", + tag, r.repoURL, err, + ) } - if image != nil { - image.Tag = tag + if img != nil { + img.Tag = tag } - return image, nil + return img, nil } // getImageByDigest retrieves an Image for a given digest. This function uses a // cache since information retrieved by digest will never change. func (r *repositoryClient) getImageByDigest( ctx context.Context, - d digest.Digest, + digest string, platform *platformConstraint, ) (*Image, error) { logger := logging.LoggerFromContext(ctx) - logger.Tracef("retrieving image for manifest %s", d) + logger.Tracef("retrieving image with digest %s", digest) - if entry, exists := r.registry.imageCache.Get(d.String()); exists { + if entry, exists := r.registry.imageCache.Get(digest); exists { image := entry.(Image) // nolint: forcetypeassert return &image, nil } - logger.Tracef("image for manifest %s NOT found in cache", d) + logger.Tracef("image with digest %s NOT found in cache", digest) - manifest, err := r.getManifestByDigestFn(ctx, d) - if err != nil { - return nil, fmt.Errorf("error retrieving manifest %s: %w", d, err) - } - image, err := r.extractImageFromManifestFn(ctx, manifest, platform) - if err != nil { - return nil, fmt.Errorf("error extracting image from manifest %s: %w", d, err) - } - - if image != nil { - // Cache the image - r.registry.imageCache.Set(d.String(), *image, cache.DefaultExpiration) - logger.Tracef("cached image for manifest %s", d) - } - - return image, nil -} - -// getManifestByTag retrieves a manifest for a given tag. -func (r *repositoryClient) getManifestByTag( - ctx context.Context, - tag string, -) (distribution.Manifest, error) { - logger := logging.LoggerFromContext(ctx) - logger.Tracef("retrieving manifest for tag %q from repository", tag) - manifestSvc, err := r.repo.Manifests(ctx) - if err != nil { - return nil, fmt.Errorf("error getting manifest service: %w", err) - } - manifest, err := manifestSvc.Get( - ctx, - digest.FromString(tag), - distribution.WithTag(tag), - distribution.WithManifestMediaTypes(knownMediaTypes), - ) - if err != nil { - return nil, fmt.Errorf("error retrieving manifest for tag %q: %w", tag, err) - } - return manifest, nil -} - -// getManifestByDigest retrieves a manifest for a given digest. -func (r *repositoryClient) getManifestByDigest( - ctx context.Context, - d digest.Digest, -) (distribution.Manifest, error) { - logger := logging.LoggerFromContext(ctx) - logger.Tracef("retrieving manifest for digest %q from repository", d.String()) - manifestSvc, err := r.repo.Manifests(ctx) - if err != nil { - return nil, fmt.Errorf("error getting manifest service: %w", err) - } - manifest, err := manifestSvc.Get( - ctx, - d, - distribution.WithManifestMediaTypes(knownMediaTypes), - ) - if err != nil { - return nil, fmt.Errorf("error retrieving manifest for digest %q: %w", d, err) - } - return manifest, nil -} - -// extractImageFromManifest extracts an Image from a given manifest. V1 -// (legacy), V2, and OCI manifests are supported as well as manifest lists and -// indices (e.g. for multi-arch images). -func (r *repositoryClient) extractImageFromManifest( - ctx context.Context, - manifest distribution.Manifest, - platform *platformConstraint, -) (*Image, error) { - switch m := manifest.(type) { - case *schema1.SignedManifest: //nolint: staticcheck - return r.extractImageFromV1ManifestFn(m, platform) - case *schema2.DeserializedManifest: - return r.extractImageFromV2ManifestFn(ctx, m, platform) - case *ocischema.DeserializedManifest: - return r.extractImageFromOCIManifestFn(ctx, m, platform) - case *manifestlist.DeserializedManifestList, *ocischema.DeserializedImageIndex: - return r.extractImageFromCollectionFn(ctx, manifest, platform) - default: - return nil, fmt.Errorf("invalid manifest type %T", manifest) - } -} - -// manifestInfo is a struct used for unmarshaling manifest information. -type manifestInfo struct { - OS string `json:"os"` - Arch string `json:"architecture"` - Variant string `json:"variant"` - Created string `json:"created"` -} - -// extractImageFromV1Manifest extracts an Image from a given V1 manifest. It is -// valid for this function to return nil if the manifest does not match the -// specified platform, if any. -func (r *repositoryClient) extractImageFromV1Manifest( - manifest *schema1.SignedManifest, // nolint: staticcheck - platform *platformConstraint, -) (*Image, error) { - // We need this to calculate the digest - _, manifestBytes, err := manifest.Payload() // nolint: staticcheck - if err != nil { - return nil, fmt.Errorf("error extracting payload from V1 manifest: %w", err) - } - digest := digest.FromBytes(manifestBytes) - - logger := logging.LoggerFromContext(context.Background()) - logger.Tracef("extracting image from V1 manifest %s", digest) - - if len(manifest.History) == 0 { - return nil, fmt.Errorf("no history information found in V1 manifest %s", digest) - } - - var info manifestInfo - if err = json.Unmarshal( - []byte(manifest.History[0].V1Compatibility), - &info, - ); err != nil { - return nil, fmt.Errorf("error unmarshaling V1 manifest %s: %w", digest, err) - } - - if platform != nil && - !platform.matches(info.OS, info.Arch, info.Variant) { - return nil, nil - } - - createdAt, err := time.Parse(time.RFC3339Nano, info.Created) + repoRef := r.repoRef.Context().Digest(digest) + opts := append(r.remoteOptions, remote.WithContext(ctx)) + desc, err := r.remoteGetFn(repoRef, opts...) if err != nil { return nil, fmt.Errorf( - "error parsing createdAt timestamp from V1 manifest %s: %w", - digest, - err, + "error getting image descriptor for digest %s from repo URL %s: %w", + digest, r.repoURL, err, ) } - return &Image{ - Digest: digest, - CreatedAt: &createdAt, - }, nil -} - -// extractImageFromV2Manifest extracts an Image from a given V2 manifest. It is -// valid for this function to return nil if the manifest does not match the -// specified platform, if any. -func (r *repositoryClient) extractImageFromV2Manifest( - ctx context.Context, - manifest *schema2.DeserializedManifest, - platform *platformConstraint, -) (*Image, error) { - // We need this to calculate the digest - _, manifestBytes, err := manifest.Payload() - if err != nil { - return nil, fmt.Errorf("error extracting payload from V2 manifest: %w", err) - } - digest := digest.FromBytes(manifestBytes) - - logger := logging.LoggerFromContext(ctx) - logger.Tracef("extracting image from V2 manifest %s", digest) - - // This referenced config object has platform information and creation - // timestamp - blob, err := r.getBlobFn(ctx, manifest.Config.Digest) + img, err := r.getImageFromRemoteDescFn(ctx, desc, platform) if err != nil { return nil, fmt.Errorf( - "error fetching blob %s referenced by V2 manifest %s: %w", - manifest.Config.Digest, - digest, - err, - ) - } - var info manifestInfo - if err = json.Unmarshal(blob, &info); err != nil { - return nil, fmt.Errorf( - "error unmarshaling blob %s referenced by V2 manifest %s: %w", - manifest.Config.Digest, - digest, - err, + "error getting image from descriptor for digest %s from repo URL %s: %w", + digest, r.repoURL, err, ) } - if platform != nil && - !platform.matches(info.OS, info.Arch, info.Variant) { - return nil, nil - } - - createdAt, err := time.Parse(time.RFC3339Nano, info.Created) - if err != nil { - return nil, fmt.Errorf( - "error parsing createdAt timestamp from blob %s referenced by V2 manifest %s: %w", - manifest.Config.Digest, - digest, - err, - ) + if img != nil { + // Cache the image + r.registry.imageCache.Set(digest, *img, cache.DefaultExpiration) + logger.Tracef("cached image for digest %s", digest) } - return &Image{ - Digest: digest, - CreatedAt: &createdAt, - }, nil + return img, nil } -// extractImageFromOCIManifest extracts an Image from a given OCI manifest. It -// is valid for this function to return nil if the manifest does not match the -// specified platform, if any. -func (r *repositoryClient) extractImageFromOCIManifest( +// getImageFromRemoteDesc gets an Image from a given remote.Descriptor. +func (r *repositoryClient) getImageFromRemoteDesc( ctx context.Context, - manifest *ocischema.DeserializedManifest, + desc *remote.Descriptor, platform *platformConstraint, ) (*Image, error) { - // We need this to calculate the digest - _, manifestBytes, err := manifest.Payload() - if err != nil { - return nil, fmt.Errorf("error extracting payload from OCI manifest: %w", err) - } - digest := digest.FromBytes(manifestBytes) - - logger := logging.LoggerFromContext(ctx) - logger.Tracef("extracting image from OCI manifest %s", digest) - - // This referenced config object has platform information and creation - // timestamp - blob, err := r.getBlobFn(ctx, manifest.Config.Digest) - if err != nil { - return nil, fmt.Errorf( - "error fetching blob %s referenced by OCI manifest %s: %w", - manifest.Config.Digest, - digest, - err, - ) - } - var info manifestInfo - if err = json.Unmarshal(blob, &info); err != nil { - return nil, fmt.Errorf( - "error unmarshaling blob %s referenced by OCI manifest %s: %w", - manifest.Config.Digest, - digest, - err, - ) - } - - if info.OS == unknown || info.OS == "" || info.Arch == unknown || info.Arch == "" { - // This doesn't look like an image. It might be an attestation or something - // else. It's definitely not what we're looking for. - return nil, nil - } - - if platform != nil && - !platform.matches(info.OS, info.Arch, info.Variant) { - return nil, nil - } - - createdAt, err := time.Parse(time.RFC3339Nano, info.Created) - if err != nil { - return nil, fmt.Errorf( - "error parsing createdAt timestamp from blob %s referenced by OCI manifest %s: %w", - manifest.Config.Digest, - digest, - err, - ) + switch desc.MediaType { + case types.OCIImageIndex, types.DockerManifestList: + idx, err := desc.ImageIndex() + if err != nil { + return nil, fmt.Errorf( + "error getting image index from descriptor with digest %s: %w", + desc.Digest.String(), err, + ) + } + return r.getImageFromV1ImageIndexFn(ctx, desc.Digest.String(), idx, platform) + case types.OCIManifestSchema1, types.DockerManifestSchema2: + img, err := desc.Image() + if err != nil { + return nil, fmt.Errorf( + "error getting image from descriptor with digest %s: %w", + desc.Digest.String(), err, + ) + } + return r.getImageFromV1ImageFn(desc.Digest.String(), img, platform) + default: + return nil, fmt.Errorf("unknown artifact type: %s", desc.MediaType) } - - return &Image{ - Digest: digest, - CreatedAt: &createdAt, - }, nil } -// extractImageFromCollection extracts an Image from a V2 manifest list or OCI -// index. It is valid for this function to return nil if no manifest in the list -// or index matches the specified platform, if any. This function assumes it is -// only ever invoked with a manifest list or index. -func (r *repositoryClient) extractImageFromCollection( +// getImageFromV1ImageIndex gets an Image from a given v1.ImageIndex. It is +// valid for this function to return nil if no image in the index matches the +// specified platform, if any. +func (r *repositoryClient) getImageFromV1ImageIndex( ctx context.Context, - collection distribution.Manifest, + digest string, + idx v1.ImageIndex, platform *platformConstraint, ) (*Image, error) { - // We need this to calculate the digest. Note that this is the digest of the - // list or index. - _, manifestBytes, err := collection.Payload() + idxManifest, err := idx.IndexManifest() if err != nil { - return nil, fmt.Errorf("error getting collection payload: %w", err) + return nil, fmt.Errorf( + "error getting index manifest from index with digest %s: %w", + digest, err, + ) } - digest := digest.FromBytes(manifestBytes) - - logger := logging.LoggerFromContext(ctx) - logger.Tracef( - "extracting image from V2 manifest list or OCI index %s", - digest, - ) - - refs := make([]distribution.Descriptor, 0, len(collection.References())) - for _, ref := range collection.References() { + refs := make([]v1.Descriptor, 0, len(idxManifest.Manifests)) + for _, ref := range idxManifest.Manifests { if ref.Platform == nil || ref.Platform.OS == unknown || ref.Platform.OS == "" || ref.Platform.Architecture == unknown || ref.Platform.Architecture == "" { @@ -593,21 +272,15 @@ func (r *repositoryClient) extractImageFromCollection( } refs = append(refs, ref) } - if len(refs) == 0 { - return nil, fmt.Errorf( - "empty V2 manifest list or OCI index %s is not supported", - digest, - ) + return nil, errors.New("empty V2 manifest list or OCI index is not supported") } - // If there's a platform constraint, find the ref that matches it and // that's the information we're really after. if platform != nil { - var matchedRefs []distribution.Descriptor - // Filter out references that don't match the platform + var matchedRefs []v1.Descriptor for _, ref := range refs { - if platform != nil && !platform.matches( + if !platform.matches( ref.Platform.OS, ref.Platform.Architecture, ref.Platform.Variant, @@ -629,24 +302,24 @@ func (r *repositoryClient) extractImageFromCollection( ) } ref := matchedRefs[0] - image, err := r.getImageByDigestFn(ctx, ref.Digest, platform) + img, err := r.getImageByDigestFn(ctx, ref.Digest.String(), platform) if err != nil { return nil, fmt.Errorf( - "error getting image from manifest %s: %w", - ref.Digest, + "error getting image with digest %s: %w", + ref.Digest.String(), err, ) } - if image == nil { + if img == nil { // This really shouldn't happen. return nil, fmt.Errorf( - "expected manifest for digest %v to match platform %q, but it did not", - ref.Digest, + "expected manifest for digest %s to match platform %q, but it did not", + ref.Digest.String(), platform.String(), ) } - image.Digest = digest - return image, nil + img.Digest = digest + return img, nil } // If we get to here there was no platform constraint. @@ -656,40 +329,49 @@ func (r *repositoryClient) extractImageFromCollection( // recently pushed manifest's createdAt timestamp. var createdAt *time.Time for _, ref := range refs { - image, err := r.getImageByDigestFn(ctx, ref.Digest, platform) + img, err := r.getImageByDigestFn(ctx, ref.Digest.String(), platform) if err != nil { return nil, fmt.Errorf( - "error getting image from manifest %s: %w", - ref.Digest, - err, + "error getting image with digest %s: %w", ref.Digest, err, ) } - if image == nil { + if img == nil { // This really shouldn't happen. - return nil, fmt.Errorf( - "found no image for manifest %s", - ref.Digest, - ) + return nil, fmt.Errorf("found no image with digest %s", ref.Digest) } - if createdAt == nil || image.CreatedAt.After(*createdAt) { - createdAt = image.CreatedAt + if createdAt == nil || img.CreatedAt.After(*createdAt) { + createdAt = img.CreatedAt } } - return &Image{ Digest: digest, CreatedAt: createdAt, }, nil } -// getBlob retrieves a blob from the repository. -func (r *repositoryClient) getBlob( - ctx context.Context, - digest digest.Digest, -) ([]byte, error) { - logger := logging.LoggerFromContext(ctx) - logger.Tracef("retrieving blob for digest %q", digest.String()) - return r.repo.Blobs(ctx).Get(ctx, digest) +// getImageFromV1Image gets an Image from a given v1.Image. It is valid for this +// function to return nil the image does not match the specified platform, if +// any. +func (r *repositoryClient) getImageFromV1Image( + digest string, + img v1.Image, + platform *platformConstraint, +) (*Image, error) { + cfg, err := img.ConfigFile() + if err != nil { + return nil, fmt.Errorf( + "error getting image config for image with digest %s: %w", + digest, err, + ) + } + if platform != nil && !platform.matches(cfg.OS, cfg.Architecture, cfg.Variant) { + // This image doesn't match the platform constraint. + return nil, nil + } + return &Image{ + Digest: digest, + CreatedAt: &cfg.Created.Time, + }, nil } // rateLimitedRoundTripper is a rate limited implementation of diff --git a/internal/image/repository_client_docker_hub_test.go b/internal/image/repository_client_docker_hub_test.go index ed9b41147..62854f4ef 100644 --- a/internal/image/repository_client_docker_hub_test.go +++ b/internal/image/repository_client_docker_hub_test.go @@ -5,11 +5,9 @@ package image import ( "context" - "net/http" "os" "testing" - "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" ) @@ -19,16 +17,7 @@ import ( // // To use your Docker credentials, set env vars: // - DOCKER_HUB_USERNAME -// - DOCKER_HUB_USERNAME (personal access token) - -func TestGetChallengeManager(t *testing.T) { - challengeManager, err := getChallengeManager( - "https://registry-1.docker.io", - http.DefaultTransport, - ) - require.NoError(t, err) - require.NotNil(t, challengeManager) -} +// - DOCKER_HUB_PASSWORD (personal access token) func TestGetTags(t *testing.T) { client, err := newRepositoryClient("debian", false, getDockerHubCreds()) @@ -39,36 +28,6 @@ func TestGetTags(t *testing.T) { require.NotEmpty(t, tags) } -func TestGetManifestByTag(t *testing.T) { - client, err := newRepositoryClient("debian", false, getDockerHubCreds()) - require.NoError(t, err) - require.NotNil(t, client) - // Note: This is only going to come back with a manifest list. It won't - // follow the references found therein. - manifest, err := client.getManifestByTag(context.Background(), "latest") - require.NoError(t, err) - require.NotNil(t, manifest) -} - -func TestGetManifestByDigest(t *testing.T) { - // This is a real digest for a debian bookworm image - // nolint: lll - // https://hub.docker.com/layers/library/debian/bookworm/images/sha256-bd989d36e94ef694541231541b04c8c89bc6ccb8d015f12a715b605c64edde4a - const testDigest = "sha256:bd989d36e94ef694541231541b04c8c89bc6ccb8d015f12a715b605c64edde4a" // nolint: gosec - client, err := newRepositoryClient("debian", false, getDockerHubCreds()) - require.NoError(t, err) - m, err := - client.getManifestByDigest(context.Background(), testDigest) - require.NoError(t, err) - _, manifestBytes, err := m.Payload() - require.NoError(t, err) - require.Equal( - t, - testDigest, - digest.FromBytes(manifestBytes).String(), - ) -} - func getDockerHubCreds() *Credentials { return &Credentials{ // It's ok if these are empty, but you'll probably get rate limited. diff --git a/internal/image/repository_client_test.go b/internal/image/repository_client_test.go index a48e3d620..dc708a974 100644 --- a/internal/image/repository_client_test.go +++ b/internal/image/repository_client_test.go @@ -3,708 +3,346 @@ package image import ( "context" "errors" - "net/http" "testing" "time" - "github.com/distribution/distribution/v3" - "github.com/distribution/distribution/v3/manifest/manifestlist" - "github.com/distribution/distribution/v3/manifest/ocischema" - "github.com/distribution/distribution/v3/manifest/schema1" // nolint: staticcheck - "github.com/distribution/distribution/v3/manifest/schema2" - "github.com/distribution/distribution/v3/registry/client/auth/challenge" - "github.com/opencontainers/go-digest" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" "github.com/patrickmn/go-cache" "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" ) func TestNewRepository(t *testing.T) { - getChallengeManagerBackup := getChallengeManager - getChallengeManager = func( - string, - http.RoundTripper, - ) (challenge.Manager, error) { - return challenge.NewSimpleManager(), nil - } - defer func() { - getChallengeManager = getChallengeManagerBackup - }() - client, err := newRepositoryClient("debian", false, nil) require.NoError(t, err) require.NotNil(t, client) require.NotNil(t, client.registry) - require.NotEmpty(t, client.image) - require.NotNil(t, client.repo) + require.NotEmpty(t, client.repoURL) + require.NotNil(t, client.repoRef) // Make sure default behaviors are set require.NotNil(t, client.getImageByTagFn) require.NotNil(t, client.getImageByDigestFn) - require.NotNil(t, client.getManifestByTagFn) - require.NotNil(t, client.getManifestByDigestFn) - require.NotNil(t, client.extractImageFromManifestFn) - require.NotNil(t, client.extractImageFromV1ManifestFn) - require.NotNil(t, client.extractImageFromV2ManifestFn) - require.NotNil(t, client.extractImageFromOCIManifestFn) - require.NotNil(t, client.extractImageFromCollectionFn) - require.NotNil(t, client.getBlobFn) + require.NotNil(t, client.getImageFromRemoteDescFn) + require.NotNil(t, client.getImageFromV1ImageIndexFn) + require.NotNil(t, client.getImageFromV1ImageFn) + require.NotNil(t, client.remoteListFn) + require.NotNil(t, client.remoteGetFn) } func TestGetImageByTag(t *testing.T) { - timePtr := func(t time.Time) *time.Time { - return &t - } + const testRepoURL = "fake-url" + const testTag = "fake-tag" + + testRepoRef, err := name.ParseReference(testRepoURL) + require.NoError(t, err) testImage := Image{ - CreatedAt: timePtr(time.Now().UTC()), + Tag: testTag, + CreatedAt: ptr.To(time.Now().UTC()), } - testRegistry := ®istry{} testCases := []struct { name string - tag string client *repositoryClient assertions func(*testing.T, *Image, error) }{ { - name: "error getting manifest for tag", - tag: "fake-tag", + name: "error getting descriptor by tag", client: &repositoryClient{ - registry: testRegistry, - getManifestByTagFn: func( - context.Context, - string, - ) (distribution.Manifest, error) { + repoRef: testRepoRef, + remoteGetFn: func( + name.Reference, + ...remote.Option, + ) (*remote.Descriptor, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error retrieving manifest") + require.ErrorContains(t, err, "error getting image descriptor for tag") require.ErrorContains(t, err, "something went wrong") }, }, { - name: "error extracting image from manifest", - tag: "fake-tag", + name: "error getting image from descriptor", client: &repositoryClient{ - registry: testRegistry, - getManifestByTagFn: func( - context.Context, - string, - ) (distribution.Manifest, error) { - return &ocischema.DeserializedManifest{}, nil - }, - extractImageFromManifestFn: func( + repoRef: testRepoRef, + remoteGetFn: func( + name.Reference, + ...remote.Option, + ) (*remote.Descriptor, error) { + return &remote.Descriptor{}, nil + }, + getImageFromRemoteDescFn: func( context.Context, - distribution.Manifest, + *remote.Descriptor, *platformConstraint, ) (*Image, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error extracting image from manifest") + require.ErrorContains(t, err, "error getting image from descriptor for tag") require.ErrorContains(t, err, "something went wrong") }, }, { name: "success", - tag: "fake-tag", client: &repositoryClient{ - image: "fake-image", - registry: testRegistry, - getManifestByTagFn: func( - context.Context, - string, - ) (distribution.Manifest, error) { - return &ocischema.DeserializedManifest{}, nil - }, - extractImageFromManifestFn: func( + repoRef: testRepoRef, + remoteGetFn: func( + name.Reference, + ...remote.Option, + ) (*remote.Descriptor, error) { + return &remote.Descriptor{}, nil + }, + getImageFromRemoteDescFn: func( context.Context, - distribution.Manifest, + *remote.Descriptor, *platformConstraint, ) (*Image, error) { return &testImage, nil }, }, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.Equal(t, testImage, *image) + require.Equal(t, testImage, *img) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.getImageByTag( + img, err := testCase.client.getImageByTag( context.Background(), - testCase.tag, + testTag, nil, ) - testCase.assertions(t, image, err) + testCase.assertions(t, img, err) }) } } func TestGetImageByDigest(t *testing.T) { - timePtr := func(t time.Time) *time.Time { - return &t - } + const testRepoURL = "fake-url" + const testDigest = "fake-digest" + + testRepoRef, err := name.ParseReference(testRepoURL) + require.NoError(t, err) testImage := Image{ - CreatedAt: timePtr(time.Now().UTC()), + Digest: testDigest, + CreatedAt: ptr.To(time.Now().UTC()), } + testRegistry := ®istry{ imageCache: cache.New(0, 0), } - const testCachedDigest = "fake-cached-digest" testRegistry.imageCache.Set( - testCachedDigest, + testImage.Digest, testImage, cache.DefaultExpiration, ) testCases := []struct { name string - digest digest.Digest client *repositoryClient assertions func(*testing.T, *Image, error) }{ { - name: "cache hit", - digest: testCachedDigest, + name: "cache hit", client: &repositoryClient{ registry: testRegistry, }, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.Equal(t, testImage, *image) + require.Equal(t, testImage, *img) }, }, { - name: "error getting manifest for digest", - digest: "fake-digest", + name: "error getting descriptor by digest", client: &repositoryClient{ - registry: testRegistry, - getManifestByDigestFn: func( - context.Context, - digest.Digest, - ) (distribution.Manifest, error) { + repoRef: testRepoRef, + registry: ®istry{ + imageCache: cache.New(30*time.Minute, time.Hour), + }, + remoteGetFn: func( + name.Reference, ...remote.Option, + ) (*remote.Descriptor, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error retrieving manifest") + require.ErrorContains(t, err, "error getting image descriptor for digest") require.ErrorContains(t, err, "something went wrong") }, }, { - name: "error extracting image from manifest", - digest: "fake-digest", + name: "error getting image from descriptor", client: &repositoryClient{ - registry: testRegistry, - getManifestByDigestFn: func( - context.Context, - digest.Digest, - ) (distribution.Manifest, error) { - return &ocischema.DeserializedManifest{}, nil + repoRef: testRepoRef, + registry: ®istry{ + imageCache: cache.New(30*time.Minute, time.Hour), }, - extractImageFromManifestFn: func( - context.Context, - distribution.Manifest, - *platformConstraint, + remoteGetFn: func( + name.Reference, ...remote.Option, + ) (*remote.Descriptor, error) { + return &remote.Descriptor{}, nil + }, + getImageFromRemoteDescFn: func( + context.Context, *remote.Descriptor, *platformConstraint, ) (*Image, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error extracting image from manifest") + require.ErrorContains(t, err, "error getting image from descriptor for digest") require.ErrorContains(t, err, "something went wrong") }, }, { - name: "success", - digest: "fake-tag", + name: "success", client: &repositoryClient{ - image: "fake-image", - registry: testRegistry, - getManifestByDigestFn: func( - context.Context, - digest.Digest, - ) (distribution.Manifest, error) { - return &ocischema.DeserializedManifest{}, nil + repoRef: testRepoRef, + registry: ®istry{ + imageCache: cache.New(30*time.Minute, time.Hour), + }, + remoteGetFn: func( + name.Reference, ...remote.Option, + ) (*remote.Descriptor, error) { + return &remote.Descriptor{}, nil }, - extractImageFromManifestFn: func( + getImageFromRemoteDescFn: func( context.Context, - distribution.Manifest, + *remote.Descriptor, *platformConstraint, ) (*Image, error) { return &testImage, nil }, }, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.Equal(t, testImage, *image) + require.Equal(t, testImage, *img) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.getImageByDigest( + img, err := testCase.client.getImageByDigest( context.Background(), - testCase.digest, + testDigest, nil, ) - testCase.assertions(t, image, err) + testCase.assertions(t, img, err) }) } } -func TestExtractImageFromManifest(t *testing.T) { - timePtr := func(t time.Time) *time.Time { - return &t +func TestGetImageFromRemoteDesc(t *testing.T) { + testImage := Image{ + CreatedAt: ptr.To(time.Now().UTC()), } - testImage := Image{ - CreatedAt: timePtr(time.Now().UTC()), + mediaTypes := []types.MediaType{ + types.OCIImageIndex, + types.DockerManifestList, + types.OCIManifestSchema1, + types.DockerManifestSchema2, } - testCases := []struct { - name string - manifest distribution.Manifest - client *repositoryClient - }{ - { - name: "V1 manifest", - manifest: &schema1.SignedManifest{}, // nolint: staticcheck - client: &repositoryClient{ - extractImageFromV1ManifestFn: func( - *schema1.SignedManifest, // nolint: staticcheck - *platformConstraint, - ) (*Image, error) { - return &testImage, nil - }, - }, - }, - { - name: "V2 manifest", - manifest: &schema2.DeserializedManifest{}, - client: &repositoryClient{ - extractImageFromV2ManifestFn: func( - context.Context, - *schema2.DeserializedManifest, - *platformConstraint, - ) (*Image, error) { - return &testImage, nil - }, - }, + testClient := &repositoryClient{ + getImageFromV1ImageIndexFn: func( + context.Context, string, v1.ImageIndex, *platformConstraint, + ) (*Image, error) { + return &testImage, nil }, - { - name: "OCI manifest", - manifest: &ocischema.DeserializedManifest{}, - client: &repositoryClient{ - extractImageFromOCIManifestFn: func( - context.Context, - *ocischema.DeserializedManifest, - *platformConstraint, - ) (*Image, error) { - return &testImage, nil - }, - }, - }, - { - name: "manifest list", - manifest: &manifestlist.DeserializedManifestList{}, - client: &repositoryClient{ - extractImageFromCollectionFn: func( - context.Context, - distribution.Manifest, - *platformConstraint, - ) (*Image, error) { - return &testImage, nil - }, - }, - }, - { - name: "image index", - manifest: &ocischema.DeserializedImageIndex{}, - client: &repositoryClient{ - extractImageFromCollectionFn: func( - context.Context, - distribution.Manifest, - *platformConstraint, - ) (*Image, error) { - return &testImage, nil - }, - }, + getImageFromV1ImageFn: func( + string, v1.Image, *platformConstraint, + ) (*Image, error) { + return &testImage, nil }, } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.extractImageFromManifest( + + for _, mediaType := range mediaTypes { + t.Run(string(mediaType), func(t *testing.T) { + img, err := testClient.getImageFromRemoteDesc( context.Background(), - testCase.manifest, + &remote.Descriptor{ + Descriptor: v1.Descriptor{ + MediaType: mediaType, + }, + }, nil, ) require.NoError(t, err) - require.NotNil(t, image) - require.Equal(t, testImage, *image) + require.Equal(t, testImage, *img) }) } } -func TestExtractImageFromV1Manifest(t *testing.T) { - testTime := time.Now().UTC() - testTimeStr := testTime.Format(time.RFC3339Nano) - testCases := []struct { - name string - platform *platformConstraint - manifest *schema1.SignedManifest // nolint: staticcheck - client *repositoryClient - assertions func(*testing.T, *Image, error) - }{ - { - name: "manifest has no history", - manifest: &schema1.SignedManifest{}, // nolint: staticcheck - client: &repositoryClient{}, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "no history information found in V1 manifest") - }, - }, - { - name: "error umarshaling blob", - // nolint: staticcheck - manifest: &schema1.SignedManifest{ - Manifest: schema1.Manifest{ - History: []schema1.History{ - { - V1Compatibility: "junk", - }, - }, - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error unmarshaling V1 manifest") - }, - }, - { - name: "platform does not match", - platform: &platformConstraint{ - os: "linux", - arch: "amd64", - }, - // nolint: staticcheck - manifest: &schema1.SignedManifest{ - Manifest: schema1.Manifest{ - History: []schema1.History{ - { - V1Compatibility: `{"os": "linux", "architecture": "arm64"}`, - }, - }, - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.Nil(t, image) - }, - }, - { - name: "error parsing timestamp", - // nolint: staticcheck - manifest: &schema1.SignedManifest{ - Manifest: schema1.Manifest{ - History: []schema1.History{ - { - V1Compatibility: `{"created": "junk"}`, - }, - }, - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error parsing createdAt timestamp") - }, - }, - { - name: "success", - // nolint: staticcheck - manifest: &schema1.SignedManifest{ - Manifest: schema1.Manifest{ - History: []schema1.History{ - { - V1Compatibility: `{"os": "linux", "architecture": "amd64", "created": "` + testTimeStr + `"}`, - }, - }, - }, - }, - client: &repositoryClient{}, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.NotNil(t, image) - require.NotNil(t, image.CreatedAt) - require.Equal(t, testTime, *image.CreatedAt) - require.NotNil(t, image.Digest) - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.extractImageFromV1Manifest( - testCase.manifest, - testCase.platform, - ) - testCase.assertions(t, image, err) - }) - } -} +func TestImageFromV1ImageIndex(t *testing.T) { + const testDigest = "fake-digest" -func TestExtractImageFromV2Manifest(t *testing.T) { - testTime := time.Now().UTC() - testTimeStr := testTime.Format(time.RFC3339Nano) - testCases := []struct { - name string - platform *platformConstraint - client *repositoryClient - assertions func(*testing.T, *Image, error) - }{ - { - name: "error fetching blob", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return nil, errors.New("something went wrong") - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error fetching blob") - require.ErrorContains(t, err, "something went wrong") - }, - }, - { - name: "error unmarshaling blob", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte("junk"), nil - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error unmarshaling blob") - }, - }, - { - name: "platform does not match", - platform: &platformConstraint{ - os: "linux", - arch: "amd64", - }, - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte(`{"os": "linux", "architecture": "arm64"}`), nil - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.Nil(t, image) - }, - }, - { - name: "error parsing timestamp", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte(`{"created": "junk"}`), nil - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error parsing createdAt timestamp") - }, - }, - { - name: "success", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte( - `{"os": "linux", "architecture": "amd64", "created": "` + testTimeStr + `"}`, - ), nil - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.NotNil(t, image) - require.NotNil(t, image.CreatedAt) - require.Equal(t, testTime, *image.CreatedAt) - require.NotNil(t, image.Digest) - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.extractImageFromV2Manifest( - context.Background(), - &schema2.DeserializedManifest{}, - testCase.platform, - ) - testCase.assertions(t, image, err) - }) + testImage := Image{ + Digest: testDigest, + CreatedAt: ptr.To(time.Now().UTC()), } -} -func TestExtractImageFromOCIManifest(t *testing.T) { - testTime := time.Now().UTC() - testTimeStr := testTime.Format(time.RFC3339Nano) testCases := []struct { name string + idx v1.ImageIndex platform *platformConstraint client *repositoryClient assertions func(*testing.T, *Image, error) }{ { - name: "error fetching blob", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return nil, errors.New("something went wrong") + name: "empty list or index not supported", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{}}, }, }, + client: &repositoryClient{}, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error fetching blob") - require.ErrorContains(t, err, "something went wrong") - }, - }, - { - name: "error unmarshaling blob", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte("junk"), nil - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.Error(t, err) - require.ErrorContains(t, err, "error unmarshaling blob") - }, - }, - { - name: "doesn't look like an image", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte(`{"os": "", "architecture": ""}`), nil - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.Nil(t, image) - }, - }, - { - name: "platform does not match", - platform: &platformConstraint{ - os: "linux", - arch: "amd64", - }, - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte(`{"os": "linux", "architecture": "arm64"}`), nil - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.Nil(t, image) - }, - }, - { - name: "error parsing timestamp", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte(`{"os": "linux", "architecture": "arm64", "created": "junk"}`), nil - }, - }, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error parsing createdAt timestamp") - }, - }, - { - name: "success", - client: &repositoryClient{ - getBlobFn: func(context.Context, digest.Digest) ([]byte, error) { - return []byte( - `{"os": "linux", "architecture": "amd64", "created": "` + testTimeStr + `"}`, - ), nil - }, - }, - assertions: func(t *testing.T, image *Image, err error) { - require.NoError(t, err) - require.NotNil(t, image) - require.NotNil(t, image.CreatedAt) - require.Equal(t, testTime, *image.CreatedAt) - require.NotNil(t, image.Digest) - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.extractImageFromOCIManifest( - context.Background(), - &ocischema.DeserializedManifest{}, - testCase.platform, - ) - testCase.assertions(t, image, err) - }) - } -} - -func TestExtractImageFromCollection(t *testing.T) { - timePtr := func(t time.Time) *time.Time { - return &t - } - - testNow := time.Now().UTC() - - testCases := []struct { - name string - collection distribution.Manifest - platform *platformConstraint - client *repositoryClient - assertions func(*testing.T, *Image, error) - }{ - { - name: "empty V2 manifest list or OCI index", - collection: &manifestlist.DeserializedManifestList{}, - client: &repositoryClient{}, - assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "empty V2 manifest list or OCI index") + require.ErrorContains(t, err, "empty V2 manifest list or OCI index is not supported") }, }, { - name: "with platform constraint -- no refs matched", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "arm64", - }, + name: "no refs match platform constraint", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", }, - }, + }}, }, }, platform: &platformConstraint{ os: "linux", - arch: "amd64", + arch: "arm64", }, client: &repositoryClient{}, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.Nil(t, image) + require.Nil(t, img) }, }, { - name: "with platform constraint -- too many refs matched", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ + name: "multiples refs match platform constraint", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{ { - Platform: manifestlist.PlatformSpec{ + Platform: &v1.Platform{ OS: "linux", Architecture: "amd64", }, }, { - Platform: manifestlist.PlatformSpec{ + Platform: &v1.Platform{ OS: "linux", Architecture: "amd64", }, @@ -718,22 +356,19 @@ func TestExtractImageFromCollection(t *testing.T) { }, client: &repositoryClient{}, assertions: func(t *testing.T, _ *Image, err error) { - require.Error(t, err) require.ErrorContains(t, err, "expected only one reference to match platform") }, }, { - name: "with platform constraint -- error getting image by digest", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, + name: "with platform constraint, error getting image by digest", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", }, - }, + }}, }, }, platform: &platformConstraint{ @@ -742,28 +377,25 @@ func TestExtractImageFromCollection(t *testing.T) { }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error getting image from manifest") + require.ErrorContains(t, err, "error getting image with digest") + require.ErrorContains(t, err, "something went wrong") }, }, { - name: "with platform constraint -- no image found", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, - }, + name: "with platform constraint, image found but doesn't match platform", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }}, }, }, }, @@ -773,9 +405,7 @@ func TestExtractImageFromCollection(t *testing.T) { }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { return nil, nil }, @@ -786,16 +416,14 @@ func TestExtractImageFromCollection(t *testing.T) { }, }, { - name: "with platform constraint -- success", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, - }, + name: "with platform constraint, success", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", + }}, }, }, }, @@ -805,117 +433,253 @@ func TestExtractImageFromCollection(t *testing.T) { }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { - return &Image{ - CreatedAt: timePtr(testNow), - }, nil + return &testImage, nil }, }, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.NotNil(t, image) - require.NotNil(t, image.CreatedAt) - require.Equal(t, testNow, *image.CreatedAt) + require.Equal(t, testImage, *img) }, }, { - name: "without platform constraint -- error getting image by digest", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, + name: "without platform constraint, error getting image by digest", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", }, - }, + }}, }, }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { return nil, errors.New("something went wrong") }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "error getting image from manifest") + require.ErrorContains(t, err, "error getting image with digest") + require.ErrorContains(t, err, "something went wrong") }, }, { - name: "without platform constraint -- no image found", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, + name: "without platform constraint, no image found", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", }, - }, + }}, }, }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { return nil, nil }, }, assertions: func(t *testing.T, _ *Image, err error) { - require.ErrorContains(t, err, "found no image for manifest") + require.ErrorContains(t, err, "found no image with digest") }, }, { - name: "without platform constraint -- success", - collection: &manifestlist.DeserializedManifestList{ - ManifestList: manifestlist.ManifestList{ - Manifests: []manifestlist.ManifestDescriptor{ - { - Platform: manifestlist.PlatformSpec{ - OS: "linux", - Architecture: "amd64", - }, + name: "without platform constraint, success", + idx: &mockImageIndex{ + indexManifest: &v1.IndexManifest{ + Manifests: []v1.Descriptor{{ + Platform: &v1.Platform{ + OS: "linux", + Architecture: "amd64", }, - }, + }}, }, }, client: &repositoryClient{ getImageByDigestFn: func( - context.Context, - digest.Digest, - *platformConstraint, + context.Context, string, *platformConstraint, ) (*Image, error) { - return &Image{ - CreatedAt: &testNow, - }, nil + return &testImage, nil }, }, - assertions: func(t *testing.T, image *Image, err error) { + assertions: func(t *testing.T, img *Image, err error) { require.NoError(t, err) - require.NotNil(t, image) - require.NotNil(t, image.CreatedAt) - require.Equal(t, testNow, *image.CreatedAt) + require.Equal(t, testImage, *img) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - image, err := testCase.client.extractImageFromCollection( + image, err := testCase.client.getImageFromV1ImageIndex( context.Background(), - testCase.collection, + testDigest, + testCase.idx, testCase.platform, ) testCase.assertions(t, image, err) }) } } + +func TestGetImageFromV1Image(t *testing.T) { + const testDigest = "fake-digest" + + testCases := []struct { + name string + img v1.Image + platform *platformConstraint + client *repositoryClient + assertions func(*testing.T, *Image, error) + }{ + { + name: "no platform constraint", + img: &mockImage{ + configFile: &v1.ConfigFile{}, + }, + client: &repositoryClient{}, + assertions: func(t *testing.T, img *Image, err error) { + require.NoError(t, err) + require.NotNil(t, img) + require.NotEmpty(t, img.Digest) + require.NotNil(t, img.CreatedAt) + }, + }, + { + name: "does not match platform constraint", + img: &mockImage{ + configFile: &v1.ConfigFile{ + OS: "linux", + Architecture: "amd64", + }, + }, + platform: &platformConstraint{ + os: "linux", + arch: "arm64", + }, + client: &repositoryClient{}, + assertions: func(t *testing.T, img *Image, err error) { + require.NoError(t, err) + require.Nil(t, img) + }, + }, + { + name: "matches platform constraint", + img: &mockImage{ + configFile: &v1.ConfigFile{ + OS: "linux", + Architecture: "amd64", + }, + }, + platform: &platformConstraint{ + os: "linux", + arch: "amd64", + }, + client: &repositoryClient{}, + assertions: func(t *testing.T, img *Image, err error) { + require.NoError(t, err) + require.NotNil(t, img) + require.NotEmpty(t, img.Digest) + require.NotNil(t, img.CreatedAt) + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + image, err := testCase.client.getImageFromV1Image( + testDigest, + testCase.img, + testCase.platform, + ) + testCase.assertions(t, image, err) + }) + } +} + +type mockImageIndex struct { + indexManifest *v1.IndexManifest +} + +func (m *mockImageIndex) MediaType() (types.MediaType, error) { + return "", errNotImplemented +} + +func (m *mockImageIndex) Digest() (v1.Hash, error) { + return v1.Hash{}, errNotImplemented +} + +func (m *mockImageIndex) Size() (int64, error) { + return 0, errNotImplemented +} + +func (m *mockImageIndex) IndexManifest() (*v1.IndexManifest, error) { + return m.indexManifest, nil +} + +func (m *mockImageIndex) RawManifest() ([]byte, error) { + return nil, errNotImplemented +} + +func (m *mockImageIndex) Image(v1.Hash) (v1.Image, error) { + return nil, errNotImplemented +} + +func (m *mockImageIndex) ImageIndex(v1.Hash) (v1.ImageIndex, error) { + return nil, errNotImplemented +} + +type mockImage struct { + configFile *v1.ConfigFile +} + +func (m *mockImage) Layers() ([]v1.Layer, error) { + return nil, errNotImplemented +} + +func (m *mockImage) MediaType() (types.MediaType, error) { + return "", errNotImplemented +} + +func (m *mockImage) Size() (int64, error) { + return 0, errNotImplemented +} + +func (m *mockImage) ConfigName() (v1.Hash, error) { + return v1.Hash{}, errNotImplemented +} + +func (m *mockImage) ConfigFile() (*v1.ConfigFile, error) { + return m.configFile, nil +} + +func (m *mockImage) RawConfigFile() ([]byte, error) { + return nil, errNotImplemented +} + +func (m *mockImage) Digest() (v1.Hash, error) { + return v1.Hash{}, errNotImplemented +} + +func (m *mockImage) Manifest() (*v1.Manifest, error) { + return nil, errNotImplemented +} + +func (m *mockImage) RawManifest() ([]byte, error) { + return nil, errNotImplemented +} + +func (m *mockImage) LayerByDigest(v1.Hash) (v1.Layer, error) { + return nil, errNotImplemented +} + +func (m *mockImage) LayerByDiffID(v1.Hash) (v1.Layer, error) { + return nil, errNotImplemented +} + +var errNotImplemented = errors.New("not implemented") diff --git a/internal/image/selector_docker_hub_test.go b/internal/image/selector_docker_hub_test.go index 807acb52f..3e957a894 100644 --- a/internal/image/selector_docker_hub_test.go +++ b/internal/image/selector_docker_hub_test.go @@ -20,7 +20,7 @@ import ( // // To use your Docker credentials, set env vars: // - DOCKER_HUB_USERNAME -// - DOCKER_HUB_USERNAME (personal access token) +// - DOCKER_HUB_PASSWORD (personal access token) func TestSelectImageDockerHub(t *testing.T) { const debianRepo = "debian" @@ -36,8 +36,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: "fake-constraint", - Creds: getDockerHubCreds(), + Constraint: "fake-constraint", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -52,8 +53,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: "bookworm", - Creds: getDockerHubCreds(), + Constraint: "bookworm", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -74,9 +76,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: "bookworm", - Platform: "linux/made-up-arch", - Creds: getDockerHubCreds(), + Constraint: "bookworm", + Platform: "linux/made-up-arch", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -91,9 +94,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: "bookworm", - Platform: platform, - Creds: getDockerHubCreds(), + Constraint: "bookworm", + Platform: platform, + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -113,8 +117,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyLexical, &SelectorOptions{ - AllowRegex: "nothing-matches-this", - Creds: getDockerHubCreds(), + AllowRegex: "nothing-matches-this", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -129,7 +134,8 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyLexical, &SelectorOptions{ - Creds: getDockerHubCreds(), + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -154,9 +160,10 @@ func TestSelectImageDockerHub(t *testing.T) { // digest, but jessie is ancient, so for now I am chalking it up to // something having to do with the evolution of the Docker Hub API over // time. - AllowRegex: "^jessie", - Platform: "linux/made-up-arch", - Creds: getDockerHubCreds(), + AllowRegex: "^jessie", + Platform: "linux/made-up-arch", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -171,9 +178,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyLexical, &SelectorOptions{ - AllowRegex: "^jessie", - Platform: platform, - Creds: getDockerHubCreds(), + AllowRegex: "^jessie", + Platform: platform, + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -193,8 +201,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: "nothing-matches-this", - Creds: getDockerHubCreds(), + AllowRegex: "nothing-matches-this", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -209,8 +218,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: `^bookworm-202310\d\d$`, - Creds: getDockerHubCreds(), + AllowRegex: `^bookworm-202310\d\d$`, + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -230,9 +240,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: `^bookworm-202310\d\d$`, - Platform: "linux/made-up-arch", - Creds: getDockerHubCreds(), + AllowRegex: `^bookworm-202310\d\d$`, + Platform: "linux/made-up-arch", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -247,9 +258,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: `^bookworm-202310\d\d$`, - Platform: platform, - Creds: getDockerHubCreds(), + AllowRegex: `^bookworm-202310\d\d$`, + Platform: platform, + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -269,8 +281,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategySemVer, &SelectorOptions{ - Constraint: "^99.0", - Creds: getDockerHubCreds(), + Constraint: "^99.0", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -285,8 +298,9 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategySemVer, &SelectorOptions{ - Constraint: "^12.0", - Creds: getDockerHubCreds(), + Constraint: "^12.0", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -310,9 +324,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategySemVer, &SelectorOptions{ - Constraint: "^12.0", - Platform: "linux/made-up-arch", - Creds: getDockerHubCreds(), + Constraint: "^12.0", + Platform: "linux/made-up-arch", + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -327,9 +342,10 @@ func TestSelectImageDockerHub(t *testing.T) { debianRepo, SelectionStrategySemVer, &SelectorOptions{ - Constraint: "^12.0", - Platform: platform, - Creds: getDockerHubCreds(), + Constraint: "^12.0", + Platform: platform, + Creds: getDockerHubCreds(), + DiscoveryLimit: 1, }, ) require.NoError(t, err) diff --git a/internal/image/selector_ghcr_test.go b/internal/image/selector_ghcr_test.go index 5ef7e885b..961e85878 100644 --- a/internal/image/selector_ghcr_test.go +++ b/internal/image/selector_ghcr_test.go @@ -32,7 +32,8 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: constraint, + Constraint: constraint, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -53,8 +54,9 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategyDigest, &SelectorOptions{ - Constraint: constraint, - Platform: platform, + Constraint: constraint, + Platform: platform, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -73,7 +75,9 @@ func TestSelectImageGHCR(t *testing.T) { s, err := NewSelector( kargoRepo, SelectionStrategyLexical, - nil, + &SelectorOptions{ + DiscoveryLimit: 1, + }, ) require.NoError(t, err) @@ -91,7 +95,8 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategyLexical, &SelectorOptions{ - Platform: platform, + Platform: platform, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -110,7 +115,8 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: `^v0.1.0-rc.2\d$`, + AllowRegex: `^v0.1.0-rc.2\d$`, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -130,8 +136,9 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategyNewestBuild, &SelectorOptions{ - AllowRegex: `^v0.1.0-rc.2\d$`, - Platform: platform, + AllowRegex: `^v0.1.0-rc.2\d$`, + Platform: platform, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -150,7 +157,9 @@ func TestSelectImageGHCR(t *testing.T) { s, err := NewSelector( kargoRepo, SelectionStrategySemVer, - nil, + &SelectorOptions{ + DiscoveryLimit: 1, + }, ) require.NoError(t, err) @@ -173,7 +182,8 @@ func TestSelectImageGHCR(t *testing.T) { kargoRepo, SelectionStrategySemVer, &SelectorOptions{ - Platform: platform, + Platform: platform, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -201,7 +211,8 @@ func TestSelectImageGHCR(t *testing.T) { "ghcr.io/akuity/kargo-test", SelectionStrategyDigest, &SelectorOptions{ - Constraint: tag, + Constraint: tag, + DiscoveryLimit: 1, }, ) require.NoError(t, err) @@ -223,7 +234,8 @@ func TestSelectImageGHCR(t *testing.T) { &SelectorOptions{ Constraint: "v0.1.0", // Nothing will match this - Platform: "linux/made-up-arch", + Platform: "linux/made-up-arch", + DiscoveryLimit: 1, }, ) require.NoError(t, err) diff --git a/internal/image/selector_test.go b/internal/image/selector_test.go index 6cb980acf..e3ce2f943 100644 --- a/internal/image/selector_test.go +++ b/internal/image/selector_test.go @@ -1,26 +1,13 @@ package image import ( - "net/http" "regexp" "testing" - "github.com/distribution/distribution/v3/registry/client/auth/challenge" "github.com/stretchr/testify/require" ) func TestNewSelector(t *testing.T) { - getChallengeManagerBackup := getChallengeManager - getChallengeManager = func( - string, - http.RoundTripper, - ) (challenge.Manager, error) { - return challenge.NewSimpleManager(), nil - } - defer func() { - getChallengeManager = getChallengeManagerBackup - }() - testCases := []struct { name string repoURL string diff --git a/internal/image/semver_selector.go b/internal/image/semver_selector.go index a7138f637..73f8a1aa0 100644 --- a/internal/image/semver_selector.go +++ b/internal/image/semver_selector.go @@ -57,7 +57,7 @@ func newSemVerSelector( func (s *semVerSelector) Select(ctx context.Context) ([]Image, error) { logger := logging.LoggerFromContext(ctx).WithFields(log.Fields{ "registry": s.repoClient.registry.name, - "image": s.repoClient.image, + "image": s.repoClient.repoURL, "selectionStrategy": SelectionStrategySemVer, "platformConstrained": s.platform != nil, "discoveryLimit": s.discoveryLimit, @@ -96,7 +96,7 @@ func (s *semVerSelector) Select(ctx context.Context) ([]Image, error) { logger.WithFields(log.Fields{ "tag": image.Tag, - "digest": image.Digest.String(), + "digest": image.Digest, }).Trace("discovered image") discoveredImages = append(discoveredImages, *image) } diff --git a/internal/image/semver_selector_test.go b/internal/image/semver_selector_test.go index 5e731000b..d8496197f 100644 --- a/internal/image/semver_selector_test.go +++ b/internal/image/semver_selector_test.go @@ -73,27 +73,27 @@ func TestNewSemVerSelector(t *testing.T) { func TestSortImagesBySemver(t *testing.T) { images := []Image{ - newImage("5.0.0", nil, ""), - newImage("0.0.1", nil, ""), - newImage("0.2.1", nil, ""), - newImage("0.1.1", nil, ""), - newImage("1.1.1", nil, ""), - newImage("7.0.6", nil, ""), - newImage("1.0.0", nil, ""), - newImage("1.0.2", nil, ""), + newImage("5.0.0", "", nil), + newImage("0.0.1", "", nil), + newImage("0.2.1", "", nil), + newImage("0.1.1", "", nil), + newImage("1.1.1", "", nil), + newImage("7.0.6", "", nil), + newImage("1.0.0", "", nil), + newImage("1.0.2", "", nil), } sortImagesBySemVer(images) require.Equal( t, []Image{ - newImage("7.0.6", nil, ""), - newImage("5.0.0", nil, ""), - newImage("1.1.1", nil, ""), - newImage("1.0.2", nil, ""), - newImage("1.0.0", nil, ""), - newImage("0.2.1", nil, ""), - newImage("0.1.1", nil, ""), - newImage("0.0.1", nil, ""), + newImage("7.0.6", "", nil), + newImage("5.0.0", "", nil), + newImage("1.1.1", "", nil), + newImage("1.0.2", "", nil), + newImage("1.0.0", "", nil), + newImage("0.2.1", "", nil), + newImage("0.1.1", "", nil), + newImage("0.0.1", "", nil), }, images, )