feat: master/agent phase 4 — registry polling and version history (#9) (#23)
Docker Build / docker (push) Successful in 47s
Docker Build / docker (push) Successful in 47s
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
ggcrregistry "github.com/google/go-containerregistry/pkg/registry"
|
||||
"github.com/google/go-containerregistry/pkg/v1/random"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var discardLog = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
func newClient() *Client { return New("test", discardLog) }
|
||||
|
||||
// request is one request the test registry received.
|
||||
type request struct{ method, path string }
|
||||
|
||||
// testRegistry is an in-memory registry that records every request.
|
||||
type testRegistry struct {
|
||||
srv *httptest.Server
|
||||
host string // "127.0.0.1:port", the registry part of a ref
|
||||
|
||||
mu sync.Mutex
|
||||
requests []request
|
||||
}
|
||||
|
||||
// newTestRegistry starts a registry. When user is set every request needs
|
||||
// those basic-auth credentials.
|
||||
func newTestRegistry(t *testing.T, user, pass string) *testRegistry {
|
||||
t.Helper()
|
||||
backend := ggcrregistry.New(ggcrregistry.Logger(log.New(io.Discard, "", 0)))
|
||||
tr := &testRegistry{}
|
||||
tr.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tr.mu.Lock()
|
||||
tr.requests = append(tr.requests, request{r.Method, r.URL.Path})
|
||||
tr.mu.Unlock()
|
||||
if user != "" {
|
||||
if u, p, ok := r.BasicAuth(); !ok || u != user || p != pass {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
backend.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(tr.srv.Close)
|
||||
tr.host = strings.TrimPrefix(tr.srv.URL, "http://")
|
||||
return tr
|
||||
}
|
||||
|
||||
// pushIndex stores a random multi-arch index as repo:tag and returns its
|
||||
// digest. user and pass are used when the registry requires them.
|
||||
func (tr *testRegistry) pushIndex(t *testing.T, repo, tag string, opts ...remote.Option) string {
|
||||
t.Helper()
|
||||
idx, err := random.Index(64, 1, 2)
|
||||
require.NoError(t, err)
|
||||
ref, err := name.ParseReference(tr.host + "/" + repo + ":" + tag)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, remote.WriteIndex(ref, idx, opts...))
|
||||
d, err := idx.Digest()
|
||||
require.NoError(t, err)
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// manifestRequests returns the requests that touched a manifest.
|
||||
func (tr *testRegistry) manifestRequests() []request {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
var out []request
|
||||
for _, r := range tr.requests {
|
||||
if strings.Contains(r.path, "/manifests/") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reset forgets the requests received so far.
|
||||
func (tr *testRegistry) reset() {
|
||||
tr.mu.Lock()
|
||||
defer tr.mu.Unlock()
|
||||
tr.requests = nil
|
||||
}
|
||||
|
||||
// dockerConfig points DOCKER_CONFIG at a fresh directory holding config.json
|
||||
// with the given content; "" leaves the directory empty.
|
||||
func dockerConfig(t *testing.T, configJSON string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if configJSON != "" {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o600))
|
||||
}
|
||||
t.Setenv("DOCKER_CONFIG", dir)
|
||||
}
|
||||
|
||||
func TestDigestOfIndexIsIndexDigest(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
reg := newTestRegistry(t, "", "")
|
||||
want := reg.pushIndex(t, "team/app", "1.2")
|
||||
|
||||
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestDigestIgnoresDigestSuffix(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
reg := newTestRegistry(t, "", "")
|
||||
want := reg.pushIndex(t, "team/app", "1.2")
|
||||
|
||||
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2@sha256:"+strings.Repeat("0", 64))
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestDigestUsesHeadOnly(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
reg := newTestRegistry(t, "", "")
|
||||
reg.pushIndex(t, "team/app", "1.2")
|
||||
reg.reset()
|
||||
|
||||
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []request{{http.MethodHead, "/v2/team/app/manifests/1.2"}}, reg.manifestRequests())
|
||||
for _, r := range reg.requests {
|
||||
if !strings.Contains(r.path, "/manifests/") {
|
||||
assert.Equal(t, request{http.MethodGet, "/v2/"}, r, "the only other request is the API ping")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestSendsUserAgent(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
var ua string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ua = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, _ = New("package-updater-test/1", discardLog).Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
||||
|
||||
assert.Contains(t, ua, "package-updater-test/1")
|
||||
}
|
||||
|
||||
func TestCredentialsFromDockerConfig(t *testing.T) {
|
||||
reg := newTestRegistry(t, "bot", "s3cret")
|
||||
want := reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
||||
auth := base64.StdEncoding.EncodeToString([]byte("bot:s3cret"))
|
||||
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
||||
|
||||
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestWrongCredentialsFail(t *testing.T) {
|
||||
reg := newTestRegistry(t, "bot", "s3cret")
|
||||
reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
||||
auth := base64.StdEncoding.EncodeToString([]byte("bot:wrong"))
|
||||
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
||||
|
||||
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "wrong", "credentials do not leak into errors")
|
||||
assert.NotContains(t, err.Error(), auth)
|
||||
}
|
||||
|
||||
func TestAnonymousWithoutConfig(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
open := newTestRegistry(t, "", "")
|
||||
want := open.pushIndex(t, "team/app", "1.2")
|
||||
protected := newTestRegistry(t, "bot", "s3cret")
|
||||
|
||||
got, err := newClient().Digest(context.Background(), open.host+"/team/app:1.2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got, "anonymous access works without a config file")
|
||||
|
||||
_, err = newClient().Digest(context.Background(), protected.host+"/team/app:1.2")
|
||||
assert.Error(t, err, "a registry that needs credentials fails without them")
|
||||
}
|
||||
|
||||
func TestUnknownTagIsAnError(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
reg := newTestRegistry(t, "", "")
|
||||
reg.pushIndex(t, "team/app", "1.2")
|
||||
|
||||
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:nope")
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRefWithoutTagIsAnError(t *testing.T) {
|
||||
_, err := newClient().Digest(context.Background(), "team/app@sha256:"+strings.Repeat("a", 64))
|
||||
|
||||
assert.ErrorContains(t, err, "has no tag")
|
||||
}
|
||||
|
||||
// A config.json whose credsStore names a helper that is not installed makes
|
||||
// the lookup fail for every registry, including ones that would work
|
||||
// anonymously. The master container has no credential helpers, so only
|
||||
// inline "auths" entries are supported there.
|
||||
func TestCredsStoreWithoutHelperFailsTheLookup(t *testing.T) {
|
||||
reg := newTestRegistry(t, "", "")
|
||||
reg.pushIndex(t, "team/app", "1.2")
|
||||
dockerConfig(t, `{"credsStore":"no-such-helper-for-package-updater-tests"}`)
|
||||
|
||||
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
|
||||
|
||||
assert.ErrorContains(t, err, "error getting credentials")
|
||||
assert.ErrorContains(t, err, "docker-credential-no-such-helper-for-package-updater-tests")
|
||||
}
|
||||
|
||||
// A registry that leaves out Docker-Content-Digest must not make the client
|
||||
// retry with a manifest download, which Docker Hub counts as a pull.
|
||||
func TestMissingDigestHeaderDoesNotFallBackToDownload(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
var mu sync.Mutex
|
||||
var methods []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/manifests/") {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
methods = append(methods, r.Method)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
|
||||
w.Header().Set("Content-Length", "10")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
_, err := newClient().Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, []string{http.MethodHead}, methods)
|
||||
}
|
||||
|
||||
// ggcr copies up to 64 KiB of the registry's response into its errors. The
|
||||
// registry host comes from an image name, so that text must not reach the
|
||||
// stored poll error.
|
||||
func TestRegistryErrorBodyDoesNotLeak(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"raw body", "internal-secret-body", "registry answered 400 Bad Request"},
|
||||
{"structured errors", `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"internal-secret-body","detail":"internal-secret-body"},{"code":"internal-secret-body"}]}`,
|
||||
"registry answered 400 Bad Request (MANIFEST_UNKNOWN)"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HEAD answers carry no body, so the leak is in the GET /v2/ ping.
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
host := strings.TrimPrefix(srv.URL, "http://")
|
||||
|
||||
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "internal-secret-body")
|
||||
assert.Equal(t, "lookup "+host+"/a/b:1: "+tt.want, strings.Replace(err.Error(), "index.docker.io/", "", 1))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ggcr puts the token server's answer into its error when the answer has no
|
||||
// token, and the token server is named by the registry's challenge.
|
||||
func TestNonRegistryErrorsAreFixedText(t *testing.T) {
|
||||
dockerConfig(t, "")
|
||||
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"detail":"internal-secret-body"}`))
|
||||
}))
|
||||
t.Cleanup(tokenSrv.Close)
|
||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenSrv.URL+`/internal-secret-realm",service="internal-secret-service"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
t.Cleanup(reg.Close)
|
||||
host := strings.TrimPrefix(reg.URL, "http://")
|
||||
|
||||
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "internal-secret")
|
||||
assert.Equal(t, "lookup "+host+"/a/b:1: lookup failed", err.Error())
|
||||
}
|
||||
|
||||
func TestClassify(t *testing.T) {
|
||||
refused := &url.Error{Op: "Get", URL: "http://internal-secret-host/v2/", Err: errors.New("connection refused")}
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"deadline", fmt.Errorf("x: %w", context.DeadlineExceeded), "timeout"},
|
||||
{"canceled", context.Canceled, "timeout"},
|
||||
{"url error", refused, "cannot reach registry"},
|
||||
{"net error", &net.DNSError{Name: "internal-secret-host"}, "cannot reach registry"},
|
||||
{"deadline inside url error", &url.Error{Op: "Get", URL: "http://x", Err: context.DeadlineExceeded}, "timeout"},
|
||||
{"anything else", errors.New("internal-secret"), "lookup failed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, classify(tt.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user