Files
Evan Jarrett ab4a4ebf9d admin panel long running imrovements, billing fixes, ui cleanup
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.
2026-06-05 20:57:25 -05:00

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)
}
}