mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-18 16:24:16 +00:00
A user who deletes a manifest record directly on their PDS was invisible
to the hold. purgeManifest only fires on appview-driven deletes, and the
GC orphan sweep only walked io.atcr.hold.layer, so scan and image config
records survived forever and the user's storage number never dropped.
analyzeRecords now also walks io.atcr.hold.scan and
io.atcr.hold.image.config, applying the same orphan test the layer sweep
uses: a record dies only when its owning PDS was reachable and
demonstrably lacks the manifest. Unreachable PDS, unparseable URI,
unparseable timestamp, and immature records all keep.
Splitting the grace period was required to make this useful, not
cosmetic. Records only need to outlast a push (blobs and layer records
are written before the manifest reaches the user's PDS), so they age out
at 24h. Blobs stay on the 7 day window, but since records now age out
faster than the blobs they name, a record can no longer serve as its
blob's clock. Blob age comes from S3 LastModified instead, which
WalkBlobs already received from ListObjectsV2 and was discarding.
Net effect: pruning an old manifest frees the user's storage on the next
nightly run rather than never, while the bytes are still reclaimed on
the same best-effort schedule as before.
DeleteManifestAuxRecord is deliberately narrow, accepting only the scan
and image config collections, so a bug in the sweep can't reach captain,
crew, or layer records.
Not addressed: the SBOM and vuln report blobs those scan records point
at. They share the /repos/{holdDID}/blobs/ prefix with the hold's avatar
and OG images, so collecting them needs a referenced-CID set built from
the hold's own records rather than a prefix walk.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
205 lines
9.6 KiB
HTML
205 lines
9.6 KiB
HTML
{{define "partials/gc_preview.html"}}
|
|
<div class="space-y-6">
|
|
<!-- Summary cards -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div class="stats shadow bg-base-100">
|
|
<div class="stat">
|
|
<div class="stat-title">Orphaned Records</div>
|
|
<div class="stat-value {{if .Preview.OrphanedRecords}}text-error{{end}}">{{len .Preview.OrphanedRecords}}{{if .Preview.OrphanedRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div>
|
|
<div class="stat-desc">Layer records with no manifest</div>
|
|
</div>
|
|
</div>
|
|
<div class="stats shadow bg-base-100">
|
|
<div class="stat">
|
|
<div class="stat-title">Orphaned Blobs</div>
|
|
<div class="stat-value {{if .Preview.OrphanedBlobs}}text-error{{end}}">{{len .Preview.OrphanedBlobs}}{{if .Preview.OrphanedBlobs}}<span class="sr-only"> — needs attention</span>{{end}}</div>
|
|
<div class="stat-desc">S3 blobs with no layer record</div>
|
|
</div>
|
|
</div>
|
|
<div class="stats shadow bg-base-100">
|
|
<div class="stat">
|
|
<div class="stat-title">Missing Records</div>
|
|
<div class="stat-value {{if .Preview.MissingRecords}}text-warning{{end}}">{{len .Preview.MissingRecords}}{{if .Preview.MissingRecords}}<span class="sr-only"> — needs attention</span>{{end}}</div>
|
|
<div class="stat-desc">Would be reconciled</div>
|
|
</div>
|
|
</div>
|
|
<div class="stats shadow bg-base-100">
|
|
<div class="stat">
|
|
<div class="stat-title">Referenced Blobs</div>
|
|
<div class="stat-value text-success">{{.Preview.ReferencedBlobs}}</div>
|
|
<div class="stat-desc">{{formatDuration .Preview.Duration}}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meta info -->
|
|
<p class="text-sm text-base-content/50">
|
|
Checked {{.Preview.UsersChecked}} users, {{.Preview.ManifestsChecked}} manifests, {{.Preview.TotalLayerRecords}} layer records, {{.Preview.TotalBlobs}} S3 blobs.
|
|
</p>
|
|
|
|
<!-- Orphaned Records table -->
|
|
{{if .Preview.OrphanedRecords}}
|
|
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
|
|
<input type="checkbox" aria-label="Show orphaned records" />
|
|
<div class="collapse-title font-medium">
|
|
{{ icon "file-x" "size-4" }} Orphaned Records ({{len .Preview.OrphanedRecords}})
|
|
</div>
|
|
<div class="collapse-content">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm table-fixed">
|
|
<caption class="sr-only">Orphaned layer, scan, and image config records</caption>
|
|
<colgroup>
|
|
<col style="width: 18%">
|
|
<col style="width: 14%">
|
|
<col style="width: 20%">
|
|
<col style="width: 36%">
|
|
<col style="width: 12%">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Collection</th>
|
|
<th scope="col">RKey</th>
|
|
<th scope="col">Digest</th>
|
|
<th scope="col">Manifest</th>
|
|
<th scope="col">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Preview.OrphanedRecords}}
|
|
<tr>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Collection}}">{{.Collection}}</code></td>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Rkey}}">{{.Rkey}}</code></td>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td>
|
|
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Orphaned Blobs table -->
|
|
{{if .Preview.OrphanedBlobs}}
|
|
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
|
|
<input type="checkbox" aria-label="Show orphaned blobs" />
|
|
<div class="collapse-title font-medium">
|
|
{{ icon "trash-2" "size-4" }} Orphaned Blobs ({{len .Preview.OrphanedBlobs}})
|
|
</div>
|
|
<div class="collapse-content">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm table-fixed">
|
|
<caption class="sr-only">Orphaned blobs</caption>
|
|
<colgroup>
|
|
<col style="width: 80%">
|
|
<col style="width: 20%">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Digest</th>
|
|
<th scope="col">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Preview.OrphanedBlobs}}
|
|
<tr>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 30}}</code></td>
|
|
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Missing Records table (reconcile mode only) -->
|
|
{{if .Preview.MissingRecords}}
|
|
<div class="collapse collapse-arrow bg-base-100 shadow-sm">
|
|
<input type="checkbox" aria-label="Show missing layer records" />
|
|
<div class="collapse-title font-medium">
|
|
{{ icon "file-plus" "size-4" }} Missing Layer Records ({{len .Preview.MissingRecords}})
|
|
</div>
|
|
<div class="collapse-content">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm table-fixed">
|
|
<caption class="sr-only">Missing layer records</caption>
|
|
<colgroup>
|
|
<col style="width: 20%">
|
|
<col style="width: 40%">
|
|
<col style="width: 25%">
|
|
<col style="width: 15%">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">Digest</th>
|
|
<th scope="col">Manifest</th>
|
|
<th scope="col">User</th>
|
|
<th scope="col">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Preview.MissingRecords}}
|
|
<tr>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.Digest}}">{{truncate .Digest 24}}</code></td>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.ManifestURI}}">{{truncate .ManifestURI 50}}</code></td>
|
|
<td><code class="text-xs font-mono truncate block max-w-full" title="{{.UserDID}}">{{truncate .UserDID 24}}</code></td>
|
|
<td class="whitespace-nowrap">{{formatBytes .Size}}</td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Individual action buttons -->
|
|
{{if or .Preview.OrphanedRecords .Preview.OrphanedBlobs .Preview.MissingRecords}}
|
|
<div class="flex flex-wrap items-center gap-3 mt-6">
|
|
{{if .Preview.MissingRecords}}
|
|
<button class="btn btn-warning gap-2"
|
|
hx-post="/admin/api/gc/reconcile"
|
|
hx-target="#gc-results"
|
|
hx-swap="innerHTML">
|
|
{{ icon "file-plus" "size-4" }}
|
|
Reconcile {{len .Preview.MissingRecords}} Records
|
|
</button>
|
|
{{end}}
|
|
{{if .Preview.OrphanedRecords}}
|
|
<button class="btn btn-error gap-2"
|
|
hx-post="/admin/api/gc/delete-records"
|
|
hx-target="#gc-results"
|
|
hx-swap="innerHTML"
|
|
hx-confirm="Delete {{len .Preview.OrphanedRecords}} orphaned records?">
|
|
{{ icon "file-x" "size-4" }}
|
|
Delete {{len .Preview.OrphanedRecords}} Orphaned Records
|
|
</button>
|
|
{{end}}
|
|
{{if .Preview.OrphanedBlobs}}
|
|
<button class="btn btn-error gap-2"
|
|
hx-post="/admin/api/gc/delete-blobs"
|
|
hx-target="#gc-results"
|
|
hx-swap="innerHTML"
|
|
hx-confirm="Delete {{len .Preview.OrphanedBlobs}} orphaned blobs from S3? This cannot be undone.">
|
|
{{ icon "trash-2" "size-4" }}
|
|
Delete {{len .Preview.OrphanedBlobs}} Orphaned Blobs
|
|
</button>
|
|
{{end}}
|
|
</div>
|
|
<p class="text-sm text-base-content/50 mt-2">Run Scan again after each operation to see updated counts.</p>
|
|
{{end}}
|
|
|
|
<!-- Nothing to clean -->
|
|
{{if and (not .Preview.OrphanedRecords) (not .Preview.OrphanedBlobs) (not .Preview.MissingRecords)}}
|
|
<div class="alert alert-success">
|
|
{{ icon "check-circle" "size-5" }}
|
|
<span>Storage is clean. No orphaned data found.</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|