298 lines
10 KiB
Go
298 lines
10 KiB
Go
package registrygate
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const testDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
|
|
type clock struct {
|
|
mu sync.Mutex
|
|
t time.Time
|
|
}
|
|
|
|
func (c *clock) now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.t
|
|
}
|
|
|
|
func (c *clock) advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
c.t = c.t.Add(d)
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func newMaintGate(t *testing.T) (*Gate, *httptest.Server, *httptest.Server, *upstreamLog, *clock) {
|
|
t.Helper()
|
|
up := &upstreamLog{}
|
|
upSrv := httptest.NewServer(up.handler())
|
|
t.Cleanup(upSrv.Close)
|
|
target, _ := url.Parse(upSrv.URL)
|
|
g := New(target, map[string]string{
|
|
PrincipalPlatform: "plat-secret", PrincipalBuild: "build-secret", PrincipalPrune: "prune-secret",
|
|
}, nil)
|
|
clk := &clock{t: time.Date(2026, 9, 24, 3, 0, 0, 0, time.UTC)}
|
|
g.maint.now = clk.now
|
|
g.maint.lastWrite = time.Time{}
|
|
gs := httptest.NewServer(g)
|
|
t.Cleanup(gs.Close)
|
|
ms := httptest.NewServer(g.MaintHandler())
|
|
t.Cleanup(ms.Close)
|
|
return g, gs, ms, up, clk
|
|
}
|
|
|
|
func post(t *testing.T, srv *httptest.Server, path string) int {
|
|
t.Helper()
|
|
resp, err := http.Post(srv.URL+path, "text/plain", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func TestPrunePrincipalMayOnlyDeleteManifestsByDigest(t *testing.T) {
|
|
up2 := &upstreamLog{}
|
|
upSrv := httptest.NewServer(up2.handler())
|
|
t.Cleanup(upSrv.Close)
|
|
target, _ := url.Parse(upSrv.URL)
|
|
g := New(target, map[string]string{PrincipalPrune: "prune-secret"}, nil)
|
|
srv := httptest.NewServer(g)
|
|
t.Cleanup(srv.Close)
|
|
|
|
for _, p := range []string{
|
|
"/v2/user-uploads/s1/manifests/" + testDigest,
|
|
"/v2/felis/felis/manifests/" + testDigest,
|
|
} {
|
|
if resp := do(t, srv, http.MethodDelete, p, PrincipalPrune, "prune-secret"); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("prune DELETE %s = %d, want it proxied", p, resp.StatusCode)
|
|
}
|
|
}
|
|
for _, c := range []struct{ method, path string }{
|
|
{http.MethodDelete, "/v2/user-uploads/s1/manifests/latest"},
|
|
{http.MethodDelete, "/v2/user-uploads/s1/blobs/" + testDigest},
|
|
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:short"},
|
|
{http.MethodPut, "/v2/user-uploads/s1/manifests/" + testDigest},
|
|
{http.MethodPost, "/v2/user-uploads/s1/blobs/uploads/"},
|
|
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc"},
|
|
} {
|
|
if resp := do(t, srv, c.method, c.path, PrincipalPrune, "prune-secret"); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("prune %s %s = %d, want 403", c.method, c.path, resp.StatusCode)
|
|
}
|
|
}
|
|
if n := up2.count(); n != 2 {
|
|
t.Fatalf("upstream saw %d requests, want the 2 allowed deletes: %v", n, up2.seen)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyWindowWaitsForQuietThenRefusesWrites(t *testing.T) {
|
|
_, gs, ms, up, clk := newMaintGate(t)
|
|
put := "/v2/user-uploads/s1/manifests/latest"
|
|
|
|
if resp := do(t, gs, http.MethodPut, put, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("write before any window = %d, want 201", resp.StatusCode)
|
|
}
|
|
// A write just finished: a push may be between two of its requests.
|
|
if code := post(t, ms, "/readonly?lease=600"); code != http.StatusConflict {
|
|
t.Fatalf("readonly right after a write = %d, want 409", code)
|
|
}
|
|
clk.advance(DefaultQuiet)
|
|
if code := post(t, ms, "/readonly?lease=600"); code != http.StatusOK {
|
|
t.Fatalf("readonly after %s of quiet = %d, want 200", DefaultQuiet, code)
|
|
}
|
|
|
|
before := up.count()
|
|
resp := do(t, gs, http.MethodPut, put, PrincipalPlatform, "plat-secret")
|
|
if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") == "" {
|
|
t.Fatalf("write during the window = %d Retry-After=%q, want 503 with Retry-After", resp.StatusCode, resp.Header.Get("Retry-After"))
|
|
}
|
|
if resp := do(t, gs, http.MethodGet, "/v2/felis/felis/manifests/b1", "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("read during the window = %d, want 200", resp.StatusCode)
|
|
}
|
|
if up.count() != before+1 {
|
|
t.Fatalf("the refused write reached the registry: %v", up.seen)
|
|
}
|
|
resp, err := http.Get(ms.URL + "/readonly")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("GET /readonly during the window = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
if code := post(t, ms, "/readwrite"); code != http.StatusOK {
|
|
t.Fatalf("readwrite = %d", code)
|
|
}
|
|
if resp := do(t, gs, http.MethodPut, put, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("write after the window = %d, want 201", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyWindowIsALease(t *testing.T) {
|
|
_, gs, ms, _, clk := newMaintGate(t)
|
|
if code := post(t, ms, "/readonly?lease=60"); code != http.StatusOK {
|
|
t.Fatalf("readonly on an idle gate = %d, want 200", code)
|
|
}
|
|
put := "/v2/user-uploads/s1/manifests/latest"
|
|
if resp := do(t, gs, http.MethodPut, put, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("write inside the lease = %d, want 503", resp.StatusCode)
|
|
}
|
|
// A GC sidecar that died mid-sweep never releases; the lease does.
|
|
clk.advance(61 * time.Second)
|
|
if resp := do(t, gs, http.MethodPut, put, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("write after the lease expired = %d, want 201", resp.StatusCode)
|
|
}
|
|
for _, bad := range []string{"0", "-5", "soon"} {
|
|
if code := post(t, ms, "/readonly?lease="+bad); code != http.StatusBadRequest {
|
|
t.Errorf("lease=%s = %d, want 400", bad, code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyWindowSurvivesAGateRestart(t *testing.T) {
|
|
dir := t.TempDir()
|
|
state := MaintStatePath(dir)
|
|
g, _, ms, _, clk := newMaintGate(t)
|
|
if err := g.SetMaintenanceState(state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code := post(t, ms, "/readonly?lease=600"); code != http.StatusOK {
|
|
t.Fatalf("readonly = %d", code)
|
|
}
|
|
if _, err := os.Stat(state); err != nil {
|
|
t.Fatalf("window not persisted: %v", err)
|
|
}
|
|
|
|
// The restarted gate reads the window back and keeps refusing writes.
|
|
g2, gs2, _, _, _ := newMaintGate(t)
|
|
g2.maint.now = clk.now
|
|
if err := g2.SetMaintenanceState(state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp := do(t, gs2, http.MethodPut, "/v2/user-uploads/s1/manifests/latest", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("write on the restarted gate = %d, want 503", resp.StatusCode)
|
|
}
|
|
|
|
if code := post(t, ms, "/readwrite"); code != http.StatusOK {
|
|
t.Fatalf("readwrite = %d", code)
|
|
}
|
|
if _, err := os.Stat(state); !os.IsNotExist(err) {
|
|
t.Fatalf("state file left after release: %v", err)
|
|
}
|
|
|
|
// An expired window in the file is ignored.
|
|
if err := os.WriteFile(state, []byte("1"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g3, gs3, _, _, _ := newMaintGate(t)
|
|
if err := g3.SetMaintenanceState(state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp := do(t, gs3, http.MethodPut, "/v2/user-uploads/s1/manifests/latest", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("write with an expired window on file = %d, want 201", resp.StatusCode)
|
|
}
|
|
if err := os.WriteFile(state, []byte("not-a-number"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := g3.SetMaintenanceState(state); err == nil || !strings.Contains(err.Error(), "maintenance state") {
|
|
t.Fatalf("a corrupt state file = %v, want an error naming it", err)
|
|
}
|
|
}
|
|
|
|
func TestManifestIndexListsUntaggedRevisions(t *testing.T) {
|
|
root := t.TempDir()
|
|
base := root + "/docker/registry/v2/repositories/felis/paper/_manifests"
|
|
hexA := strings.Repeat("a", 64)
|
|
hexB := strings.Repeat("b", 64)
|
|
hexGone := strings.Repeat("c", 64)
|
|
for _, h := range []string{hexA, hexB} {
|
|
if err := os.MkdirAll(base+"/revisions/sha256/"+h, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(base+"/revisions/sha256/"+h+"/link", []byte("sha256:"+h), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// A deleted manifest leaves its directory without a link.
|
|
if err := os.MkdirAll(base+"/revisions/sha256/"+hexGone, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(base+"/tags/demo/current", 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(base+"/tags/demo/current/link", []byte("sha256:"+hexB), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
if err := os.Chtimes(base+"/revisions/sha256/"+hexA+"/link", old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
up := httptest.NewServer(http.NotFoundHandler())
|
|
t.Cleanup(up.Close)
|
|
target, _ := url.Parse(up.URL)
|
|
g := New(target, nil, nil)
|
|
g.DataDir = root
|
|
srv := httptest.NewServer(g)
|
|
t.Cleanup(srv.Close)
|
|
|
|
resp, err := http.Get(srv.URL + IndexPathPrefix + "felis/paper")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var idx Index
|
|
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(idx.Revisions) != 2 || idx.Revisions[0].Digest != "sha256:"+hexA || !idx.Revisions[0].Pushed.Equal(old) {
|
|
t.Fatalf("revisions = %+v, want a (pushed %s) and b, without the deleted c", idx.Revisions, old)
|
|
}
|
|
if idx.Tags["demo"] != "sha256:"+hexB || len(idx.Tags) != 1 {
|
|
t.Fatalf("tags = %v, want demo -> b", idx.Tags)
|
|
}
|
|
|
|
// A repository that does not exist is empty; a traversal is refused.
|
|
if idx, err := ReadIndex(root, "user-uploads/none"); err != nil || len(idx.Revisions) != 0 || len(idx.Tags) != 0 {
|
|
t.Fatalf("missing repo = %+v, %v, want empty", idx, err)
|
|
}
|
|
for _, bad := range []string{"felis/../felis/paper", "felis/./paper", "Felis/paper", "felis//paper", "felis/paper/"} {
|
|
resp, err := http.Get(srv.URL + IndexPathPrefix + bad)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("index of %q = %d, want 400", bad, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A gate that just started grants no window before a quiet period has passed:
|
|
// the installer pushes right after the registry rolls out.
|
|
func TestReadOnlyWindowWaitsAfterStart(t *testing.T) {
|
|
up := httptest.NewServer(http.NotFoundHandler())
|
|
t.Cleanup(up.Close)
|
|
target, _ := url.Parse(up.URL)
|
|
g := New(target, nil, nil)
|
|
ms := httptest.NewServer(g.MaintHandler())
|
|
t.Cleanup(ms.Close)
|
|
if code := post(t, ms, "/readonly?lease=60"); code != http.StatusConflict {
|
|
t.Fatalf("read-only window right after start = %d, want 409", code)
|
|
}
|
|
g.SetQuiet(0)
|
|
if code := post(t, ms, "/readonly?lease=60"); code != http.StatusOK {
|
|
t.Fatalf("read-only window once quiet = %d, want 200", code)
|
|
}
|
|
}
|