147 lines
5.3 KiB
Go
147 lines
5.3 KiB
Go
// Package registry looks up the digest a tag points at in a container
|
|
// registry and lists the tags of a repository. Manifests are only ever
|
|
// requested with HEAD.
|
|
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 and lists tags. 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. The only GETs it sends are the /v2/ ping and
|
|
// tags/list, which Docker Hub does not count as a pull.
|
|
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 {
|
|
return "", c.lookupError("lookup", r.String(), ref, err)
|
|
}
|
|
return desc.Digest.String(), nil
|
|
}
|
|
|
|
// Tags returns every tag of repo ("host/path", or a Docker Hub name), in the
|
|
// order the registry lists them. It sends GET /v2/<repo>/tags/list, following
|
|
// the registry's pagination links. Errors are sanitised like Digest's.
|
|
func (c *Client) Tags(ctx context.Context, repo string) ([]string, error) {
|
|
r, err := name.NewRepository(repo)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", repo, err)
|
|
}
|
|
auth, err := c.keychain.Resolve(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list %s: %w", r, err)
|
|
}
|
|
tags, err := remote.List(r,
|
|
remote.WithContext(ctx),
|
|
remote.WithAuth(auth),
|
|
remote.WithUserAgent(c.userAgent))
|
|
if err != nil {
|
|
return nil, c.lookupError("list", r.String(), repo, err)
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// lookupError turns a failed registry request into an error that is safe to
|
|
// show: a registry answer is described by its status and codes, anything
|
|
// else by a fixed text. The full error goes to the log. verb and target
|
|
// start the message; ref is what the log line names.
|
|
func (c *Client) lookupError(verb, target, ref string, err error) error {
|
|
var terr *transport.Error
|
|
if errors.As(err, &terr) {
|
|
return fmt.Errorf("%s %s: %s", verb, target, describe(terr))
|
|
}
|
|
c.log.Warn("registry_request_failed", "ref", ref, "error", err)
|
|
return fmt.Errorf("%s %s: %s", verb, target, classify(err))
|
|
}
|
|
|
|
// 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
|
|
}
|