Files
package-updater/internal/master/registry/registry.go

115 lines
4.0 KiB
Go

// Package registry looks up the digest a tag points at in a container
// registry, using HEAD requests only.
package registry
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/shcizo/package-updater/internal/imageref"
)
// Client looks up tag digests with HEAD requests only. Docker Hub counts a
// GET of a manifest as a pull against the rate limit and a HEAD as nothing,
// so Client never issues a manifest GET.
type Client struct {
keychain authn.Keychain
userAgent string
log *slog.Logger
}
// New returns a Client that finds credentials the way the Docker CLI does:
// in $DOCKER_CONFIG/config.json, else ~/.docker/config.json. Registries
// without an entry are accessed anonymously. userAgent is added to the
// User-Agent header of every request. log receives the full error of a
// failed lookup, which Digest itself returns only in a sanitised form.
func New(userAgent string, log *slog.Logger) *Client {
return &Client{keychain: authn.DefaultKeychain, userAgent: userAgent, log: log}
}
// Digest returns the digest that "repo:tag" points at, as the registry
// reports it: for a multi-arch image that is the digest of the index, the
// same one `docker inspect` shows in RepoDigests. A digest suffix in ref is
// ignored. It sends HEAD /v2/<repo>/manifests/<tag> and nothing else to the
// manifest endpoint; it does not fall back to GET when the registry omits
// the digest header.
func (c *Client) Digest(ctx context.Context, ref string) (string, error) {
p := imageref.Parse(ref)
if p.Tag == "" {
return "", fmt.Errorf("image %q has no tag", ref)
}
r, err := name.ParseReference(p.Repo + ":" + p.Tag)
if err != nil {
return "", fmt.Errorf("parse %s: %w", ref, err)
}
// Credentials are resolved here, not by ggcr, so that a broken credential
// helper keeps its own message: that text comes from the local config.
auth, err := c.keychain.Resolve(r.Context())
if err != nil {
return "", fmt.Errorf("lookup %s: %w", r, err)
}
desc, err := remote.Head(r,
remote.WithContext(ctx),
remote.WithAuth(auth),
remote.WithUserAgent(c.userAgent))
if err != nil {
var terr *transport.Error
if errors.As(err, &terr) {
return "", fmt.Errorf("lookup %s: %s", r, describe(terr))
}
c.log.Warn("registry_lookup_failed", "ref", ref, "error", err)
return "", fmt.Errorf("lookup %s: %s", r, classify(err))
}
return desc.Digest.String(), nil
}
// classify returns a fixed text for an error that is not a registry answer.
// ggcr copies registry-controlled text (token responses, authentication
// challenges, redirect URLs, certificate names) into such errors, so none of
// it is passed on.
func classify(err error) string {
var netErr net.Error
var urlErr *url.Error
switch {
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
return "timeout"
case errors.As(err, &netErr), errors.As(err, &urlErr):
return "cannot reach registry"
}
return "lookup failed"
}
// errorCodePattern matches the registry error codes of the distribution
// spec ("MANIFEST_UNKNOWN"). Anything else is dropped from error text.
var errorCodePattern = regexp.MustCompile(`^[A-Z_]{1,40}$`)
// describe returns the HTTP status and the structured error codes of a
// registry error, without the messages, details or raw body that ggcr copies
// into its error text. The registry host comes from an image name, so
// whatever it answers must not end up in the UI.
func describe(e *transport.Error) string {
msg := fmt.Sprintf("registry answered %d %s", e.StatusCode, http.StatusText(e.StatusCode))
var codes []string
for _, d := range e.Errors {
if errorCodePattern.MatchString(string(d.Code)) {
codes = append(codes, string(d.Code))
}
}
if len(codes) > 0 {
msg += " (" + strings.Join(codes, ", ") + ")"
}
return msg
}