Files
Felis/internal/registrygate/maint_test.go
T

279 lines
9.5 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
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)
}
}
}