mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
1. Multiple registry domains + per-user domain preference
The biggest feature. The appview can serve several registry domains (e.g. buoy.cr, atcr.io),
and users can now pick which one shows up in their pull/push commands.
- Lexicon/record: adds registryDomain (and documents ociClient)
to the sailor profile (lexicons/.../profile.json, pkg/atproto/lexicon.go).
- DB: new registry_domain column on users (schema.sql + migration 0027),
with GetUserByDID/Handle reads, UpdateUserRegistryDomain writer,
and Jetstream caching it on profile updates (writes unconditionally so clearing propagates).
- UI/handlers: new UpdateRegistryDomainHandler + /api/profile/registry-domain route,
a <select> in the user settings panel (only shown when >1 domain configured), and resolveRegistryURL()
which falls back to the primary domain if the user's pref is stale/removed. Tests added for all of it.
2. default_hold_did removed → first managed_holds entry is the default
Consolidates two overlapping config fields into one. ServerConfig.DefaultHoldDID is gone;
PrimaryHoldDID() now returns managed_holds[0]. managed_holds is now REQUIRED.
Updated in config, validation, server wiring, test harness, example YAML, and the deploy template.
3. Admin long-running operations → generic background-job framework
New pkg/hold/admin/jobs.go introduces a reusable startJob/jobRegistry pattern
(a detached context.Background() job + a /admin/api/jobs/{key}/status polling endpoint).
This replaces the bespoke scan-backfill goroutine state machine, and now also wraps crew tier remap and crew import
all three previously looped synchronously on the request context and got 504'd/cancelled mid-run by the reverse proxy.
Forms switched from POST-redirect to htmx fragments (job_progress.html, job_result.html, crew_import_results.html)
the old crew_import_results.html page and scan_backfill_progress.html partial were deleted.
This is also captured as a new rule in CLAUDE.md.
4. Cascade-delete manifest on last-tag deletion
DeleteTagHandler now, after removing the last tag pointing to a digest, cascade-deletes the manifest itself
(PDS + DB + hold blob purge) — but only if it's not a child of a manifest list (multi-arch parent).
New GetTagDigest and ShouldCascadeDeleteManifest queries back it, plus cascade_delete_test.go.
Also switches tag rkey computation to the atproto.RepositoryTagToRKey helper.
5. Billing simplification
Drops the OwnerBadge config option (hold-owner supporter badge).
The user-profile template no longer special-cases an "owner" badge value (only "Captain").
Example tiers renamed to the nautical scheme (deckhand/bosun/quartermaster).
6. Build/deploy: go generate always runs via Make
make generate is now a phony target that always runs go generate ./... (regenerating cbor_gen, icon sprites, etc.),
and build-trixie depends on it. The deploy tooling (provision.go/update.go)
drops its own runGenerate calls since the Makefile handles it.
7. New cmd/firehose-tap tool (untracked)
A standalone CLI that subscribes to a com.atproto.sync.subscribeRepos endpoint and pretty-prints events,
with emphasis on Sync 1.1 compliance fields (per-op prev CIDs, commit prevData) and a --validate CI mode.
Fits with the recent "more sync1.1 compliant" commit.
200 lines
5.3 KiB
Go
200 lines
5.3 KiB
Go
package db
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// seedCascadeFixture inserts a user and a single manifest. Returns the
|
|
// manifest's row id so callers can attach references (for the multi-arch case).
|
|
func seedCascadeFixture(t *testing.T, db *sql.DB, didStr, repo, digest string) int64 {
|
|
t.Helper()
|
|
|
|
user := &User{
|
|
DID: didStr,
|
|
Handle: "tester.example.com",
|
|
PDSEndpoint: "https://test.pds.example.com",
|
|
LastSeen: time.Now(),
|
|
}
|
|
if err := UpsertUser(db, user); err != nil {
|
|
t.Fatalf("UpsertUser: %v", err)
|
|
}
|
|
|
|
id, err := InsertManifest(db, &Manifest{
|
|
DID: didStr,
|
|
Repository: repo,
|
|
Digest: digest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestGetTagDigest_ReturnsDigestForKnownTag(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:tagdigest"
|
|
const repo = "myapp"
|
|
const digest = "sha256:aaa"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "latest",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
got, err := GetTagDigest(db, did, repo, "latest")
|
|
if err != nil {
|
|
t.Fatalf("GetTagDigest: %v", err)
|
|
}
|
|
if got != digest {
|
|
t.Errorf("digest mismatch: got %q want %q", got, digest)
|
|
}
|
|
}
|
|
|
|
func TestGetTagDigest_UnknownTagReturnsErrNoRows(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
seedCascadeFixture(t, db, "did:plc:tagdigest2", "myapp", "sha256:bbb")
|
|
|
|
_, err = GetTagDigest(db, "did:plc:tagdigest2", "myapp", "does-not-exist")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Errorf("expected sql.ErrNoRows for unknown tag, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_LastTagAndNoParent: the common case —
|
|
// digest has no remaining tags and is not referenced by any manifest list.
|
|
// Cascade should fire.
|
|
func TestShouldCascadeDeleteManifest_LastTagAndNoParent(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade1"
|
|
const repo = "myapp"
|
|
const digest = "sha256:lonely"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
// No tags pointing to this digest, no manifest_references entries.
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, repo, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("expected cascade=true when manifest is untagged and unreferenced")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_RemainingTagBlocks: another tag still
|
|
// points to this digest → keep the manifest alive.
|
|
func TestShouldCascadeDeleteManifest_RemainingTagBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade2"
|
|
const repo = "myapp"
|
|
const digest = "sha256:shared"
|
|
|
|
seedCascadeFixture(t, db, did, repo, digest)
|
|
|
|
if err := UpsertTag(db, &Tag{
|
|
DID: did,
|
|
Repository: repo,
|
|
Tag: "v1",
|
|
Digest: digest,
|
|
CreatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("UpsertTag: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, repo, digest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when another tag still points to the digest")
|
|
}
|
|
}
|
|
|
|
// TestShouldCascadeDeleteManifest_MultiArchChildBlocks: the digest is a child
|
|
// of a manifest list (multi-arch parent). Even with no tags, deleting it
|
|
// would orphan the parent's reference, so we must NOT cascade.
|
|
func TestShouldCascadeDeleteManifest_MultiArchChildBlocks(t *testing.T) {
|
|
db, err := InitDB(":memory:", LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
const did = "did:plc:cascade3"
|
|
const repo = "myapp"
|
|
const childDigest = "sha256:amd64child"
|
|
const parentDigest = "sha256:multiarchparent"
|
|
|
|
// Insert the child manifest fixture.
|
|
seedCascadeFixture(t, db, did, repo, childDigest)
|
|
|
|
// Insert a separate parent (manifest list) and attach a manifest_reference
|
|
// from parent → child.
|
|
parentID, err := InsertManifest(db, &Manifest{
|
|
DID: did,
|
|
Repository: repo,
|
|
Digest: parentDigest,
|
|
HoldEndpoint: "did:web:hold.example.com",
|
|
SchemaVersion: 2,
|
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
|
CreatedAt: time.Now(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("InsertManifest(parent): %v", err)
|
|
}
|
|
|
|
if err := InsertManifestReference(db, &ManifestReference{
|
|
ManifestID: parentID,
|
|
Digest: childDigest,
|
|
Size: 1234,
|
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
PlatformArchitecture: "amd64",
|
|
PlatformOS: "linux",
|
|
ReferenceIndex: 0,
|
|
}); err != nil {
|
|
t.Fatalf("InsertManifestReference: %v", err)
|
|
}
|
|
|
|
ok, err := ShouldCascadeDeleteManifest(db, did, repo, childDigest)
|
|
if err != nil {
|
|
t.Fatalf("ShouldCascadeDeleteManifest: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("expected cascade=false when digest is a child of a manifest list")
|
|
}
|
|
}
|