summaryrefslogtreecommitdiff
path: root/registry-scanner/pkg/registry/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'registry-scanner/pkg/registry/client.go')
-rw-r--r--registry-scanner/pkg/registry/client.go447
1 files changed, 447 insertions, 0 deletions
diff --git a/registry-scanner/pkg/registry/client.go b/registry-scanner/pkg/registry/client.go
new file mode 100644
index 0000000..7a26946
--- /dev/null
+++ b/registry-scanner/pkg/registry/client.go
@@ -0,0 +1,447 @@
+package registry
+
+import (
+ "context"
+ "crypto/sha256"
+ "fmt"
+ "time"
+
+ "github.com/argoproj/pkg/json"
+
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/options"
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/tag"
+
+ "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/opencontainers/go-digest"
+ ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+ "go.uber.org/ratelimit"
+
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+// TODO: Check image's architecture and OS
+
+// knownMediaTypes is the list of media types we can process
+var knownMediaTypes = []string{
+ ocischema.SchemaVersion.MediaType,
+ schema1.MediaTypeSignedManifest, //nolint:staticcheck
+ schema2.SchemaVersion.MediaType,
+ manifestlist.SchemaVersion.MediaType,
+ ociv1.MediaTypeImageIndex,
+}
+
+// RegistryClient defines the methods we need for querying container registries
+type RegistryClient interface {
+ NewRepository(nameInRepository string) error
+ Tags() ([]string, error)
+ ManifestForTag(tagStr string) (distribution.Manifest, error)
+ ManifestForDigest(dgst digest.Digest) (distribution.Manifest, error)
+ TagMetadata(manifest distribution.Manifest, opts *options.ManifestOptions) (*tag.TagInfo, error)
+}
+
+type NewRegistryClient func(*RegistryEndpoint, string, string) (RegistryClient, error)
+
+// Helper type for registry clients
+type registryClient struct {
+ regClient distribution.Repository
+ endpoint *RegistryEndpoint
+ creds credentials
+}
+
+// credentials is an implementation of distribution/V3/session struct
+// to manage registry credentials and token
+type credentials struct {
+ username string
+ password string
+ refreshTokens map[string]string
+}
+
+func (c credentials) Basic(url *url.URL) (string, string) {
+ return c.username, c.password
+}
+
+func (c credentials) RefreshToken(url *url.URL, service string) string {
+ return c.refreshTokens[service]
+}
+
+func (c credentials) SetRefreshToken(realm *url.URL, service, token string) {
+ if c.refreshTokens != nil {
+ c.refreshTokens[service] = token
+ }
+}
+
+// rateLimitTransport encapsulates our custom HTTP round tripper with rate
+// limiter from the endpoint.
+type rateLimitTransport struct {
+ limiter ratelimit.Limiter
+ transport http.RoundTripper
+ endpoint *RegistryEndpoint
+}
+
+// RoundTrip is a custom RoundTrip method with rate-limiter
+func (rlt *rateLimitTransport) RoundTrip(r *http.Request) (*http.Response, error) {
+ rlt.limiter.Take()
+ log.Tracef("Performing HTTP %s %s", r.Method, r.URL)
+ resp, err := rlt.transport.RoundTrip(r)
+ return resp, err
+}
+
+// NewRepository is a wrapper for creating a registry client that is possibly
+// rate-limited by using a custom HTTP round tripper method.
+func (clt *registryClient) NewRepository(nameInRepository string) error {
+ urlToCall := strings.TrimSuffix(clt.endpoint.RegistryAPI, "/")
+ challengeManager1 := challenge.NewSimpleManager()
+ _, err := ping(challengeManager1, clt.endpoint, "")
+ if err != nil {
+ return err
+ }
+
+ authTransport := transport.NewTransport(
+ clt.endpoint.GetTransport(), auth.NewAuthorizer(
+ challengeManager1,
+ auth.NewTokenHandler(clt.endpoint.GetTransport(), clt.creds, nameInRepository, "pull"),
+ auth.NewBasicHandler(clt.creds)))
+
+ rlt := &rateLimitTransport{
+ limiter: clt.endpoint.Limiter,
+ transport: authTransport,
+ endpoint: clt.endpoint,
+ }
+
+ named, err := reference.WithName(nameInRepository)
+ if err != nil {
+ return err
+ }
+ clt.regClient, err = client.NewRepository(named, urlToCall, rlt)
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// NewClient returns a new RegistryClient for the given endpoint information
+func NewClient(endpoint *RegistryEndpoint, username, password string) (RegistryClient, error) {
+ if username == "" && endpoint.Username != "" {
+ username = endpoint.Username
+ }
+ if password == "" && endpoint.Password != "" {
+ password = endpoint.Password
+ }
+ creds := credentials{
+ username: username,
+ password: password,
+ }
+ return &registryClient{
+ creds: creds,
+ endpoint: endpoint,
+ }, nil
+}
+
+// Tags returns a list of tags for given name in repository
+func (clt *registryClient) Tags() ([]string, error) {
+ tagService := clt.regClient.Tags(context.Background())
+ tTags, err := tagService.All(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ return tTags, nil
+}
+
+// Manifest returns a Manifest for a given tag in repository
+func (clt *registryClient) ManifestForTag(tagStr string) (distribution.Manifest, error) {
+ manService, err := clt.regClient.Manifests(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ manifest, err := manService.Get(
+ context.Background(),
+ digest.FromString(tagStr),
+ distribution.WithTag(tagStr), distribution.WithManifestMediaTypes(knownMediaTypes))
+ if err != nil {
+ return nil, err
+ }
+ return manifest, nil
+}
+
+// ManifestForDigest returns a Manifest for a given digest in repository
+func (clt *registryClient) ManifestForDigest(dgst digest.Digest) (distribution.Manifest, error) {
+ manService, err := clt.regClient.Manifests(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ manifest, err := manService.Get(
+ context.Background(),
+ dgst,
+ distribution.WithManifestMediaTypes(knownMediaTypes))
+ if err != nil {
+ return nil, err
+ }
+ return manifest, nil
+}
+
+// TagMetadata retrieves metadata for a given manifest of given repository
+func (client *registryClient) TagMetadata(manifest distribution.Manifest, opts *options.ManifestOptions) (*tag.TagInfo, error) {
+ ti := &tag.TagInfo{}
+ logCtx := opts.Logger()
+ var info struct {
+ Arch string `json:"architecture"`
+ Created string `json:"created"`
+ OS string `json:"os"`
+ Variant string `json:"variant"`
+ }
+
+ // We support the following types of manifests as returned by the registry:
+ //
+ // V1 (legacy, might go away), V2 and OCI
+ //
+ // Also ManifestLists (e.g. on multi-arch images) are supported.
+ //
+ switch deserialized := manifest.(type) {
+
+ case *schema1.SignedManifest: //nolint:staticcheck
+ var man schema1.Manifest = deserialized.Manifest //nolint:staticcheck
+ if len(man.History) == 0 {
+ return nil, fmt.Errorf("no history information found in schema V1")
+ }
+
+ _, mBytes, err := manifest.Payload()
+ if err != nil {
+ return nil, err
+ }
+ ti.Digest = sha256.Sum256(mBytes)
+
+ logCtx.Tracef("v1 SHA digest is %s", ti.EncodedDigest())
+ if err := json.Unmarshal([]byte(man.History[0].V1Compatibility), &info); err != nil {
+ return nil, err
+ }
+ if !opts.WantsPlatform(info.OS, info.Arch, "") {
+ logCtx.Debugf("ignoring v1 manifest %v. Manifest platform: %s, requested: %s",
+ ti.EncodedDigest(), options.PlatformKey(info.OS, info.Arch, info.Variant), strings.Join(opts.Platforms(), ","))
+ return nil, nil
+ }
+ if createdAt, err := time.Parse(time.RFC3339Nano, info.Created); err != nil {
+ return nil, err
+ } else {
+ ti.CreatedAt = createdAt
+ }
+ return ti, nil
+
+ case *manifestlist.DeserializedManifestList:
+ var list manifestlist.DeserializedManifestList = *deserialized
+
+ // List must contain at least one image manifest
+ if len(list.Manifests) == 0 {
+ return nil, fmt.Errorf("empty manifestlist not supported")
+ }
+
+ // We use the SHA from the manifest list to let the container engine
+ // decide which image to pull, in case of multi-arch clusters.
+ _, mBytes, err := list.Payload()
+ if err != nil {
+ return nil, fmt.Errorf("could not retrieve manifestlist payload: %v", err)
+ }
+ ti.Digest = sha256.Sum256(mBytes)
+
+ logCtx.Tracef("SHA256 of manifest parent is %v", ti.EncodedDigest())
+
+ return TagInfoFromReferences(client, opts, logCtx, ti, list.References())
+
+ case *ocischema.DeserializedImageIndex:
+ var index ocischema.DeserializedImageIndex = *deserialized
+
+ // Index must contain at least one image manifest
+ if len(index.Manifests) == 0 {
+ return nil, fmt.Errorf("empty index not supported")
+ }
+
+ // We use the SHA from the manifest index to let the container engine
+ // decide which image to pull, in case of multi-arch clusters.
+ _, mBytes, err := index.Payload()
+ if err != nil {
+ return nil, fmt.Errorf("could not retrieve index payload: %v", err)
+ }
+ ti.Digest = sha256.Sum256(mBytes)
+
+ logCtx.Tracef("SHA256 of manifest parent is %v", ti.EncodedDigest())
+
+ return TagInfoFromReferences(client, opts, logCtx, ti, index.References())
+
+ case *schema2.DeserializedManifest:
+ var man schema2.Manifest = deserialized.Manifest
+
+ logCtx.Tracef("Manifest digest is %v", man.Config.Digest.Encoded())
+
+ _, mBytes, err := manifest.Payload()
+ if err != nil {
+ return nil, err
+ }
+ ti.Digest = sha256.Sum256(mBytes)
+ logCtx.Tracef("v2 SHA digest is %s", ti.EncodedDigest())
+
+ // The data we require from a V2 manifest is in a blob that we need to
+ // fetch from the registry.
+ blobReader, err := client.regClient.Blobs(context.Background()).Get(context.Background(), man.Config.Digest)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := json.Unmarshal(blobReader, &info); err != nil {
+ return nil, err
+ }
+
+ if !opts.WantsPlatform(info.OS, info.Arch, info.Variant) {
+ logCtx.Debugf("ignoring v2 manifest %v. Manifest platform: %s, requested: %s",
+ ti.EncodedDigest(), options.PlatformKey(info.OS, info.Arch, info.Variant), strings.Join(opts.Platforms(), ","))
+ return nil, nil
+ }
+
+ if ti.CreatedAt, err = time.Parse(time.RFC3339Nano, info.Created); err != nil {
+ return nil, err
+ }
+
+ return ti, nil
+ case *ocischema.DeserializedManifest:
+ var man ocischema.Manifest = deserialized.Manifest
+
+ _, mBytes, err := manifest.Payload()
+ if err != nil {
+ return nil, err
+ }
+ ti.Digest = sha256.Sum256(mBytes)
+ logCtx.Tracef("OCI SHA digest is %s", ti.EncodedDigest())
+
+ // The data we require from a V2 manifest is in a blob that we need to
+ // fetch from the registry.
+ blobReader, err := client.regClient.Blobs(context.Background()).Get(context.Background(), man.Config.Digest)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := json.Unmarshal(blobReader, &info); err != nil {
+ return nil, err
+ }
+
+ if !opts.WantsPlatform(info.OS, info.Arch, info.Variant) {
+ logCtx.Debugf("ignoring OCI manifest %v. Manifest platform: %s, requested: %s",
+ ti.EncodedDigest(), options.PlatformKey(info.OS, info.Arch, info.Variant), strings.Join(opts.Platforms(), ","))
+ return nil, nil
+ }
+
+ if ti.CreatedAt, err = time.Parse(time.RFC3339Nano, info.Created); err != nil {
+ return nil, err
+ }
+
+ return ti, nil
+ default:
+ return nil, fmt.Errorf("invalid manifest type %T", manifest)
+ }
+}
+
+// TagInfoFromReferences is a helper method to retrieve metadata for a given
+// list of references. It will return the most recent pushed manifest from the
+// list of references.
+func TagInfoFromReferences(client *registryClient, opts *options.ManifestOptions, logCtx *log.LogContext, ti *tag.TagInfo, references []distribution.Descriptor) (*tag.TagInfo, error) {
+ var ml []distribution.Descriptor
+ platforms := []string{}
+
+ for _, ref := range references {
+ var refOS, refArch, refVariant string
+ if ref.Platform != nil {
+ refOS = ref.Platform.OS
+ refArch = ref.Platform.Architecture
+ refVariant = ref.Platform.Variant
+ }
+ platform1 := options.PlatformKey(refOS, refArch, refVariant)
+ platforms = append(platforms, platform1)
+ logCtx.Tracef("Found %s", platform1)
+ if !opts.WantsPlatform(refOS, refArch, refVariant) {
+ logCtx.Tracef("Ignoring referenced manifest %v because platform %s does not match any of: %s",
+ ref.Digest,
+ platform1,
+ strings.Join(opts.Platforms(), ","))
+ continue
+ }
+ ml = append(ml, ref)
+ }
+
+ // We need at least one reference that matches requested platforms
+ if len(ml) == 0 {
+ logCtx.Debugf("Manifest list did not contain any usable reference. Platforms requested: (%s), platforms included: (%s)",
+ strings.Join(opts.Platforms(), ","), strings.Join(platforms, ","))
+ return nil, nil
+ }
+
+ // For some strategies, we do not need to fetch metadata for further
+ // processing.
+ if !opts.WantsMetadata() {
+ return ti, nil
+ }
+
+ // Loop through all referenced manifests to get their metadata. We only
+ // consider manifests for platforms we are interested in.
+ for _, ref := range ml {
+ logCtx.Tracef("Inspecting metadata of reference: %v", ref.Digest)
+
+ man, err := client.ManifestForDigest(ref.Digest)
+ if err != nil {
+ return nil, fmt.Errorf("could not fetch manifest %v: %v", ref.Digest, err)
+ }
+
+ cti, err := client.TagMetadata(man, opts)
+ if err != nil {
+ return nil, fmt.Errorf("could not fetch metadata for manifest %v: %v", ref.Digest, err)
+ }
+
+ // We save the timestamp of the most recent pushed manifest for any
+ // given reference, if the metadata for the tag was correctly
+ // retrieved. This is important for the latest update strategy to
+ // be able to handle multi-arch images. The latest strategy will
+ // consider the most recent reference from an image index.
+ if cti != nil {
+ if cti.CreatedAt.After(ti.CreatedAt) {
+ ti.CreatedAt = cti.CreatedAt
+ }
+ } else {
+ logCtx.Warnf("returned metadata for manifest %v is nil, this should not happen.", ref.Digest)
+ continue
+ }
+ }
+
+ return ti, nil
+}
+
+// Implementation of ping method to initialize the challenge list
+// Without this, tokenHandler and AuthorizationHandler won't work
+func ping(manager challenge.Manager, endpoint *RegistryEndpoint, versionHeader string) ([]auth.APIVersion, error) {
+ httpc := &http.Client{Transport: endpoint.GetTransport()}
+ url := endpoint.RegistryAPI + "/v2/"
+ resp, err := httpc.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ // Let's consider only HTTP 200 and 401 valid responses for the initial request
+ if resp.StatusCode != 200 && resp.StatusCode != 401 {
+ return nil, fmt.Errorf("endpoint %s does not seem to be a valid v2 Docker Registry API (received HTTP code %d for GET %s)", endpoint.RegistryAPI, resp.StatusCode, url)
+ }
+
+ if err := manager.AddResponse(resp); err != nil {
+ return nil, err
+ }
+
+ return auth.APIVersions(resp, versionHeader), err
+}