diff --git a/registry/client.go b/registry/client.go index c279e31..d5c63da 100644 --- a/registry/client.go +++ b/registry/client.go @@ -51,13 +51,12 @@ func Client(host string, opts *ociclient.Options) (ociregistry.Interface, error) Transport: clientOptions.Transport, }) - connectHost := host - if host == dockerHubCanonical { - connectHost = dockerHubConnect - } else if host == "localhost" || strings.HasPrefix(host, "localhost:") { - // assume localhost means HTTP + // keep the finished transport (auth, rate limiting, User-Agent) around for any requests we have to make ourselves because "ociclient" does not know about them (see "serverSideCopyManifest") + transportCache.Store(host, clientOptions.Transport) + + connectHost, insecure := registryConnectHost(host) + if insecure { clientOptions.Insecure = true - // TODO some way for callers to specify that their "localhost" *does* require TLS (maybe only do this if `opts == nil`, but then users cannot supply *any* options and still get help setting Insecure for localhost 🤔 -- at least this is a more narrow use case than the opposite of not having a way to have non-localhost insecure registries) } hostOptions := clientOptions // make a copy, since "ociclient.New" mutates it (such that sharing the object afterwards probably isn't the best idea -- they'll have the same DebugID if so, which isn't ideal) @@ -133,6 +132,16 @@ func Client(host string, opts *ociclient.Options) (ociregistry.Interface, error) return f.(func() (ociregistry.Interface, error))() } +// returns the actual "host[:port]" requests for the given registry host should be sent to (see [dockerHubConnect]) and whether it should be assumed to be plain HTTP +func registryConnectHost(host string) (connectHost string, insecure bool) { + if host == dockerHubCanonical { + return dockerHubConnect, false + } + // assume localhost means HTTP + // TODO some way for callers to specify that their "localhost" *does* require TLS (maybe only do this if `opts == nil`, but then users cannot supply *any* options and still get help setting Insecure for localhost 🤔 -- at least this is a more narrow use case than the opposite of not having a way to have non-localhost insecure registries) + return host, host == "localhost" || strings.HasPrefix(host, "localhost:") +} + type dockerAuthConfigWrapper struct { ociauth.Config } @@ -172,4 +181,6 @@ var ( return dockerAuthConfigWrapper{config}, nil }) clientCache = sync.Map{} // "(normalized) host" => OnceValues() => ociregistry.Interface, error + + transportCache = sync.Map{} // "host exactly as passed to Client" => http.RoundTripper (populated as a side effect of the corresponding clientCache entry, so look up the same string you gave Client; neither normalizes, that is up to the caller, see Reference.Normalize) ) diff --git a/registry/push.go b/registry/push.go index e5309f7..4afca08 100644 --- a/registry/push.go +++ b/registry/push.go @@ -96,6 +96,10 @@ func EnsureManifest(ctx context.Context, ref Reference, manifest json.RawMessage for _, child := range manifestChildren.Manifests { childRef, childTargetRef := childToRefs(child) + if _, ok := serverSideCopyManifest(ctx, childRef, childTargetRef); ok { + // the registry copied the child (and all *its* children) for us + continue + } r, err := Lookup(ctx, childRef, nil) if err != nil { return desc, fmt.Errorf("%s: manifest lookup failed: %w", childRef, err) @@ -155,7 +159,11 @@ func EnsureManifest(ctx context.Context, ref Reference, manifest json.RawMessage func CopyManifest(ctx context.Context, srcRef, dstRef Reference, childRefs map[ociregistry.Digest]Reference) (ociregistry.Descriptor, error) { var desc ociregistry.Descriptor - // wouldn't it be nice if MountBlob for manifests was a thing? 🥺 + // wouldn't it be nice if MountBlob for manifests was a thing? 🥺 (on Docker Hub it kind of is, see "serverSideCopyManifest", and if that does not work out, we fall back to copying client side) + if copied, ok := serverSideCopyManifest(ctx, srcRef, dstRef); ok { + return copied, nil + } + r, err := Lookup(ctx, srcRef, nil) if err != nil { return desc, fmt.Errorf("%s: lookup failed: %w", srcRef, err) diff --git a/registry/server-side-copy.go b/registry/server-side-copy.go new file mode 100644 index 0000000..5bab98a --- /dev/null +++ b/registry/server-side-copy.go @@ -0,0 +1,174 @@ +package registry + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "cuelabs.dev/go/oci/ociregistry" + "cuelabs.dev/go/oci/ociregistry/ociauth" +) + +var ( + // "host" or "host|srcNamespace|dstNamespace" => true once we have good reason to believe a server-side manifest copy cannot work there (so we stop wasting a request on every manifest) + serverSideCopyDisabled = sync.Map{} +) + +func repoNamespace(repo string) string { + ns, _, _ := strings.Cut(repo, "/") + return ns +} + +// asks the registry to copy the manifest srcRef (by tag or by digest) into dstRef's repository *server side*, including every child manifest, config, and layer (so we don't have to do any per-blob or per-child requests ourselves) +// +// this is Docker Hub's "POST /v2//_docker/copy/image?from=&reference=[&tag=]" endpoint (basically a blob mount, but for a manifest), which Hub only enables for specific namespaces, so this is explicitly *best effort*: it returns true only if the registry confirmed it copied the right digest (and every child), and false for every other outcome (including "this registry has never heard of that endpoint") -- callers are expected to fall back to copying client side, which will also produce the authoritative error if something is genuinely wrong +// +// a failed attempt has no side effects (the registry stores nothing unless it was able to mount every child) +// +// the returned descriptor always has the right Digest on success, but MediaType and Size are only filled in if we happened to have to look them up anyhow (a source with a digest does not need a lookup, so we do not do one) +func serverSideCopyManifest(ctx context.Context, srcRef, dstRef Reference) (ociregistry.Descriptor, bool) { + if srcRef.Host != dstRef.Host || srcRef.Repository == dstRef.Repository { + return ociregistry.Descriptor{}, false + } + + // this is checked first so that a registry (or pair of namespaces) we already know does not work never costs us even a lookup + host := srcRef.Host + pairKey := host + "|" + repoNamespace(srcRef.Repository) + "|" + repoNamespace(dstRef.Repository) + if _, disabled := serverSideCopyDisabled.Load(host); disabled { + return ociregistry.Descriptor{}, false + } + if _, disabled := serverSideCopyDisabled.Load(pairKey); disabled { + return ociregistry.Descriptor{}, false + } + + // we always ask by digest, so if we were not given one, we need to find it (and a source that does not exist is a good reason to let the client side code produce the error) + desc := ociregistry.Descriptor{Digest: srcRef.Digest} + if srcRef.Digest == "" { + r, err := Lookup(ctx, srcRef, &LookupOptions{Head: true}) + if err != nil || r == nil { + return ociregistry.Descriptor{}, false + } + desc = r.Descriptor() + r.Close() + srcRef.Tag, srcRef.Digest = "", desc.Digest + } + if dstRef.Digest != "" && dstRef.Digest != srcRef.Digest { + // a digest mismatch is an error, but the client side code is better equipped to report it + return ociregistry.Descriptor{}, false + } + + // if the destination already has this exact manifest (at this tag, if we were given one), there's nothing to do (the endpoint is not a cheap no-op: it redoes the whole push, including checking every child), see also the same assumption in "EnsureManifest" that this means child objects exist as well + headRef := dstRef + if headRef.Tag != "" { + // a lookup with both prefers the digest, which would not tell us anything about the tag + headRef.Digest = "" + } else { + headRef.Digest = srcRef.Digest + } + if r, err := Lookup(ctx, headRef, &LookupOptions{Head: true}); err == nil && r != nil { + head := r.Descriptor() + r.Close() + if head.Digest == srcRef.Digest { + return head, true + } + } + + if !requestServerSideCopy(ctx, srcRef, dstRef) { + return ociregistry.Descriptor{}, false + } + return desc, true +} + +// the actual request for [serverSideCopyManifest] (srcRef must have a Digest, and every precondition has been checked), which also records anything we learn about what the registry does not support +func requestServerSideCopy(ctx context.Context, srcRef, dstRef Reference) bool { + host := srcRef.Host + pairKey := host + "|" + repoNamespace(srcRef.Repository) + "|" + repoNamespace(dstRef.Repository) + + // making sure "Client" has run for this host is what populates "transportCache" + if _, err := Client(host, nil); err != nil { + return false + } + rt, ok := transportCache.Load(host) + if !ok { + return false + } + + connectHost, insecure := registryConnectHost(host) + scheme := "https" + if insecure { + scheme = "http" + } + + query := url.Values{ + "from": {srcRef.Repository}, + "reference": {string(srcRef.Digest)}, // by digest so there's no implicit tag (and so we know exactly what we're asking for) + } + if dstRef.Tag != "" { + query.Set("tag", dstRef.Tag) + } + u := url.URL{ + Scheme: scheme, + Host: connectHost, + Path: "/v2/" + dstRef.Repository + "/_docker/copy/image", + RawQuery: query.Encode(), + } + + // the server requires push on the destination and pull on the source (the same as it does for a blob mount) + ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ + RequiredScope: ociauth.NewScope( + ociauth.ResourceScope{ResourceType: ociauth.TypeRepository, Resource: dstRef.Repository, Action: ociauth.ActionPush}, + ociauth.ResourceScope{ResourceType: ociauth.TypeRepository, Resource: dstRef.Repository, Action: ociauth.ActionPull}, + ociauth.ResourceScope{ResourceType: ociauth.TypeRepository, Resource: srcRef.Repository, Action: ociauth.ActionPull}, + ), + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), http.NoBody) + if err != nil { + return false + } + resp, err := (&http.Client{Transport: rt.(http.RoundTripper)}).Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + + switch { + case resp.StatusCode == http.StatusCreated: + // the digest we get back is the one the server actually copied, so it had better be the one we asked for + return resp.Header.Get("Docker-Content-Digest") == string(srcRef.Digest) + + case resp.StatusCode == http.StatusMethodNotAllowed, + resp.StatusCode == http.StatusNotFound && !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json"): + // a non-JSON 404 (like the plain text "404 page not found") means the endpoint does not exist at all, so this registry will never support this and we should stop asking it + serverSideCopyDisabled.Store(host, true) + + case resp.StatusCode == http.StatusBadRequest && responseHasErrorCode(body, "MANIFEST_BLOB_UNKNOWN"): + // the endpoint exists, but it did not mount anything (it falls back to a normal manifest push with no mounting if the feature is not enabled for either namespace), so stop asking about this specific pair of namespaces + serverSideCopyDisabled.Store(pairKey, true) + } + + // anything else (401, 403, 5xx, a JSON 404 for an unknown manifest, etc) might be specific to this one manifest and/or temporary, so we do not remember it, we just fall back + return false +} + +// whether body is an OCI distribution error response containing the given error code +func responseHasErrorCode(body []byte, code string) bool { + var errs struct { + Errors []struct { + Code string `json:"code"` + } `json:"errors"` + } + if err := json.Unmarshal(body, &errs); err != nil { + return false + } + for _, e := range errs.Errors { + if e.Code == code { + return true + } + } + return false +} diff --git a/registry/server-side-copy_test.go b/registry/server-side-copy_test.go new file mode 100644 index 0000000..c7b7ca4 --- /dev/null +++ b/registry/server-side-copy_test.go @@ -0,0 +1,688 @@ +package registry_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/docker-library/meta-scripts/registry" + + "cuelabs.dev/go/oci/ociregistry" + "cuelabs.dev/go/oci/ociregistry/ociauth" + "cuelabs.dev/go/oci/ociregistry/ocimem" + "cuelabs.dev/go/oci/ociregistry/ociserver" + godigest "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +// a fake registry that is an in-memory OCI registry plus an emulation of Docker Hub's "POST /v2//_docker/copy/image" endpoint (and all the interesting ways it can fail) + +type fakeHubMode int + +const ( + // Docker Hub with the feature enabled for the namespaces involved + hubWorks fakeHubMode = iota + // Docker Hub with the feature *not* enabled for the namespaces involved (nothing is mounted, so the manifest is rejected for missing blobs) + hubFlagOff + // any other registry (plain text 404) + hubNoRoute + // the endpoint exists, but is having a bad day + hubBroken + // the endpoint claims success, but did not copy what we asked for + hubWrongDigest + // any other registry (plain text 404) which also reports a manifest push with missing children as a 500 instead of a 4xx (which is what "ocimem" does, and the spec does not forbid) + hubMissingChildren500 +) + +type fakeHub struct { + t *testing.T + Host string + Registry *ocimem.Registry + + mode fakeHubMode + requireAuth bool + + mu sync.Mutex + requests []string // "METHOD /request/uri" (only requests that made it past authentication) + tokenScopes []string // the "scope" values of every token request (one entry per request, space separated) +} + +func newFakeHub(t *testing.T, mode fakeHubMode, requireAuth bool) *fakeHub { + t.Helper() + + h := &fakeHub{ + t: t, + Registry: ocimem.New(), + mode: mode, + requireAuth: requireAuth, + } + backend := ociserver.New(h.Registry, nil) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/token" { + h.mu.Lock() + h.tokenScopes = append(h.tokenScopes, strings.Join(r.URL.Query()["scope"], " ")) + h.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"token":"fake"}`) + return + } + if h.requireAuth && r.Header.Get("Authorization") != "Bearer fake" { + w.Header().Set("Www-Authenticate", fmt.Sprintf(`Bearer realm="http://%s/token",service="fake"`, h.Host)) + w.WriteHeader(http.StatusUnauthorized) + return + } + + h.mu.Lock() + h.requests = append(h.requests, r.Method+" "+r.URL.RequestURI()) + h.mu.Unlock() + + if repo, ok := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, "/v2/"), "/_docker/copy/image"); ok && strings.HasPrefix(r.URL.Path, "/v2/") { + h.copyImage(w, r, repo) + return + } + if r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/") && !h.manifestChildrenExist(w, r) { + return + } + backend.ServeHTTP(w, r) + })) + // registry.Client assumes "localhost" means plain HTTP, so we need to be listening on "localhost" (and every hub has a unique port, so a unique "host" for all the process-wide caches) + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + srv.Listener.Close() + srv.Listener = l + srv.Start() + t.Cleanup(srv.Close) + + _, port, err := net.SplitHostPort(l.Addr().String()) + if err != nil { + t.Fatal(err) + } + h.Host = "localhost:" + port + return h +} + +// like Docker Hub (and unlike "ocimem", which gives a 500, see [hubMissingChildren500]), reject a manifest push with 400 MANIFEST_BLOB_UNKNOWN if any of its children do not exist in the repository (this is what tells clients they need to copy children and try again) +func (h *fakeHub) manifestChildrenExist(w http.ResponseWriter, r *http.Request) bool { + status, code := http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN" + if h.mode == hubMissingChildren500 { + status, code = http.StatusInternalServerError, "UNKNOWN" + } + + repo, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/v2/"), "/manifests/") + body, err := io.ReadAll(r.Body) + r.Body.Close() + if err != nil { + h.t.Error(err) + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + children, _ := registry.ParseManifestChildren(body) // (not JSON, not our problem) + ctx := r.Context() + for _, child := range children.Manifests { + if _, err := h.Registry.ResolveManifest(ctx, repo, child.Digest); err != nil { + writeErrors(w, status, code) + return false + } + } + blobs := children.Layers + if children.Config != nil { + blobs = append(blobs, *children.Config) + } + for _, blob := range blobs { + if _, err := h.Registry.ResolveBlob(ctx, repo, blob.Digest); err != nil { + writeErrors(w, status, code) + return false + } + } + return true +} + +func (h *fakeHub) ref(repo, tag string, digest ociregistry.Digest) registry.Reference { + return registry.Reference{Host: h.Host, Repository: repo, Tag: tag, Digest: digest} +} + +func writeErrors(w http.ResponseWriter, status int, codes ...string) { + type e struct { + Code string `json:"code"` + Message string `json:"message"` + } + var errs struct { + Errors []e `json:"errors"` + } + for _, code := range codes { + errs.Errors = append(errs.Errors, e{Code: code, Message: strings.ToLower(code)}) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(errs) +} + +func (h *fakeHub) copyImage(w http.ResponseWriter, r *http.Request, dstRepo string) { + switch h.mode { + case hubNoRoute, hubMissingChildren500: + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + fmt.Fprintln(w, "404 page not found") + return + case hubFlagOff: + writeErrors(w, http.StatusBadRequest, "MANIFEST_BLOB_UNKNOWN", "MANIFEST_BLOB_UNKNOWN") + return + case hubBroken: + writeErrors(w, http.StatusInternalServerError, "UNKNOWN") + return + case hubWrongDigest: + w.Header().Set("Docker-Content-Digest", "sha256:"+strings.Repeat("0", 64)) + w.WriteHeader(http.StatusCreated) + return + } + + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + query := r.URL.Query() + desc, err := h.CopyTree(r.Context(), query.Get("from"), dstRepo, ociregistry.Digest(query.Get("reference")), query.Get("tag")) + if err != nil { + writeErrors(w, http.StatusNotFound, "MANIFEST_UNKNOWN") + return + } + w.Header().Set("Docker-Content-Digest", string(desc.Digest)) + w.WriteHeader(http.StatusCreated) +} + +// copies a manifest, every child manifest, and every blob between repositories (what the real endpoint does for us) +func (h *fakeHub) CopyTree(ctx context.Context, from, to string, digest ociregistry.Digest, tag string) (ociregistry.Descriptor, error) { + r, err := h.Registry.GetManifest(ctx, from, digest) + if err != nil { + return ociregistry.Descriptor{}, err + } + desc := r.Descriptor() + data, err := io.ReadAll(r) + r.Close() + if err != nil { + return desc, err + } + + children, err := registry.ParseManifestChildren(data) + if err != nil { + return desc, err + } + for _, child := range children.Manifests { + if _, err := h.CopyTree(ctx, from, to, child.Digest, ""); err != nil { + return desc, err + } + } + blobs := children.Layers + if children.Config != nil { + blobs = append(blobs, *children.Config) + } + for _, blob := range blobs { + if _, err := h.Registry.MountBlob(ctx, from, to, blob.Digest); err != nil { + return desc, err + } + } + return h.Registry.PushManifest(ctx, to, tag, data, desc.MediaType) +} + +func (h *fakeHub) matching(match func(request string) bool) []string { + h.mu.Lock() + defer h.mu.Unlock() + var matched []string + for _, request := range h.requests { + if match(request) { + matched = append(matched, request) + } + } + return matched +} + +// requests to the (Hub-specific) copy endpoint +func (h *fakeHub) copyRequests() []string { + return h.matching(func(request string) bool { + return strings.HasPrefix(request, "POST ") && strings.Contains(request, "/_docker/copy/image") + }) +} + +// blob mount requests (the standard way of copying a blob between repositories) +func (h *fakeHub) mountRequests() []string { + return h.matching(func(request string) bool { + return strings.HasPrefix(request, "POST ") && strings.Contains(request, "/blobs/uploads/") && strings.Contains(request, "mount=") + }) +} + +func (h *fakeHub) resetRequests() { + h.mu.Lock() + defer h.mu.Unlock() + h.requests = nil +} + +type testImage struct { + manifest ocispec.Descriptor + blobs []ocispec.Descriptor // config, then layers +} + +type testIndex struct { + desc ocispec.Descriptor + data []byte + images []testImage +} + +func pushTestBlob(t *testing.T, reg ociregistry.Interface, repo, mediaType string, content []byte) ocispec.Descriptor { + t.Helper() + desc := ocispec.Descriptor{ + MediaType: mediaType, + Digest: godigest.FromBytes(content), + Size: int64(len(content)), + } + if _, err := reg.PushBlob(context.Background(), repo, desc, bytes.NewReader(content)); err != nil { + t.Fatalf("pushing blob to %s: %v", repo, err) + } + return desc +} + +func pushTestManifest(t *testing.T, reg ociregistry.Interface, repo, tag, mediaType string, v any) (ocispec.Descriptor, []byte) { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if _, err := reg.PushManifest(context.Background(), repo, tag, data, mediaType); err != nil { + t.Fatalf("pushing manifest to %s: %v", repo, err) + } + return ocispec.Descriptor{MediaType: mediaType, Digest: godigest.FromBytes(data), Size: int64(len(data))}, data +} + +// pushes an image index of "n" distinct single-platform images (each with a config and two layers) to the given repository +func pushTestIndex(t *testing.T, reg ociregistry.Interface, repo, tag, seed string, n int) testIndex { + t.Helper() + + var index testIndex + manifest := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + } + for i := 0; i < n; i++ { + config := pushTestBlob(t, reg, repo, ocispec.MediaTypeImageConfig, []byte(fmt.Sprintf(`{"seed":%q,"image":%d}`, seed, i))) + layers := []ocispec.Descriptor{ + pushTestBlob(t, reg, repo, ocispec.MediaTypeImageLayer, []byte(fmt.Sprintf("%s image %d layer 1", seed, i))), + pushTestBlob(t, reg, repo, ocispec.MediaTypeImageLayer, []byte(fmt.Sprintf("%s image %d layer 2", seed, i))), + } + desc, _ := pushTestManifest(t, reg, repo, "", ocispec.MediaTypeImageManifest, ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + Config: config, + Layers: layers, + }) + index.images = append(index.images, testImage{ + manifest: desc, + blobs: append([]ocispec.Descriptor{config}, layers...), + }) + + desc.Platform = &ocispec.Platform{OS: "linux", Architecture: fmt.Sprintf("arch%d", i)} + manifest.Manifests = append(manifest.Manifests, desc) + } + index.desc, index.data = pushTestManifest(t, reg, repo, tag, ocispec.MediaTypeImageIndex, manifest) + return index +} + +// verifies that everything about the given index (including every child and every blob) exists in the given repository (and the given tag, if any, points to it) +func assertCopied(t *testing.T, hub *fakeHub, repo, tag string, index testIndex) { + t.Helper() + ctx := context.Background() + + if desc, err := hub.Registry.ResolveManifest(ctx, repo, index.desc.Digest); err != nil { + t.Errorf("%s: missing index %s: %v", repo, index.desc.Digest, err) + } else if desc.Digest != index.desc.Digest { + t.Errorf("%s: index digest mismatch: %s vs %s", repo, desc.Digest, index.desc.Digest) + } + if tag != "" { + if desc, err := hub.Registry.ResolveTag(ctx, repo, tag); err != nil { + t.Errorf("%s:%s: missing tag: %v", repo, tag, err) + } else if desc.Digest != index.desc.Digest { + t.Errorf("%s:%s: tag points to %s, not %s", repo, tag, desc.Digest, index.desc.Digest) + } + } + for _, image := range index.images { + if _, err := hub.Registry.ResolveManifest(ctx, repo, image.manifest.Digest); err != nil { + t.Errorf("%s: missing child manifest %s: %v", repo, image.manifest.Digest, err) + } + for _, blob := range image.blobs { + if _, err := hub.Registry.ResolveBlob(ctx, repo, blob.Digest); err != nil { + t.Errorf("%s: missing blob %s: %v", repo, blob.Digest, err) + } + } + } +} + +func assertRequestCount(t *testing.T, what string, requests []string, expected int) { + t.Helper() + if len(requests) != expected { + t.Errorf("expected %d %s request(s), got %d: %q", expected, what, len(requests), requests) + } +} + +// the "lookup" a real deploy would have for an index: every child comes from the same source repository +func lookupFor(hub *fakeHub, repo string) map[ociregistry.Digest]registry.Reference { + return map[ociregistry.Digest]registry.Reference{"": hub.ref(repo, "", "")} +} + +func TestServerSideCopyCopyManifest(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "latest", "copy", 3) + + desc, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), hub.ref("ns/dst", "copied", ""), map[ociregistry.Digest]registry.Reference{}) + if err != nil { + t.Fatal(err) + } + if desc.Digest != index.desc.Digest || desc.MediaType != index.desc.MediaType || desc.Size != index.desc.Size { + t.Errorf("unexpected descriptor: %+v (vs %+v)", desc, index.desc) + } + assertCopied(t, hub, "ns/dst", "copied", index) + + // one request for the whole tree, and no client side blob mounting at all + copyRequests := hub.copyRequests() + assertRequestCount(t, "copy", copyRequests, 1) + assertRequestCount(t, "blob mount", hub.mountRequests(), 0) + if len(copyRequests) == 1 { + u, err := url.Parse(strings.TrimPrefix(copyRequests[0], "POST ")) + if err != nil { + t.Fatal(err) + } + if u.Path != "/v2/ns/dst/_docker/copy/image" { + t.Errorf("unexpected path: %s", u.Path) + } + expected := url.Values{"from": {"ns/src"}, "reference": {string(index.desc.Digest)}, "tag": {"copied"}} + if u.Query().Encode() != expected.Encode() { + t.Errorf("unexpected query: %s (expected %s)", u.Query().Encode(), expected.Encode()) + } + } +} + +func TestServerSideCopyCopyManifestByDigest(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "latest", "bydigest", 2) + + // pushing by digest only means no tag should be created at all (even though the source was a tag) + if _, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), hub.ref("ns/dst", "", index.desc.Digest), map[ociregistry.Digest]registry.Reference{}); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "", index) + + copyRequests := hub.copyRequests() + assertRequestCount(t, "copy", copyRequests, 1) + if len(copyRequests) == 1 && strings.Contains(copyRequests[0], "tag=") { + t.Errorf("unexpected tag in copy request: %s", copyRequests[0]) + } + if _, err := hub.Registry.ResolveTag(ctx, "ns/dst", "latest"); err == nil { + t.Error("unexpected tag \"latest\" in destination") + } +} + +func TestServerSideCopyEnsureManifestChildren(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // this is the "real world" shape for deploy: the index is pushed from raw data, and its children have to come from somewhere else + hub := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "", "children", 4) + + desc, err := registry.EnsureManifest(ctx, hub.ref("ns/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns/src")) + if err != nil { + t.Fatal(err) + } + if desc.Digest != index.desc.Digest { + t.Errorf("unexpected digest: %s", desc.Digest) + } + assertCopied(t, hub, "ns/dst", "latest", index) + + // one copy per child (the index itself is a normal push), and no client side blob mounting at all + assertRequestCount(t, "copy", hub.copyRequests(), 4) + assertRequestCount(t, "blob mount", hub.mountRequests(), 0) +} + +func TestServerSideCopyEnsureManifestExistingChildren(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "", "existing", 3) + + // one child is already (completely) there, so we should not ask for it again + if _, err := hub.CopyTree(ctx, "ns/src", "ns/dst", index.images[0].manifest.Digest, ""); err != nil { + t.Fatal(err) + } + + if _, err := registry.EnsureManifest(ctx, hub.ref("ns/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 2) + assertRequestCount(t, "blob mount", hub.mountRequests(), 0) +} + +func TestServerSideCopyIdempotent(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "latest", "idempotent", 3) + + for _, dst := range []registry.Reference{hub.ref("ns/dst", "latest", ""), hub.ref("ns/dst", "latest", "")} { + hub.resetRequests() + if _, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), dst, map[ociregistry.Digest]registry.Reference{}); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "latest", index) + } + // the second time around, everything is already there, so we should not even be asking + assertRequestCount(t, "copy", hub.copyRequests(), 0) + assertRequestCount(t, "blob mount", hub.mountRequests(), 0) +} + +func TestServerSideCopyRouteMissing(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // any registry that is not Docker Hub (or is, but without this feature) + hub := newFakeHub(t, hubNoRoute, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "", "noroute", 3) + + if _, err := registry.EnsureManifest(ctx, hub.ref("ns/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 1) // we only asked the first time + assertRequestCount(t, "blob mount", hub.mountRequests(), 3*3) + + // and we should have remembered that for the whole host (regardless of repository) + index2 := pushTestIndex(t, hub.Registry, "other/src", "latest", "noroute2", 2) + if _, err := registry.CopyManifest(ctx, hub.ref("other/src", "latest", ""), hub.ref("other/dst", "latest", ""), map[ociregistry.Digest]registry.Reference{}); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "other/dst", "latest", index2) + assertRequestCount(t, "copy", hub.copyRequests(), 1) + + // a registry we know does not work should not cost us anything extra either (the source is only fetched, never resolved separately first) + assertRequestCount(t, "source HEAD", hub.matching(func(request string) bool { + return strings.HasPrefix(request, "HEAD /v2/other/src/") + }), 0) +} + +func TestServerSideCopyMissingChildrenReportedAs500(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // "EnsureManifest" only recognizes a 4xx as "some children are missing" (and so does not recognize this registry's 500), which is existing behavior that having a server side copy to try does not change: the error is the same, and we only ask once + hub := newFakeHub(t, hubMissingChildren500, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "latest", "500", 2) + + if _, err := registry.EnsureManifest(ctx, hub.ref("ns/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns/src")); err == nil { + t.Error("expected an error pushing an index with missing children to a registry that reports a 500 for it") + } + assertRequestCount(t, "copy", hub.copyRequests(), 0) // never gets as far as looking for children + + for i := 0; i < 2; i++ { + if _, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), hub.ref("ns/dst", "latest", ""), map[ociregistry.Digest]registry.Reference{}); err == nil { + t.Error("expected an error copying an index to a registry that reports a 500 for missing children") + } + } + assertRequestCount(t, "copy", hub.copyRequests(), 1) // and we remembered after the first one + if _, err := hub.Registry.ResolveManifest(ctx, "ns/dst", index.desc.Digest); err == nil { + t.Error("unexpected index in destination") + } +} + +func TestServerSideCopyFeatureDisabled(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // the endpoint exists, but the feature flag is not on for these namespaces + hub := newFakeHub(t, hubFlagOff, false) + index := pushTestIndex(t, hub.Registry, "ns1/src", "", "flagoff", 3) + + if _, err := registry.EnsureManifest(ctx, hub.ref("ns1/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns1/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns1/dst", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 1) + assertRequestCount(t, "blob mount", hub.mountRequests(), 3*3) + + // the same pair of namespaces should not be tried again + if _, err := registry.EnsureManifest(ctx, hub.ref("ns1/dst2", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns1/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns1/dst2", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 1) + + // but a different pair of namespaces might have the feature enabled, so we have to ask again + index2 := pushTestIndex(t, hub.Registry, "ns2/src", "", "flagoff2", 2) + if _, err := registry.EnsureManifest(ctx, hub.ref("ns1/dst3", "latest", ""), index2.data, index2.desc.MediaType, lookupFor(hub, "ns2/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns1/dst3", "latest", index2) + assertRequestCount(t, "copy", hub.copyRequests(), 2) +} + +func TestServerSideCopyUntrustworthy(t *testing.T) { + t.Parallel() + + for name, mode := range map[string]fakeHubMode{ + "server error": hubBroken, + "wrong digest": hubWrongDigest, + } { + name, mode := name, mode + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, mode, false) + index := pushTestIndex(t, hub.Registry, "ns/src", "", "untrustworthy", 3) + + // these are not something we can safely draw a conclusion from, so every child gets its own attempt (and its own fallback, which has to be sufficient to get the right result) + if _, err := registry.EnsureManifest(ctx, hub.ref("ns/dst", "latest", ""), index.data, index.desc.MediaType, lookupFor(hub, "ns/src")); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 3) + assertRequestCount(t, "blob mount", hub.mountRequests(), 3*3) + }) + } +} + +func TestServerSideCopyUnknownSource(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // a source that does not exist is still an error (and a good one, from the client side code) + hub := newFakeHub(t, hubWorks, false) + if _, err := registry.CopyManifest(ctx, hub.ref("ns/nope", "latest", ""), hub.ref("ns/dst", "latest", ""), map[ociregistry.Digest]registry.Reference{}); err == nil { + t.Error("expected an error copying a manifest that does not exist") + } + assertRequestCount(t, "copy", hub.copyRequests(), 0) +} + +func TestServerSideCopyDigestMismatch(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // explicitly asking for a destination digest that is not the source digest is an error, and not something we should hand to the server + hub := newFakeHub(t, hubWorks, false) + pushTestIndex(t, hub.Registry, "ns/src", "latest", "mismatch", 2) + + wrong := godigest.FromString("wrong") + if _, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), hub.ref("ns/dst", "latest", wrong), map[ociregistry.Digest]registry.Reference{}); err == nil { + t.Error("expected an error for a mismatched destination digest") + } + assertRequestCount(t, "copy", hub.copyRequests(), 0) +} + +func TestServerSideCopyDifferentHosts(t *testing.T) { + t.Parallel() + ctx := context.Background() + + // the endpoint can only copy within one registry, so copying between two registries has to stay client side + src := newFakeHub(t, hubWorks, false) + dst := newFakeHub(t, hubWorks, false) + index := pushTestIndex(t, src.Registry, "ns/src", "latest", "hosts", 2) + + if _, err := registry.CopyManifest(ctx, src.ref("ns/src", "latest", ""), dst.ref("ns/dst", "latest", ""), map[ociregistry.Digest]registry.Reference{}); err != nil { + t.Fatal(err) + } + assertCopied(t, dst, "ns/dst", "latest", index) + assertRequestCount(t, "source copy", src.copyRequests(), 0) + assertRequestCount(t, "destination copy", dst.copyRequests(), 0) +} + +func TestServerSideCopyAuthScopes(t *testing.T) { + t.Parallel() + ctx := context.Background() + + hub := newFakeHub(t, hubWorks, true) + index := pushTestIndex(t, hub.Registry, "ns/src", "latest", "scopes", 2) + + if _, err := registry.CopyManifest(ctx, hub.ref("ns/src", "latest", ""), hub.ref("ns/dst", "latest", ""), map[ociregistry.Digest]registry.Reference{}); err != nil { + t.Fatal(err) + } + assertCopied(t, hub, "ns/dst", "latest", index) + assertRequestCount(t, "copy", hub.copyRequests(), 1) + + // the copy needs to push (and pull) the destination and pull the source, and (to not need a second token) all in one token + needed := []ociauth.ResourceScope{ + {ResourceType: "repository", Resource: "ns/dst", Action: "push"}, + {ResourceType: "repository", Resource: "ns/dst", Action: "pull"}, + {ResourceType: "repository", Resource: "ns/src", Action: "pull"}, + } + hub.mu.Lock() + defer hub.mu.Unlock() + for _, scopes := range hub.tokenScopes { + scope := ociauth.ParseScope(scopes) + holdsAll := true + for _, rs := range needed { + holdsAll = holdsAll && scope.Holds(rs) + } + if holdsAll { + return + } + } + t.Errorf("no token request included %v: %q", needed, hub.tokenScopes) +}