mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 07:39:19 -05:00
Mirroring img:v1@sha512:<X> compared the sha512 digest with the sha256 digest the tag resolved to, so an unmoved tag was reported as moved and its manifest walked twice. Key seen manifests and the returned digest by sha256, as tags are.
685 lines
29 KiB
Go
685 lines
29 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha512"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
// fakeImageRegistry serves manifests and blobs of a small multi-arch image,
|
|
// a single-arch image and a moved tag. It fails with 503 once offline.
|
|
type fakeImageRegistry struct {
|
|
server *httptest.Server
|
|
offline atomic.Bool
|
|
// mu guards manifests against a test changing it while blobs are served.
|
|
mu sync.RWMutex
|
|
manifests map[string]fakeManifest
|
|
blobs map[string]string
|
|
blobGets atomic.Int32
|
|
// tagGets counts GET requests for manifests by tag.
|
|
tagGets atomic.Int32
|
|
// truncateManifests makes manifest GETs end after a few bytes.
|
|
truncateManifests atomic.Bool
|
|
// onBlob, if set, runs before a blob is served.
|
|
onBlob func(path string)
|
|
// sha512Digests makes manifests requested by a sha512 digest name that
|
|
// digest in Docker-Content-Digest, as registries do.
|
|
sha512Digests bool
|
|
}
|
|
|
|
type fakeImage struct {
|
|
indexDigest string
|
|
amd64Digest string
|
|
arm64Digest string
|
|
amd64Layer string
|
|
arm64Layer string
|
|
singleDigest string
|
|
singleLayer string
|
|
// attestationLayers holds the amd64 and arm64 provenance statements.
|
|
attestationLayers []string
|
|
}
|
|
|
|
func newFakeImageRegistry(t *testing.T) (*fakeImageRegistry, fakeImage) {
|
|
t.Helper()
|
|
const repository = "team/app"
|
|
r := &fakeImageRegistry{manifests: make(map[string]fakeManifest), blobs: make(map[string]string)}
|
|
var image fakeImage
|
|
|
|
addBlob := func(content string) string {
|
|
digest := sha256Digest([]byte(content))
|
|
r.blobs["/v2/"+repository+"/blobs/"+digest] = content
|
|
return digest
|
|
}
|
|
addManifest := func(contentType string, doc any, tags ...string) string {
|
|
body, err := json.Marshal(doc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
digest := sha256Digest(body)
|
|
for _, reference := range append(tags, digest) {
|
|
r.manifests["/v2/"+repository+"/manifests/"+reference] = fakeManifest{contentType: contentType, body: string(body)}
|
|
}
|
|
return digest
|
|
}
|
|
imageManifest := func(platform string) (string, string) {
|
|
config := addBlob(`{"architecture":"` + platform + `"}`)
|
|
layer := addBlob("layer for " + platform)
|
|
digest := addManifest(ociManifestMediaType, map[string]any{
|
|
"schemaVersion": 2, "mediaType": ociManifestMediaType,
|
|
"config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": config},
|
|
"layers": []map[string]any{
|
|
{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": layer},
|
|
{"mediaType": "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip", "digest": sha256Digest([]byte("foreign")), "urls": []string{"https://example.invalid/layer"}},
|
|
},
|
|
})
|
|
return digest, layer
|
|
}
|
|
|
|
image.amd64Digest, image.amd64Layer = imageManifest("amd64")
|
|
image.arm64Digest, image.arm64Layer = imageManifest("arm64")
|
|
attestation := func(platformDigest, platform string) map[string]any {
|
|
statement := addBlob("provenance for " + platform)
|
|
digest := addManifest(ociManifestMediaType, map[string]any{
|
|
"schemaVersion": 2, "mediaType": ociManifestMediaType,
|
|
"config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": addBlob("{}")},
|
|
"layers": []map[string]any{{"mediaType": "application/vnd.in-toto+json", "digest": statement}},
|
|
})
|
|
image.attestationLayers = append(image.attestationLayers, statement)
|
|
return map[string]any{
|
|
"mediaType": ociManifestMediaType, "digest": digest,
|
|
"platform": map[string]string{"os": "unknown", "architecture": "unknown"},
|
|
"annotations": map[string]string{
|
|
dockerReferenceTypeAnnotation: attestationManifestReference,
|
|
dockerReferenceDigestAnnotation: platformDigest,
|
|
},
|
|
}
|
|
}
|
|
image.indexDigest = addManifest(ociImageIndexMediaType, map[string]any{
|
|
"schemaVersion": 2, "mediaType": ociImageIndexMediaType,
|
|
"manifests": []map[string]any{
|
|
{"mediaType": ociManifestMediaType, "digest": image.amd64Digest, "platform": map[string]string{"os": "linux", "architecture": "amd64"}},
|
|
{"mediaType": ociManifestMediaType, "digest": image.arm64Digest, "platform": map[string]string{"os": "linux", "architecture": "arm64", "variant": "v8"}},
|
|
attestation(image.amd64Digest, "amd64"),
|
|
attestation(image.arm64Digest, "arm64"),
|
|
},
|
|
}, "multi")
|
|
|
|
image.singleDigest, image.singleLayer = imageManifest("single")
|
|
r.manifests["/v2/"+repository+"/manifests/single"] = r.manifests["/v2/"+repository+"/manifests/"+image.singleDigest]
|
|
|
|
r.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
if r.offline.Load() {
|
|
http.Error(w, "offline", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
r.mu.RLock()
|
|
doc, ok := r.manifests[req.URL.Path]
|
|
r.mu.RUnlock()
|
|
if ok {
|
|
w.Header().Set("Content-Type", doc.contentType)
|
|
w.Header().Set("Docker-Content-Digest", sha256Digest([]byte(doc.body)))
|
|
if _, ref, _ := strings.Cut(req.URL.Path, "/manifests/"); r.sha512Digests && strings.HasPrefix(ref, "sha512:") {
|
|
w.Header().Set("Docker-Content-Digest", ref)
|
|
}
|
|
if req.Method == http.MethodGet && !strings.Contains(req.URL.Path, "/manifests/sha256:") {
|
|
r.tagGets.Add(1)
|
|
}
|
|
if req.Method == http.MethodGet && r.truncateManifests.Load() {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(doc.body)))
|
|
_, _ = io.WriteString(w, doc.body[:4])
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, doc.body)
|
|
return
|
|
}
|
|
if blob, ok := r.blobs[req.URL.Path]; ok {
|
|
if r.onBlob != nil {
|
|
r.onBlob(req.URL.Path)
|
|
}
|
|
r.blobGets.Add(1)
|
|
_, _ = io.WriteString(w, blob)
|
|
return
|
|
}
|
|
http.NotFound(w, req)
|
|
}))
|
|
t.Cleanup(r.server.Close)
|
|
return r, image
|
|
}
|
|
|
|
func newMirrorTestProxy(t *testing.T, registry *fakeImageRegistry) *Proxy {
|
|
t.Helper()
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = registry.server.Client()
|
|
proxy.Fetcher = fetch.NewFetcher(fetch.WithHTTPClient(registry.server.Client()), fetch.WithMaxRetries(0))
|
|
proxy.MetadataTTL = 0
|
|
return proxy
|
|
}
|
|
|
|
func pull(t *testing.T, h *ContainerHandler, target, accept string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
request := httptest.NewRequest(http.MethodGet, target, nil)
|
|
if accept != "" {
|
|
request.Header.Set("Accept", accept)
|
|
}
|
|
response := httptest.NewRecorder()
|
|
h.Routes().ServeHTTP(response, request)
|
|
return response
|
|
}
|
|
|
|
func TestMirrorImageThenPullOffline(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
mirrorHandler := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
result, err := mirrorHandler.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"multi", "single"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
if len(result.Warnings) == 0 || !strings.Contains(strings.Join(result.Warnings, "\n"), "non-distributable") {
|
|
t.Errorf("warnings = %v, want the skipped foreign layer", result.Warnings)
|
|
}
|
|
registry.offline.Store(true)
|
|
|
|
// A fresh handler instance, as after a restart, serves the pull.
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
for _, accept := range []string{dockerManifestAccept, containerdManifestAccept} {
|
|
for _, reference := range []string{"multi", image.indexDigest, image.amd64Digest, image.arm64Digest, "single", image.singleDigest} {
|
|
if response := pull(t, h, "/team/app/manifests/"+reference, accept); response.Code != http.StatusOK {
|
|
t.Errorf("offline manifest %s with %.30q: status %d: %s", reference, accept, response.Code, response.Body.String())
|
|
}
|
|
}
|
|
}
|
|
for _, layer := range append([]string{image.amd64Layer, image.arm64Layer, image.singleLayer}, image.attestationLayers...) {
|
|
if response := pull(t, h, "/team/app/blobs/"+layer, ""); response.Code != http.StatusOK {
|
|
t.Errorf("offline blob %s: status %d", layer, response.Code)
|
|
}
|
|
}
|
|
|
|
registry.offline.Store(false)
|
|
gets := registry.blobGets.Load()
|
|
again, err := mirrorHandler.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"multi"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("second MirrorImage: %v", err)
|
|
}
|
|
if again.Fetched != 0 || registry.blobGets.Load() != gets {
|
|
t.Errorf("second run fetched %d items and %d blobs, want nothing new", again.Fetched, registry.blobGets.Load()-gets)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImagePlatformFilter(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
platform, err := ParsePlatform("linux/arm64")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"multi"}}, []Platform{platform}); err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
registry.offline.Store(true)
|
|
if response := pull(t, h, "/team/app/blobs/"+image.arm64Layer, ""); response.Code != http.StatusOK {
|
|
t.Errorf("arm64 layer status = %d, want 200", response.Code)
|
|
}
|
|
if response := pull(t, h, "/team/app/blobs/"+image.attestationLayers[1], ""); response.Code != http.StatusOK {
|
|
t.Errorf("arm64 attestation status = %d, want 200", response.Code)
|
|
}
|
|
for _, layer := range []string{image.amd64Layer, image.attestationLayers[0]} {
|
|
if response := pull(t, h, "/team/app/blobs/"+layer, ""); response.Code == http.StatusOK {
|
|
t.Errorf("amd64 blob %s was mirrored despite the linux/arm64 filter", layer)
|
|
}
|
|
}
|
|
|
|
registry.offline.Store(false)
|
|
riscv, _ := ParsePlatform("linux/riscv64")
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"multi"}}, []Platform{riscv}); err == nil {
|
|
t.Error("a filter matching no index entry did not fail")
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageMovedTagMirrorsBothTargets(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
result, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{image.singleDigest, "multi"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(result.Warnings, "\n"), "now points to") {
|
|
t.Errorf("warnings = %v, want the moved-tag warning", result.Warnings)
|
|
}
|
|
registry.offline.Store(true)
|
|
for _, layer := range []string{image.singleLayer, image.amd64Layer} {
|
|
if response := pull(t, h, "/team/app/blobs/"+layer, ""); response.Code != http.StatusOK {
|
|
t.Errorf("blob %s status = %d, want 200", layer, response.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageNamespaceRouteMatchesContainerdPull(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
named := map[string]string{"internal": registry.server.URL}
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", dockerHubRegistry, named)
|
|
host := strings.TrimPrefix(registry.server.URL, "http://")
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Registry: host, Name: "team/app", References: []string{"single"}}, nil); err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
registry.offline.Store(true)
|
|
ns := "?" + url.Values{namespaceQueryParam: {host}}.Encode()
|
|
if response := pull(t, h, "/team/app/manifests/single"+ns, containerdManifestAccept); response.Code != http.StatusOK {
|
|
t.Errorf("containerd ns manifest status = %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if response := pull(t, h, "/team/app/blobs/"+image.singleLayer+ns, ""); response.Code != http.StatusOK {
|
|
t.Errorf("containerd ns blob status = %d", response.Code)
|
|
}
|
|
if response := pull(t, h, "/upstream/internal/team/app/blobs/"+image.singleLayer, ""); response.Code != http.StatusOK {
|
|
t.Errorf("prefix-route blob status = %d", response.Code)
|
|
}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Registry: "unknown.example", Name: "team/app", References: []string{"single"}}, nil); err == nil {
|
|
t.Error("an unconfigured registry host was accepted")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeImageRef(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ref ImageRef
|
|
defaultRT string
|
|
wantName string
|
|
wantErr bool
|
|
}{
|
|
{name: "docker hub default adds library", ref: ImageRef{Name: "alpine", References: []string{"3.20"}}, defaultRT: dockerHubRegistry, wantName: "library/alpine"},
|
|
{name: "docker hub ns adds library", ref: ImageRef{Registry: "hub.docker.com", Name: "alpine", References: []string{"3.20"}}, defaultRT: "https://registry.example", wantName: "library/alpine"},
|
|
{name: "other default keeps name", ref: ImageRef{Name: "alpine", References: []string{"3.20"}}, defaultRT: "https://registry.example", wantName: "alpine"},
|
|
{name: "path traversal", ref: ImageRef{Name: "../../x", References: []string{"1"}}, defaultRT: dockerHubRegistry, wantErr: true},
|
|
{name: "query in tag", ref: ImageRef{Name: "library/alpine", References: []string{"3.20?x#y"}}, defaultRT: dockerHubRegistry, wantErr: true},
|
|
{name: "short digest", ref: ImageRef{Name: "library/alpine", References: []string{"sha256:abcd"}}, defaultRT: dockerHubRegistry, wantErr: true},
|
|
{name: "no reference", ref: ImageRef{Name: "library/alpine"}, defaultRT: dockerHubRegistry, wantErr: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := normalizeImageRef(tt.ref, tt.defaultRT)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if !tt.wantErr && got.Name != tt.wantName {
|
|
t.Errorf("name = %q, want %q", got.Name, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageKeepsPreviousTagWhenMovedTagIsIncomplete(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"single"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
previous := registry.manifests["/v2/team/app/manifests/single"]
|
|
|
|
// Move the tag to an image whose layer the registry cannot serve.
|
|
broken, err := json.Marshal(map[string]any{
|
|
"schemaVersion": 2, "mediaType": ociManifestMediaType,
|
|
"config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": image.singleLayer},
|
|
"layers": []map[string]any{{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": sha256Digest([]byte("missing layer"))}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
brokenDoc := fakeManifest{contentType: ociManifestMediaType, body: string(broken)}
|
|
registry.manifests["/v2/team/app/manifests/single"] = brokenDoc
|
|
registry.manifests["/v2/team/app/manifests/"+sha256Digest(broken)] = brokenDoc
|
|
|
|
missing := sha256Digest([]byte("missing layer"))
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err == nil || !strings.Contains(err.Error(), missing) {
|
|
t.Fatalf("mirroring the incomplete image: err = %v, want a failure naming layer %s", err, missing)
|
|
}
|
|
registry.offline.Store(true)
|
|
|
|
response := pull(t, h, "/team/app/manifests/single", dockerManifestAccept)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("offline tag status = %d: %s", response.Code, response.Body.String())
|
|
}
|
|
if response.Body.String() != previous.body {
|
|
t.Errorf("offline tag points to %s, want the previously mirrored image", sha256Digest(response.Body.Bytes()))
|
|
}
|
|
}
|
|
|
|
func TestMirrorImagePlatformFilterAcrossNestedIndex(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
nested := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
|
|
`{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"` + image.arm64Digest + `","platform":{"os":"linux","architecture":"arm64"}}]}`
|
|
nestedDigest := sha256Digest([]byte(nested))
|
|
top := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` +
|
|
`{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"` + image.amd64Digest + `","platform":{"os":"linux","architecture":"amd64"}},` +
|
|
`{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"` + nestedDigest + `"}]}`
|
|
for _, reference := range []string{"nested", nestedDigest} {
|
|
registry.manifests["/v2/team/app/manifests/"+reference] = fakeManifest{contentType: ociImageIndexMediaType, body: nested}
|
|
}
|
|
for _, reference := range []string{"top", sha256Digest([]byte(top))} {
|
|
registry.manifests["/v2/team/app/manifests/"+reference] = fakeManifest{contentType: ociImageIndexMediaType, body: top}
|
|
}
|
|
|
|
amd64, _ := ParsePlatform("linux/amd64")
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"top"}}, []Platform{amd64}); err != nil {
|
|
t.Fatalf("MirrorImage: %v (a nested index without amd64 must not fail the image)", err)
|
|
}
|
|
riscv, _ := ParsePlatform("linux/riscv64")
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"top"}}, []Platform{riscv}); err == nil {
|
|
t.Error("a filter matching nothing in the whole image did not fail")
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageRevalidatesUnchangedTagWithHead(t *testing.T) {
|
|
registry, _ := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"multi"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
gets := registry.tagGets.Load()
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("second MirrorImage: %v", err)
|
|
}
|
|
if extra := registry.tagGets.Load() - gets; extra != 0 {
|
|
t.Errorf("second run sent %d GETs for the unchanged tag, want a HEAD only", extra)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageFallsBackWhenTagBodyIsCutOff(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"single"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
// Point the tag elsewhere so the HEAD check sends a GET, then cut that
|
|
// GET's body short.
|
|
registry.manifests["/v2/team/app/manifests/single"] = registry.manifests["/v2/team/app/manifests/"+image.amd64Digest]
|
|
registry.truncateManifests.Store(true)
|
|
|
|
result, err := h.MirrorImage(context.Background(), ref, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage with a cut-off body: %v, want the cached tag to be used", err)
|
|
}
|
|
if !strings.Contains(strings.Join(result.Warnings, "\n"), "stale cache") {
|
|
t.Errorf("warnings = %v, want the stale-cache warning", result.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageKeepsTagMovedByPullDuringWalk(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
// While the mirror fetches the first layer of "single", the tag moves
|
|
// upstream and a client pull caches the new target.
|
|
moved := registry.manifests["/v2/team/app/manifests/"+image.amd64Digest]
|
|
var once atomic.Bool
|
|
registry.onBlob = func(string) {
|
|
if once.CompareAndSwap(false, true) {
|
|
registry.mu.Lock()
|
|
registry.manifests["/v2/team/app/manifests/single"] = moved
|
|
registry.mu.Unlock()
|
|
if response := pull(t, h, "/team/app/manifests/single", dockerManifestAccept); response.Code != http.StatusOK {
|
|
t.Errorf("client pull during the walk: status %d", response.Code)
|
|
}
|
|
}
|
|
}
|
|
result, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"single"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(result.Warnings, "\n"), "moved") {
|
|
t.Errorf("warnings = %v, want the moved-tag warning", result.Warnings)
|
|
}
|
|
registry.onBlob = nil
|
|
registry.offline.Store(true)
|
|
if response := pull(t, h, "/team/app/manifests/single", dockerManifestAccept); response.Body.String() != moved.body {
|
|
t.Errorf("offline tag points to %s, want the newer target %s", sha256Digest(response.Body.Bytes()), image.amd64Digest)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageCountsNoCacheHits(t *testing.T) {
|
|
registry, _ := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"multi"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
var hits int
|
|
var lastAccess sql.NullString
|
|
if err := proxy.DB.QueryRow("SELECT COALESCE(SUM(hit_count), 0), MAX(last_accessed_at) FROM artifacts").Scan(&hits, &lastAccess); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("second MirrorImage: %v", err)
|
|
}
|
|
var hitsAfter int
|
|
var lastAccessAfter sql.NullString
|
|
if err := proxy.DB.QueryRow("SELECT COALESCE(SUM(hit_count), 0), MAX(last_accessed_at) FROM artifacts").Scan(&hitsAfter, &lastAccessAfter); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hitsAfter != hits || lastAccessAfter != lastAccess {
|
|
t.Errorf("second run changed hits %d -> %d and last access %v -> %v, want no change", hits, hitsAfter, lastAccess, lastAccessAfter)
|
|
}
|
|
}
|
|
|
|
// A scheduled image that no client pulls keeps its blobs: a mirror run that
|
|
// finds them cached holds them back from retention, though not from LRU
|
|
// eviction, which TestMirrorImageCountsNoCacheHits covers.
|
|
func TestMirrorImageKeepsCachedBlobsFromRetention(t *testing.T) {
|
|
registry, _ := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"multi"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
old := time.Now().Add(-30 * 24 * time.Hour).UTC()
|
|
if _, err := proxy.DB.Exec(proxy.DB.Rebind("UPDATE artifacts SET fetched_at = ?, last_accessed_at = ?"), old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cutoff := time.Now().Add(-14 * 24 * time.Hour)
|
|
maxID, err := proxy.DB.MaxArtifactID()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if candidates, err := proxy.DB.GetRetentionCandidates(0, maxID, cutoff); err != nil || len(candidates) == 0 {
|
|
t.Fatalf("before the second run: %d candidates, err %v; want the aged blobs", len(candidates), err)
|
|
}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("second MirrorImage: %v", err)
|
|
}
|
|
candidates, err := proxy.DB.GetRetentionCandidates(0, maxID, cutoff)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(candidates) != 0 {
|
|
t.Errorf("after the second run %d blobs are retention candidates, want none", len(candidates))
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageRefetchesBlobMissingFromStorage(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
ref := ImageRef{Name: "team/app", References: []string{"multi"}}
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("first MirrorImage: %v", err)
|
|
}
|
|
var path string
|
|
if err := proxy.DB.QueryRow("SELECT storage_path FROM artifacts WHERE filename = ?", image.amd64Layer).Scan(&path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := proxy.Storage.Delete(context.Background(), path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gets := registry.blobGets.Load()
|
|
if _, err := h.MirrorImage(context.Background(), ref, nil); err != nil {
|
|
t.Fatalf("second MirrorImage: %v", err)
|
|
}
|
|
if got := registry.blobGets.Load() - gets; got != 1 {
|
|
t.Errorf("second run fetched %d blobs, want the one missing from storage", got)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageFetchesBlobsConcurrently(t *testing.T) {
|
|
registry, _ := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
// "single" has a config and a layer blob. Each request waits until the
|
|
// other is in flight too, or gives up after a while.
|
|
var inFlight atomic.Int32
|
|
var concurrent atomic.Bool
|
|
both := make(chan struct{})
|
|
var once sync.Once
|
|
registry.onBlob = func(string) {
|
|
defer inFlight.Add(-1)
|
|
if inFlight.Add(1) >= 2 {
|
|
concurrent.Store(true)
|
|
once.Do(func() { close(both) })
|
|
}
|
|
select {
|
|
case <-both:
|
|
case <-time.After(2 * time.Second):
|
|
}
|
|
}
|
|
result, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"single"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
if !concurrent.Load() {
|
|
t.Error("blobs were fetched one after another, want them fetched concurrently")
|
|
}
|
|
if result.Fetched != 3 {
|
|
t.Errorf("fetched = %d, want the manifest and its two blobs", result.Fetched)
|
|
}
|
|
}
|
|
|
|
// TestMirrorImageBlobFailureKeepsOtherFetches checks that a blob failing
|
|
// doesn't cancel the blobs already being fetched: their fetch may be shared
|
|
// with a client pull, which would fail with it, and the next run would have
|
|
// to fetch them again.
|
|
func TestMirrorImageBlobFailureKeepsOtherFetches(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
|
|
// The config blob is gone upstream; the layer is still being sent when
|
|
// its 404 arrives.
|
|
for path := range registry.blobs {
|
|
if !strings.HasSuffix(path, image.singleLayer) {
|
|
delete(registry.blobs, path)
|
|
}
|
|
}
|
|
registry.onBlob = func(string) { time.Sleep(300 * time.Millisecond) }
|
|
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"single"}}, nil); err == nil {
|
|
t.Fatal("MirrorImage succeeded with a blob missing upstream")
|
|
}
|
|
registry.offline.Store(true)
|
|
if response := pull(t, h, "/team/app/blobs/"+image.singleLayer, ""); response.Code != http.StatusOK {
|
|
t.Errorf("layer fetched alongside the failing blob: status %d, want it cached", response.Code)
|
|
}
|
|
}
|
|
|
|
func TestMirrorImageAcceptsSHA512Blobs(t *testing.T) {
|
|
registry, image := newFakeImageRegistry(t)
|
|
const layer = "sha512 layer"
|
|
sum := sha512.Sum512([]byte(layer))
|
|
layerDigest := "sha512:" + hex.EncodeToString(sum[:])
|
|
body, err := json.Marshal(map[string]any{
|
|
"schemaVersion": 2, "mediaType": ociManifestMediaType,
|
|
"config": map[string]any{"mediaType": "application/vnd.oci.image.config.v1+json", "digest": image.singleLayer},
|
|
"layers": []map[string]any{{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": layerDigest}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry.mu.Lock()
|
|
registry.manifests["/v2/team/app/manifests/sha512"] = fakeManifest{contentType: ociManifestMediaType, body: string(body)}
|
|
registry.blobs["/v2/team/app/blobs/"+layerDigest] = layer
|
|
registry.mu.Unlock()
|
|
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
if _, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{"sha512"}}, nil); err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
cached, err := proxy.GetCachedArtifact(t.Context(), "oci", "team/app", layerDigest, layerDigest)
|
|
if err != nil || cached == nil {
|
|
t.Fatalf("sha512 layer not cached: %v, %v", cached, err)
|
|
}
|
|
if cached.Reader != nil {
|
|
_ = cached.Reader.Close()
|
|
}
|
|
}
|
|
|
|
// TestMirrorImageSHA512ReferenceMatchesTag checks that mirroring a tag
|
|
// pinned by a sha512 digest compares it with the tag's manifest by sha512,
|
|
// so an unmoved tag is neither reported as moved nor counted twice.
|
|
func TestMirrorImageSHA512ReferenceMatchesTag(t *testing.T) {
|
|
mirror := func(t *testing.T, sha512Ref bool) ImageResult {
|
|
registry, image := newFakeImageRegistry(t)
|
|
reference := image.singleDigest
|
|
if sha512Ref {
|
|
body := registry.manifests["/v2/team/app/manifests/single"].body
|
|
sum := sha512.Sum512([]byte(body))
|
|
reference = "sha512:" + hex.EncodeToString(sum[:])
|
|
registry.manifests["/v2/team/app/manifests/"+reference] = registry.manifests["/v2/team/app/manifests/single"]
|
|
registry.sha512Digests = true
|
|
}
|
|
proxy := newMirrorTestProxy(t, registry)
|
|
h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example", registry.server.URL)
|
|
result, err := h.MirrorImage(context.Background(), ImageRef{Name: "team/app", References: []string{reference, "single"}}, nil)
|
|
if err != nil {
|
|
t.Fatalf("MirrorImage: %v", err)
|
|
}
|
|
return result
|
|
}
|
|
want := mirror(t, false)
|
|
got := mirror(t, true)
|
|
if !slices.Equal(got.Warnings, want.Warnings) {
|
|
t.Errorf("warnings = %v, want %v as for a sha256 reference", got.Warnings, want.Warnings)
|
|
}
|
|
if got.Fetched != want.Fetched {
|
|
t.Errorf("fetched = %d, want %d as for a sha256 reference", got.Fetched, want.Fetched)
|
|
}
|
|
}
|