mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
DELETE /v2/<name>/manifests/<ref> answered UNSUPPORTED before ever
reaching the ATProto-backed stores: distribution v3.1.1's DeleteManifest
handler short-circuits unless app.deleteEnabled is set, which comes from
storage.delete.enabled. Set it (mirrored in the test harness). The
companion storage.EnableDelete option it appends only affects
distribution's built-in store, which RoutingRepository replaces, so it is
a no-op for us.
With the route reachable, make the stores do the right thing:
- ManifestStore.Delete purges the hold's per-layer, scan and image
config records on a detached context, since the DELETE handler
returns immediately and cancels the request context.
- TagStore.Untag resolves the digest before deleting the tag record,
then deletes the manifest if that was its last tag and it is not a
manifest list child, matching the web UI's delete-tag behavior so
deleting an only-tagged image doesn't orphan the manifest.
- cleanupUntaggedManifest becomes package-level over *RegistryContext
so both stores share one implementation.
- purgeOnHold moves out of handlers into pkg/appview/holdpurge so the
storage layer can call it: handlers already depends on storage via
middleware, so storage to handlers would be an import cycle.
- ProxyBlobStore.Delete returns distribution.ErrUnsupported, so the
always-registered blob DELETE route gives a clean OCI UNSUPPORTED
error instead of a generic 500. Layer bytes are reclaimed by the
hold's refcounted GC.
The cascade's still-tagged re-check pages through the tag records rather
than reading a single capped page. Tags for all of a user's repositories
share one collection, so one page is a per-account budget: past ~100 tags
a live tag fell off the end and the manifest was deleted while still
referenced. Incomplete enumeration now skips the delete, since an
orphaned manifest is recoverable and a deleted live one is not.
This also makes the over-quota delete grant added in 6e426dc load-bearing:
it hands out pull,delete tokens, which could not do anything while
distribution rejected every DELETE.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
431 lines
12 KiB
Go
431 lines
12 KiB
Go
package appview
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/registryauth"
|
|
)
|
|
|
|
func Test_getServiceName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseURL string
|
|
want string
|
|
}{
|
|
{
|
|
name: "localhost - use default",
|
|
baseURL: "http://localhost:5000",
|
|
want: "atcr.io",
|
|
},
|
|
{
|
|
name: "127.0.0.1 - use default",
|
|
baseURL: "http://127.0.0.1:5000",
|
|
want: "atcr.io",
|
|
},
|
|
{
|
|
name: "custom domain",
|
|
baseURL: "https://registry.example.com",
|
|
want: "registry.example.com",
|
|
},
|
|
{
|
|
name: "domain with port",
|
|
baseURL: "https://registry.example.com:443",
|
|
want: "registry.example.com",
|
|
},
|
|
{
|
|
name: "invalid URL - use default",
|
|
baseURL: "://invalid",
|
|
want: "atcr.io",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := getServiceName(tt.baseURL)
|
|
if got != tt.want {
|
|
t.Errorf("getServiceName() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildStorageConfig(t *testing.T) {
|
|
got := buildStorageConfig()
|
|
|
|
// Verify inmemory driver exists
|
|
if _, ok := got["inmemory"]; !ok {
|
|
t.Error("buildStorageConfig() missing inmemory driver")
|
|
}
|
|
|
|
// Verify maintenance config
|
|
maintenance, ok := got["maintenance"]
|
|
if !ok {
|
|
t.Fatal("buildStorageConfig() missing maintenance config")
|
|
}
|
|
|
|
uploadPurging, ok := maintenance["uploadpurging"]
|
|
if !ok {
|
|
t.Fatal("buildStorageConfig() missing uploadpurging config")
|
|
}
|
|
|
|
// Verify uploadpurging is map[any]any (for distribution validation)
|
|
purging, ok := uploadPurging.(map[any]any)
|
|
if !ok {
|
|
t.Fatalf("uploadpurging is %T, want map[any]any", uploadPurging)
|
|
}
|
|
|
|
if purging["enabled"] != false {
|
|
t.Error("uploadpurging enabled should be false")
|
|
}
|
|
|
|
// Manifest deletion must be enabled: distribution v3.1.1's DeleteManifest
|
|
// handler short-circuits with UNSUPPORTED unless storage.delete.enabled is
|
|
// true, which is what lets `skopeo delete` / OCI DELETE reach our stores.
|
|
deleteCfg, ok := got["delete"]
|
|
if !ok {
|
|
t.Fatal("buildStorageConfig() missing delete config — OCI DELETE would return UNSUPPORTED")
|
|
}
|
|
if deleteCfg["enabled"] != true {
|
|
t.Errorf("storage.delete.enabled = %v, want true", deleteCfg["enabled"])
|
|
}
|
|
}
|
|
|
|
func TestBuildMiddlewareConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
defaultHoldDID string
|
|
baseURL string
|
|
testMode bool
|
|
wantTestMode bool
|
|
}{
|
|
{
|
|
name: "normal mode",
|
|
defaultHoldDID: "did:web:hold01.atcr.io",
|
|
baseURL: "https://atcr.io",
|
|
testMode: false,
|
|
wantTestMode: false,
|
|
},
|
|
{
|
|
name: "test mode enabled",
|
|
defaultHoldDID: "did:web:hold01.atcr.io",
|
|
baseURL: "https://atcr.io",
|
|
testMode: true,
|
|
wantTestMode: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := buildMiddlewareConfig(tt.defaultHoldDID, tt.baseURL, tt.testMode)
|
|
|
|
registryMW, ok := got["registry"]
|
|
if !ok {
|
|
t.Fatal("buildMiddlewareConfig() missing registry middleware")
|
|
}
|
|
|
|
if len(registryMW) != 1 {
|
|
t.Fatalf("buildMiddlewareConfig() registry middleware count = %v, want 1", len(registryMW))
|
|
}
|
|
|
|
mw := registryMW[0]
|
|
if mw.Name != "atproto-resolver" {
|
|
t.Errorf("middleware name = %v, want atproto-resolver", mw.Name)
|
|
}
|
|
|
|
if mw.Options["default_hold_did"] != tt.defaultHoldDID {
|
|
t.Errorf("default_hold_did = %v, want %v", mw.Options["default_hold_did"], tt.defaultHoldDID)
|
|
}
|
|
|
|
if mw.Options["base_url"] != tt.baseURL {
|
|
t.Errorf("base_url = %v, want %v", mw.Options["base_url"], tt.baseURL)
|
|
}
|
|
|
|
if mw.Options["test_mode"] != tt.wantTestMode {
|
|
t.Errorf("test_mode = %v, want %v", mw.Options["test_mode"], tt.wantTestMode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildHealthConfig(t *testing.T) {
|
|
got := buildHealthConfig()
|
|
|
|
if !got.StorageDriver.Enabled {
|
|
t.Error("buildHealthConfig().StorageDriver.Enabled = false, want true")
|
|
}
|
|
|
|
if got.StorageDriver.Interval.Seconds() != 10 {
|
|
t.Errorf("buildHealthConfig().StorageDriver.Interval = %v, want 10s", got.StorageDriver.Interval)
|
|
}
|
|
|
|
if got.StorageDriver.Threshold != 3 {
|
|
t.Errorf("buildHealthConfig().StorageDriver.Threshold = %v, want 3", got.StorageDriver.Threshold)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envHoldDID string
|
|
setHoldDID bool
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "valid config",
|
|
envHoldDID: "did:web:hold01.atcr.io",
|
|
setHoldDID: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "missing default hold DID",
|
|
setHoldDID: false,
|
|
wantError: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.setHoldDID {
|
|
t.Setenv("ATCR_SERVER_MANAGED_HOLDS", tt.envHoldDID)
|
|
} else {
|
|
os.Unsetenv("ATCR_SERVER_MANAGED_HOLDS")
|
|
}
|
|
|
|
// Clear other env vars to use defaults
|
|
os.Unsetenv("ATCR_SERVER_BASE_URL")
|
|
|
|
got, err := LoadConfig("")
|
|
if (err != nil) != tt.wantError {
|
|
t.Errorf("LoadConfig() error = %v, wantError %v", err, tt.wantError)
|
|
return
|
|
}
|
|
|
|
if tt.wantError {
|
|
return
|
|
}
|
|
|
|
// Verify config structure
|
|
if got.Version != "0.1" {
|
|
t.Errorf("version = %v, want 0.1", got.Version)
|
|
}
|
|
|
|
if got.LogLevel != "info" {
|
|
t.Errorf("log level = %v, want info", got.LogLevel)
|
|
}
|
|
|
|
if got.Server.Addr != ":5000" {
|
|
t.Errorf("HTTP addr = %v, want :5000", got.Server.Addr)
|
|
}
|
|
|
|
if got.Server.PrimaryHoldDID() != tt.envHoldDID {
|
|
t.Errorf("primary hold DID = %v, want %v", got.Server.PrimaryHoldDID(), tt.envHoldDID)
|
|
}
|
|
|
|
if got.UI.DatabasePath != "/var/lib/atcr/ui.db" {
|
|
t.Errorf("UI database path = %v, want /var/lib/atcr/ui.db", got.UI.DatabasePath)
|
|
}
|
|
|
|
if got.Health.CacheTTL != 15*time.Minute {
|
|
t.Errorf("health cache TTL = %v, want 15m", got.Health.CacheTTL)
|
|
}
|
|
|
|
if len(got.Jetstream.URLs) != 4 || got.Jetstream.URLs[0] != "wss://jetstream2.us-west.bsky.network/subscribe" {
|
|
t.Errorf("jetstream URLs = %v, want 4 endpoints starting with us-west-2", got.Jetstream.URLs)
|
|
}
|
|
|
|
if len(got.Jetstream.RelayEndpoints) != 2 || got.Jetstream.RelayEndpoints[0] != "https://relay1.us-east.bsky.network" {
|
|
t.Errorf("jetstream RelayEndpoints = %v, want 2 endpoints starting with us-east", got.Jetstream.RelayEndpoints)
|
|
}
|
|
|
|
// Verify distribution config was built
|
|
if got.Distribution == nil {
|
|
t.Error("distribution config is nil")
|
|
}
|
|
|
|
if _, ok := got.Distribution.Storage["inmemory"]; !ok {
|
|
t.Error("distribution storage missing inmemory driver")
|
|
}
|
|
|
|
if _, ok := got.Distribution.Middleware["registry"]; !ok {
|
|
t.Error("distribution middleware missing registry")
|
|
}
|
|
|
|
params, ok := got.Distribution.Auth[registryauth.AuthType]
|
|
if !ok {
|
|
t.Fatalf("distribution auth missing %q config", registryauth.AuthType)
|
|
}
|
|
// The controller builds one delegate per entry, so this list is
|
|
// what decides which domains get their own audience. A nil check
|
|
// would pass on garbage.
|
|
services, ok := params["services"].([]string)
|
|
if !ok {
|
|
t.Fatalf("distribution auth services = %T, want []string", params["services"])
|
|
}
|
|
if len(services) == 0 {
|
|
t.Fatal("distribution auth services is empty; every /v2/ request would fail to authorize")
|
|
}
|
|
// The issuer must be one of the audiences the controller accepts,
|
|
// otherwise the unknown-host fallback mints tokens no delegate
|
|
// honours.
|
|
if issuer := params["issuer"]; issuer != services[0] {
|
|
t.Errorf("issuer = %v, want primary service %q", issuer, services[0])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfig(t *testing.T) {
|
|
cfg := DefaultConfig()
|
|
|
|
if cfg.Version != "0.1" {
|
|
t.Errorf("DefaultConfig().Version = %q, want \"0.1\"", cfg.Version)
|
|
}
|
|
if cfg.LogLevel != "info" {
|
|
t.Errorf("DefaultConfig().LogLevel = %q, want \"info\"", cfg.LogLevel)
|
|
}
|
|
if cfg.Server.Addr != ":5000" {
|
|
t.Errorf("DefaultConfig().Server.Addr = %q, want \":5000\"", cfg.Server.Addr)
|
|
}
|
|
if cfg.UI.DatabasePath != "/var/lib/atcr/ui.db" {
|
|
t.Errorf("DefaultConfig().UI.DatabasePath = %q, want \"/var/lib/atcr/ui.db\"", cfg.UI.DatabasePath)
|
|
}
|
|
if cfg.Health.CacheTTL != 15*time.Minute {
|
|
t.Errorf("DefaultConfig().Health.CacheTTL = %v, want 15m", cfg.Health.CacheTTL)
|
|
}
|
|
if cfg.Server.ClientName != "AT Container Registry" {
|
|
t.Errorf("DefaultConfig().Server.ClientName = %q, want \"AT Container Registry\"", cfg.Server.ClientName)
|
|
}
|
|
}
|
|
|
|
func TestExampleYAML(t *testing.T) {
|
|
out, err := ExampleYAML()
|
|
if err != nil {
|
|
t.Fatalf("ExampleYAML() error: %v", err)
|
|
}
|
|
|
|
s := string(out)
|
|
|
|
// Should contain the title
|
|
if !strings.Contains(s, "ATCR AppView Configuration") {
|
|
t.Error("expected title in YAML output")
|
|
}
|
|
|
|
// Should contain key fields with defaults
|
|
if !strings.Contains(s, "addr:") {
|
|
t.Error("expected addr field in YAML output")
|
|
}
|
|
if !strings.Contains(s, "database_path:") {
|
|
t.Error("expected database_path field in YAML output")
|
|
}
|
|
|
|
// Should contain comments
|
|
if !strings.Contains(s, "# Listen address") {
|
|
t.Error("expected comment for addr field")
|
|
}
|
|
if !strings.Contains(s, "# Log level") {
|
|
t.Error("expected comment for log_level field")
|
|
}
|
|
}
|
|
|
|
func Test_deriveServices(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseURL string
|
|
registryDomains []string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "no registry domains falls back to base url host",
|
|
baseURL: "https://atcr.io",
|
|
registryDomains: nil,
|
|
want: []string{"atcr.io"},
|
|
},
|
|
{
|
|
name: "registry domains in order, first is primary",
|
|
baseURL: "https://seamark.dev",
|
|
registryDomains: []string{"buoy.cr", "seamark.cr", "atcr.io"},
|
|
want: []string{"buoy.cr", "seamark.cr", "atcr.io"},
|
|
},
|
|
{
|
|
name: "ports stripped to match port-stripped request hosts",
|
|
baseURL: "http://127.0.0.1:5000",
|
|
registryDomains: []string{"127.0.0.1:5000", "atcr.io"},
|
|
want: []string{"127.0.0.1", "atcr.io"},
|
|
},
|
|
{
|
|
name: "normalized and deduplicated",
|
|
baseURL: "https://seamark.dev",
|
|
registryDomains: []string{"BUOY.cr", "buoy.cr:443", " atcr.io "},
|
|
want: []string{"buoy.cr", "atcr.io"},
|
|
},
|
|
{
|
|
name: "blank entries dropped",
|
|
baseURL: "https://seamark.dev",
|
|
registryDomains: []string{"", " ", "atcr.io"},
|
|
want: []string{"atcr.io"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &Config{}
|
|
cfg.Server.BaseURL = tt.baseURL
|
|
cfg.Server.RegistryDomains = tt.registryDomains
|
|
|
|
got := deriveServices(cfg)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("deriveServices() = %v, want %v", got, tt.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.want[i] {
|
|
t.Fatalf("deriveServices() = %v, want %v", got, tt.want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// DomainRoutingMiddleware matches a port-stripped host, so it is handed
|
|
// cfg.Auth.Services rather than the raw registry_domains. A domain configured
|
|
// with a port has to route to /v2/ rather than being redirected to the UI.
|
|
func TestDomainRoutingMiddleware_UsesNormalizedDomains(t *testing.T) {
|
|
handler := DomainRoutingMiddleware(
|
|
deriveServices(&Config{Server: ServerConfig{
|
|
BaseURL: "https://seamark.dev",
|
|
RegistryDomains: []string{"127.0.0.1:5000", "ATCR.io"},
|
|
}}),
|
|
"https://seamark.dev",
|
|
)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusTeapot) // reached the registry
|
|
}))
|
|
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
want int
|
|
}{
|
|
{"configured with a port, requested without", "127.0.0.1", http.StatusTeapot},
|
|
{"configured with a port, requested with", "127.0.0.1:5000", http.StatusTeapot},
|
|
{"configured uppercase", "atcr.io", http.StatusTeapot},
|
|
{"unknown host still redirects", "example.com", http.StatusTemporaryRedirect},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
|
|
req.Host = tt.host
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.want {
|
|
t.Errorf("host %q: status = %d, want %d", tt.host, rec.Code, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|