mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 19:54:15 +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.
310 lines
9.6 KiB
Go
310 lines
9.6 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// waitDone polls until the job is finished (Started && !Running). Because the
|
|
// goroutine sets running=false in a defer that runs AFTER the result is stored,
|
|
// observing !Running guarantees the result/error is visible — no race.
|
|
func waitDone(t *testing.T, ui *AdminUI, key string) jobSnapshot {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
snap := ui.jobSnapshot(key)
|
|
if snap.Started && !snap.Running {
|
|
return snap
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("job %q did not finish within deadline", key)
|
|
return jobSnapshot{}
|
|
}
|
|
|
|
func TestStartJob_HappyPath(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
release := make(chan struct{})
|
|
|
|
started := ui.startJob("k", "Doing thing", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-release
|
|
return jobResult{Message: "all done"}, nil
|
|
})
|
|
if !started {
|
|
t.Fatal("startJob returned false for a fresh key")
|
|
}
|
|
|
|
// While the fn is blocked, the job is observably running.
|
|
snap := ui.jobSnapshot("k")
|
|
if !snap.Started || !snap.Running {
|
|
t.Fatalf("expected started+running, got started=%v running=%v", snap.Started, snap.Running)
|
|
}
|
|
if snap.Title != "Doing thing" {
|
|
t.Errorf("title = %q, want %q", snap.Title, "Doing thing")
|
|
}
|
|
|
|
close(release)
|
|
snap = waitDone(t, ui, "k")
|
|
|
|
if snap.Running {
|
|
t.Error("job still running after completion")
|
|
}
|
|
if snap.Error != "" {
|
|
t.Errorf("unexpected error: %q", snap.Error)
|
|
}
|
|
res, ok := snap.Result.(jobResult)
|
|
if !ok {
|
|
t.Fatalf("result type = %T, want jobResult", snap.Result)
|
|
}
|
|
if res.Message != "all done" {
|
|
t.Errorf("result message = %q, want %q", res.Message, "all done")
|
|
}
|
|
}
|
|
|
|
func TestStartJob_DoubleStartGuard(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
release := make(chan struct{})
|
|
|
|
if !ui.startJob("k", "first", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-release
|
|
return jobResult{Message: "first"}, nil
|
|
}) {
|
|
t.Fatal("first startJob returned false")
|
|
}
|
|
|
|
// Second start while the first is in flight must be rejected and must not
|
|
// clobber the running job's title.
|
|
if ui.startJob("k", "second", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
return jobResult{Message: "second"}, nil
|
|
}) {
|
|
t.Fatal("second startJob returned true while a run was in flight")
|
|
}
|
|
if snap := ui.jobSnapshot("k"); snap.Title != "first" {
|
|
t.Errorf("running job title clobbered: %q", snap.Title)
|
|
}
|
|
|
|
close(release)
|
|
snap := waitDone(t, ui, "k")
|
|
if res := snap.Result.(jobResult); res.Message != "first" {
|
|
t.Errorf("result message = %q, want from first run", res.Message)
|
|
}
|
|
}
|
|
|
|
func TestStartJob_RerunAfterCompletion(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
|
|
ui.startJob("k", "run1", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
progress(jobProgress{Done: 5, Total: 5})
|
|
return jobResult{Message: "run1"}, nil
|
|
})
|
|
waitDone(t, ui, "k")
|
|
|
|
// A second start with the same key succeeds and resets progress/result/err.
|
|
release := make(chan struct{})
|
|
if !ui.startJob("k", "run2", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-release
|
|
return jobResult{Message: "run2"}, nil
|
|
}) {
|
|
t.Fatal("re-run startJob returned false after completion")
|
|
}
|
|
snap := ui.jobSnapshot("k")
|
|
if snap.Progress != (jobProgress{}) {
|
|
t.Errorf("progress not reset on re-run: %+v", snap.Progress)
|
|
}
|
|
if snap.Result != nil {
|
|
t.Errorf("result not reset on re-run: %v", snap.Result)
|
|
}
|
|
close(release)
|
|
waitDone(t, ui, "k")
|
|
}
|
|
|
|
func TestStartJob_ProgressPublishingIsCopied(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
publishedOne := make(chan struct{})
|
|
proceed := make(chan struct{})
|
|
publishedTwo := make(chan struct{})
|
|
release := make(chan struct{})
|
|
|
|
ui.startJob("k", "t", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
progress(jobProgress{Done: 1, Total: 3, Message: "one"})
|
|
publishedOne <- struct{}{}
|
|
<-proceed // wait until the test has snapshotted the first value
|
|
progress(jobProgress{Done: 2, Total: 3, Message: "two"})
|
|
publishedTwo <- struct{}{}
|
|
<-release
|
|
return jobResult{}, nil
|
|
})
|
|
|
|
// Synchronize on the first publish, then snapshot it (fn is parked on proceed).
|
|
<-publishedOne
|
|
first := ui.jobSnapshot("k")
|
|
if first.Progress.Done != 1 || first.Progress.Message != "one" {
|
|
t.Fatalf("first progress = %+v", first.Progress)
|
|
}
|
|
|
|
// Let the job publish again; the earlier snapshot must be an independent copy.
|
|
proceed <- struct{}{}
|
|
<-publishedTwo
|
|
if first.Progress.Done != 1 || first.Progress.Message != "one" {
|
|
t.Errorf("earlier snapshot mutated: %+v", first.Progress)
|
|
}
|
|
second := ui.jobSnapshot("k")
|
|
if second.Progress.Done != 2 || second.Progress.Message != "two" {
|
|
t.Errorf("second progress = %+v", second.Progress)
|
|
}
|
|
|
|
close(release)
|
|
waitDone(t, ui, "k")
|
|
}
|
|
|
|
func TestStartJob_ErrorPath(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
ui.startJob("k", "t", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
return nil, errors.New("boom")
|
|
})
|
|
snap := waitDone(t, ui, "k")
|
|
if snap.Error != "boom" {
|
|
t.Errorf("error = %q, want %q", snap.Error, "boom")
|
|
}
|
|
if snap.Result != nil {
|
|
t.Errorf("result = %v, want nil on error", snap.Result)
|
|
}
|
|
}
|
|
|
|
func TestStartJob_RespectsTimeout(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
ui.startJob("k", "t", "partials/job_result.html", 10*time.Millisecond,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-ctx.Done() // detached timeout fires here, not the request deadline
|
|
return nil, ctx.Err()
|
|
})
|
|
snap := waitDone(t, ui, "k")
|
|
if snap.Error != context.DeadlineExceeded.Error() {
|
|
t.Errorf("error = %q, want %q", snap.Error, context.DeadlineExceeded.Error())
|
|
}
|
|
}
|
|
|
|
func TestJobSnapshot_UnknownKey(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
snap := ui.jobSnapshot("never-run")
|
|
if snap.Started || snap.Running {
|
|
t.Errorf("unknown key reported started=%v running=%v", snap.Started, snap.Running)
|
|
}
|
|
}
|
|
|
|
func TestStartJob_DistinctKeysAreIndependent(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
releaseA := make(chan struct{})
|
|
|
|
ui.startJob("a", "A", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-releaseA
|
|
return jobResult{Message: "a"}, nil
|
|
})
|
|
ui.startJob("b", "B", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
return jobResult{Message: "b"}, nil
|
|
})
|
|
|
|
// b finishes while a is still blocked.
|
|
bSnap := waitDone(t, ui, "b")
|
|
if bSnap.Result.(jobResult).Message != "b" {
|
|
t.Errorf("b result = %v", bSnap.Result)
|
|
}
|
|
if aSnap := ui.jobSnapshot("a"); !aSnap.Running {
|
|
t.Error("job a should still be running while b finished")
|
|
}
|
|
|
|
close(releaseA)
|
|
aSnap := waitDone(t, ui, "a")
|
|
if aSnap.Result.(jobResult).Message != "a" {
|
|
t.Errorf("a result = %v", aSnap.Result)
|
|
}
|
|
}
|
|
|
|
// TestStartJob_OutlivesCaller is the core regression: the work must complete
|
|
// even after the calling scope (the HTTP handler) has returned.
|
|
func TestStartJob_OutlivesCaller(t *testing.T) {
|
|
ui := &AdminUI{}
|
|
|
|
// kickoff mimics a handler that returns immediately after starting the job.
|
|
kickoff := func() {
|
|
ui.startJob("k", "t", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
time.Sleep(20 * time.Millisecond)
|
|
return jobResult{Message: "finished after handler returned"}, nil
|
|
})
|
|
}
|
|
kickoff() // handler scope ends here
|
|
|
|
snap := waitDone(t, ui, "k")
|
|
if snap.Result.(jobResult).Message != "finished after handler returned" {
|
|
t.Errorf("job did not complete after caller returned: %v", snap.Result)
|
|
}
|
|
}
|
|
|
|
func TestHandleJobStatus_Dispatch(t *testing.T) {
|
|
tmpls, err := parseTemplates()
|
|
if err != nil {
|
|
t.Fatalf("parseTemplates: %v", err)
|
|
}
|
|
ui := &AdminUI{templates: tmpls}
|
|
|
|
status := func(key string) (int, string) {
|
|
req := httptest.NewRequest(http.MethodGet, "/admin/api/jobs/"+key+"/status", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("key", key)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
rec := httptest.NewRecorder()
|
|
ui.handleJobStatus(rec, req)
|
|
return rec.Code, rec.Body.String()
|
|
}
|
|
|
|
// not-started -> empty body
|
|
if code, body := status("nope"); code != http.StatusOK || strings.TrimSpace(body) != "" {
|
|
t.Errorf("not-started: code=%d body=%q", code, body)
|
|
}
|
|
|
|
// running -> progress fragment (polls the status endpoint)
|
|
release := make(chan struct{})
|
|
ui.startJob("running", "Working", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
<-release
|
|
return jobResult{Message: "done"}, nil
|
|
})
|
|
if _, body := status("running"); !strings.Contains(body, "/admin/api/jobs/running/status") {
|
|
t.Errorf("running fragment missing poll URL: %q", body)
|
|
}
|
|
close(release)
|
|
waitDone(t, ui, "running")
|
|
|
|
// done -> result template
|
|
if _, body := status("running"); !strings.Contains(body, "done") {
|
|
t.Errorf("result fragment missing message: %q", body)
|
|
}
|
|
|
|
// error -> gc_error fragment
|
|
ui.startJob("failed", "Working", "partials/job_result.html", time.Minute,
|
|
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
|
return nil, errors.New("kaput")
|
|
})
|
|
waitDone(t, ui, "failed")
|
|
if _, body := status("failed"); !strings.Contains(body, "alert-error") || !strings.Contains(body, "kaput") {
|
|
t.Errorf("error fragment wrong: %q", body)
|
|
}
|
|
}
|