Add gitea-image-prune.sh: inspect/prune Gitea container images + reclaim disk
New operator CLI (curl+jq, dry-run default) to list, prune (keep-N or older-than), and reclaim old container images in the self-hosted Gitea registry. Reclaim implements the three-step mechanism proven live on Gitea 1.26.2: delete tag (frees only the index pointer) -> delete the orphaned sha256 manifest versions (default cleanup_packages does NOT remove untagged manifests) -> cleanup_packages cron GCs the now unreferenced blobs. Orders by upload date, protects ^latest$, fail-closed orphan detection, audit log, never logs the token. Live-verified: single-version spike freed 5.1 MiB; cleaning felhom-hub's 16 orphan manifests freed 86 MiB; surviving tags still docker-pull. felhom-controller and other packages left untouched for the operator. Adds README section (usage, minimal token scopes, reclaim caveat, native cleanup-rule recommendation), CHANGELOG, REPORT, and .gitattributes (LF). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# REPORT — `gitea-image-prune.sh` (2026-06-17)
|
||||
|
||||
New operator CLI to inspect/prune old container images in the Gitea registry and
|
||||
reclaim disk, with a load-bearing live spike to **prove** the reclaim mechanism.
|
||||
|
||||
## Confirmed baselines (live)
|
||||
|
||||
| Thing | Value |
|
||||
|---|---|
|
||||
| Gitea version | **1.26.2** (`GET /api/v1/version`) |
|
||||
| Owner namespace | `admin` (standard per-owner packages API; "admin" in the path is the owner) |
|
||||
| Container packages | `felhom-controller` **96 tags / 247 digests**, `felhom-hub` **42 tags / 96 digests**, plus `recipe-importer` (42), `revfulop-calendar` (12), `jarr` (2), `wan-probe` (1) |
|
||||
| Packages cron | `cleanup_packages`, default `@midnight`, `OLDER_THAN = 24h` |
|
||||
| Packages dir | `/data/gitea/packages` (gitea-system pod, container `gitea`), **baseline 5,122,143,723 B ≈ 4.77 GiB** |
|
||||
| Tooling | `jq` 1.7 present on build server; `shellcheck` **absent** (not run — script written carefully, `bash -n` clean) |
|
||||
|
||||
## Minimal token scope set (empirically confirmed via 403 bodies)
|
||||
|
||||
| Operation | Required scope |
|
||||
|---|---|
|
||||
| list packages / versions / files | `read:package` |
|
||||
| delete a tag / manifest version | `write:package` |
|
||||
| list cron tasks | `read:admin` |
|
||||
| trigger `cleanup_packages` cron | **`write:admin`** |
|
||||
|
||||
The build server token (`~/.gitea-token`) has `read:admin` + `write:package` but
|
||||
**not** `write:admin` (its 403 body named exactly `required=[write:admin]`), so it
|
||||
can list/prune/delete-orphans but cannot trigger the GC cron on demand. Token must
|
||||
belong to a site-admin user. (Scopes were *not* minimized by minting reduced
|
||||
tokens — that needs `write:user`, which this token also lacks — but each required
|
||||
scope was confirmed by a successful call and the cron requirement by its 403.)
|
||||
|
||||
## §3 spike — the reclaim mechanism (PROVEN, not assumed)
|
||||
|
||||
Gitea stores a tag as a tiny OCI **index** pointer; the real bytes are in untagged
|
||||
`sha256:` **manifest versions** (config + layer blobs), whose layers are shared
|
||||
across tags. The mechanism turned out to be **three steps**, not two:
|
||||
|
||||
| Step (single-version spike, `felhom-hub`) | `du` (bytes) | freed |
|
||||
|---|---|---|
|
||||
| baseline | 5,122,143,723 | — |
|
||||
| DELETE tag `0.1.1` (via the script, HTTP 204) | 5,122,143,723 | **0** |
|
||||
| run `cleanup_packages` (tag-only) | 5,122,138,771 | ~5 KB (index pointer only) |
|
||||
| DELETE tag `0.1.2` + its **2 orphaned manifests** (204×3) | 5,122,138,771 | **0** |
|
||||
| run `cleanup_packages` GC | 5,116,799,814 | **5,338,957 B ≈ 5.1 MiB** |
|
||||
|
||||
**Conclusions:**
|
||||
1. Deleting a tag frees ~nothing (only the index pointer).
|
||||
2. **Default `cleanup_packages` does NOT remove untagged manifest versions** —
|
||||
only unreferenced *blobs*. So the orphaned `sha256:` manifests must be deleted
|
||||
explicitly (the script's reclaim does this); otherwise their blobs stay
|
||||
referenced forever. (Confirmed: a tag-only delete + cron left `felhom-hub`
|
||||
digests at 96.)
|
||||
3. Once the orphaned manifests are deleted, `cleanup_packages` GCs their *unique*
|
||||
blobs (created > `OLDER_THAN`); shared base layers stay. 5.1 MiB freed for one
|
||||
9.4 MB-apparent image — the difference is shared layers, correctly retained.
|
||||
|
||||
Because the token lacks `write:admin`, the GC cron was triggered by adding
|
||||
`[cron.cleanup_packages] RUN_AT_START = true` to `app.ini` (on the data PVC,
|
||||
backup `app.ini.bak.prune-spike`) and rolling-restarting Gitea — left in place per
|
||||
operator request (the daily `@midnight` run also performs the GC).
|
||||
|
||||
## §9 verification results
|
||||
|
||||
1. **List (read-only):** `--repo felhom-hub list` → 42 tags, newest-first, per-tag
|
||||
sizes resolved via OCI (24 MB recent, ~9 MB older), `latest` flagged PROTECTED,
|
||||
shared-layer caveat printed. `--all` lists all 6 packages; `--no-sizes` fast path
|
||||
works. No mutation.
|
||||
2. **Dry-run prune:** `--repo felhom-hub --keep 5 --dry-run` → would delete 36,
|
||||
keep 5 + 1 protected, oldest first, totals shown, **nothing changed**.
|
||||
3. **One-version live proof (spike):** see table above — delete-alone = 0 bytes;
|
||||
delete + orphan-manifest delete + GC = 5.1 MiB.
|
||||
4. **Full reclaim path validated** on `felhom-hub` only: `reclaim --apply` deleted
|
||||
the **16 accumulated orphan manifests** (untagged, referenced by no tag — dead
|
||||
weight from re-pointed `latest` + buildx attestations), then GC freed
|
||||
**5,116,799,814 → 5,026,431,222 = 90,368,592 B ≈ 86 MiB**.
|
||||
5. **Safety:** after reclaim, surviving tags still resolve and **`docker pull`
|
||||
cleanly** (`latest`, `0.1.3` — the immediate neighbor of the deleted tags).
|
||||
Orphan detection is fail-closed (skips a package if any surviving tag won't
|
||||
resolve).
|
||||
6. **Audit log** captured every RUN / DRY-RUN / APPLIED / RECLAIM line; **token
|
||||
scan of the log = 0 hits**. Edge cases: `--keep`+`--older-than` → error;
|
||||
`--keep 999` → "Nothing to prune (40 tags: 39 kept, 1 protected)".
|
||||
|
||||
## State left on the registry
|
||||
|
||||
- `felhom-hub`: tags `0.1.1` and `0.1.2` deleted (spike); 16 orphan manifests
|
||||
cleaned; now **40 tags / 78 digests**; ~91 MiB reclaimed total. All remaining
|
||||
tags pull cleanly.
|
||||
- **`felhom-controller` (96 tags) and all other packages: UNTOUCHED.**
|
||||
- `app.ini`: `RUN_AT_START = true` added for `cleanup_packages` (kept).
|
||||
|
||||
## NOT yet run
|
||||
|
||||
**The real bulk cleanup — left to the operator (interactive).** This run proved
|
||||
the mechanism on one disposable version and validated the full reclaim path on
|
||||
`felhom-hub`'s dead orphans only. Pruning the ~90 `felhom-controller` tags (and
|
||||
the bulk of `felhom-hub`/`recipe-importer` history) is the operator's call via
|
||||
`gitea-image-prune.sh --repo … --keep N --apply --reclaim`.
|
||||
|
||||
## Backlog / notes
|
||||
|
||||
- A `write:admin` token (or the native cleanup rule in the UI) would let `reclaim`
|
||||
trigger the GC immediately instead of relying on the `@midnight`/restart run.
|
||||
- Per-tag "apparent" sizes overlap (shared base layers counted once per tag);
|
||||
`--measure` (`du`) is the honest real-reclaim signal. Documented in the tool.
|
||||
- `shellcheck` was unavailable on the build server, so the script was not
|
||||
statically linted (only `bash -n` syntax-checked + extensively run live).
|
||||
Reference in New Issue
Block a user