mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-19 16: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.
91 lines
3.0 KiB
Go
91 lines
3.0 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"atcr.io/pkg/hold/pds"
|
||
)
|
||
|
||
// handleScanBackfill kicks off a scan-status backfill as a background job and
|
||
// returns a progress fragment that polls /admin/api/jobs/scan-backfill/status.
|
||
// Idempotent — clicking again while a run is in flight just shows the current
|
||
// progress (startJob returns false).
|
||
//
|
||
// Why background: reverse proxies typically cap upstream HTTP timeouts at
|
||
// 10–60s, which would cancel a synchronous request mid-loop. The job runs under
|
||
// its own detached context (see jobs.go), so it survives the request ending.
|
||
//
|
||
// JSON callers (Accept: application/json) get a synchronous run instead —
|
||
// useful for curl + scripting.
|
||
func (ui *AdminUI) handleScanBackfill(w http.ResponseWriter, r *http.Request) {
|
||
session := getSessionFromContext(r.Context())
|
||
wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json")
|
||
|
||
if wantJSON {
|
||
// Synchronous JSON path — caller decides their own timeout.
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||
defer cancel()
|
||
res, err := ui.pds.BackfillScanStatus(ctx, scanBackfillLogger, nil)
|
||
if err != nil {
|
||
slog.Error("scan-backfill failed", "by", session.DID, "error", err)
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
slog.Info("scan-status backfill complete (sync)",
|
||
"by", session.DID,
|
||
"scanned", res.Scanned,
|
||
"already_tagged", res.AlreadyTagged,
|
||
"marked_skipped", res.MarkedSkipped,
|
||
"marked_failed", res.MarkedFailed,
|
||
"rewritten", res.Rewritten,
|
||
)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(res)
|
||
return
|
||
}
|
||
|
||
started := ui.startJob("scan-backfill", "Backfilling scan records",
|
||
"partials/scan_backfill_result.html", 10*time.Minute,
|
||
func(ctx context.Context, progress func(jobProgress)) (any, error) {
|
||
res, err := ui.pds.BackfillScanStatus(ctx, scanBackfillLogger, func(snap *pds.ScanBackfillResult) {
|
||
progress(jobProgress{
|
||
Done: snap.Scanned,
|
||
Message: fmt.Sprintf("Scanned %d · rewrites %d (%d skipped, %d failed)",
|
||
snap.Scanned, snap.Rewritten, snap.MarkedSkipped, snap.MarkedFailed),
|
||
})
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
slog.Info("scan-status backfill complete",
|
||
"scanned", res.Scanned,
|
||
"already_tagged", res.AlreadyTagged,
|
||
"marked_skipped", res.MarkedSkipped,
|
||
"marked_failed", res.MarkedFailed,
|
||
"rewritten", res.Rewritten,
|
||
)
|
||
return res, nil
|
||
})
|
||
|
||
if started {
|
||
slog.Info("scan-status backfill started via admin panel", "by", session.DID)
|
||
} else {
|
||
slog.Debug("scan-status backfill already in progress; returning current state")
|
||
}
|
||
|
||
ui.renderTemplate(w, "partials/job_progress.html", ui.jobSnapshot("scan-backfill"))
|
||
}
|
||
|
||
// scanBackfillLogger formats the printf-style messages from BackfillScanStatus
|
||
// into a single slog message — slog's variadic args are key/value pairs, not
|
||
// printf operands.
|
||
func scanBackfillLogger(format string, args ...any) {
|
||
slog.Warn("scan-backfill: " + fmt.Sprintf(format, args...))
|
||
}
|