Capture-set computation (INERT; Task 3-core, v0.133.0)

Pure appbackup.ComputeCaptureSet(binds, hasClassification, tier, hddPath) → CaptureSet
{HasClassification, Paths, Skipped}: legacy short-circuit → tier filter (§2) → structural
guards → equal-Abs collapse (mandatory>optional) → containment dedup → sort. Slash algebra,
no filepath/FS/log. Structural guards (traversal / bare HDD drive-root / reserved backups/)
are load-bearing (the compose parser does not reject ..). Pure CrossAppOverlaps advisory
(WARN wiring deferred to 3a/3b). INERT — no engine consumes it yet.

Tests: Groups A-F (appbackup) + F-S3 no-seam wiring (stacks); all 6 §10 red-proofs verified.
Docs: architecture §3 aligned (felhom.eu 8d85da7).
This commit is contained in:
2026-07-14 21:53:05 +02:00
parent 95f3180ab4
commit 2668ac4da3
7 changed files with 734 additions and 104 deletions
+72 -103
View File
@@ -1,127 +1,96 @@
# REPORT — Backup classification: schema + parser + classifier + catalog migration (controller v0.132.0)
# REPORT — Capture-set computation (INERT; Task 3-core) — controller v0.133.0
## Summary
Task 2 of the backup-classification-redesign arc
(`felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`). Ships the
referential-coupling classification as **DATA + PARSER + PURE CLASSIFIER — deliberately INERT**: no
backup tier changes behavior. The `backup:` block now lands in `.felhom.yml` for all 13 bind-bearing
catalog apps; the controller parses + validates it loudly and a pure classifier resolves per-bind
classes with the SQ5 two-level default. Task 3 (tier policy engine) and Task 4 (manual `.fab` UI)
consume this — nothing does yet except a validation log pass.
Task 3-core of the backup-classification-redesign arc
(`felhom.eu/documentation/architecture/07-backup-architecture.md` §3; tier×class matrix §2; spike
verdicts in `SPIKE-restic-snapshot-shape-2026-07-14.md`). Ships **one pure function**,
`appbackup.ComputeCaptureSet`, that turns an app's classified binds + a tier + the app's live
`hddPath` into the tier-filtered, structurally-guarded, containment-deduped absolute capture set that
the 3a (offsite) and 3b (tier-2) engines will consume — plus a `Skipped` list for structurally unsafe
would-be captures and a pure `CrossAppOverlaps` advisory. **Deliberately INERT** like Task 2: no
backup tier changes behavior; nothing consumes any of it yet.
## Confirmed baselines
## Baselines (live-verified at session start)
| Repo | `main` @ start | → | Commit(s) on `main` |
|------|----------------|---|---------------------|
| felhom-controller | `af98c53` (v0.131.0) | **v0.132.0** | `0649f9a` (code+tests+docs) |
| app-catalog-felhom.eu | `365a017` | (no version scheme) | `21e8df1` (13 blocks + CHANGELOG) |
Trunk-based, direct to `main`. Controller committed + deployed FIRST, then the catalog blocks (so the
parser validates them on first sync).
| Repo | `main` @ start | Version | This task |
|---|---|---|---|
| felhom-controller | `95f3180` | v0.132.0 **v0.133.0** | `appbackup` engine + `stacks` wiring test |
| felhom.eu | `b279312` | — | §3 docs alignment only (commit `8d85da7`) |
## Files created / modified
**Controller (`0649f9a`)**
- `internal/appbackup/classify.go` (NEW) — schema (`BindClass`, `BindRoot`, `BackupSpec`, `BindSpec`,
`ComposeBind`, `ClassOrigin`, `ClassifiedBind`) + `ValidateBackupSpec` (whole-block-reject) +
`ClassifyBinds` (two-level default; explicit beats `:ro`; nil spec → legacy/false).
- `internal/stacks/classify_binds.go` (NEW) — `ParseComposeClassifiableBinds`: `${VAR}`-relative binds
+ `:ro` flag, `ParseComposeUserdataMounts` scanner shape, dedupe (first-`:ro`-wins).
- `internal/stacks/metadata.go``Metadata.Backup` field + the LoadMetadata validation choke point
(bad block → nil + one `[ERROR]` → legacy) + `Manager.ClassifiedBinds`.
- `internal/appbackup/appdata.go``StackDataProvider.GetStackClassifiedBinds`.
- `internal/backup/appbackup_bridge.go``ClassifiedBind` alias.
- `cmd/controller/main.go``stackAdapter.GetStackClassifiedBinds` delegation.
- 5 test fakes (`recovery_unit_test.go`, `tier2_restore_test.go`, `volume_dumps_test.go`,
`api/backup_snapshots_test.go`, `web/async_restore_test.go`) — nil-stub method ONLY (no logic edits).
- Tests: `appbackup/classify_test.go`, `stacks/classify_binds_test.go`, `stacks/classify_wiring_test.go`.
- Docs: `CHANGELOG.md`, `CONTEXT.md`, `REUSE.md`, `controller/README.md`.
- **new** `controller/internal/appbackup/captureset.go``ComputeCaptureSet`, `CaptureTier`,
`CapturePath`, `SkippedPath`, `CaptureSet`, `CrossAppOverlaps`, `Overlap`; the §8 pipeline +
structural guards.
- **new** `controller/internal/appbackup/captureset_test.go` — Groups AF (7 tests).
- **new** `controller/internal/stacks/captureset_wiring_test.go` — Group G, F-S3 no-seam end-to-end.
- **mod** `controller/CHANGELOG.md` (v0.133.0, newest-on-top), `controller/REPORT.md` (this),
`controller/CONTEXT.md`, `controller/README.md` (appbackup surface, one block).
- **mod** `felhom.eu/documentation/architecture/07-backup-architecture.md` §3 (as-built API sketch;
separate commit `8d85da7`).
**Catalog (`21e8df1`)**`backup:` block added after `resources:` in 13 `.felhom.yml` files (immich,
paperless-ngx, nextcloud, calibre-web, audiobookshelf, komga, navidrome, radarr, sonarr, emby,
jellyfin, plex, romm) + `CHANGELOG.md`.
No engine edits (`RunTier2`/`RunOffboxBackup`/`RestoreOffbox` untouched), no web/scheduler wiring, no
`ClassifyBinds`/`ValidateBackupSpec`/`ParseComposeClassifiableBinds` change, no `AppDataDirNames` call.
## Tests
## Design (as-built)
`go build ./... && go vet ./... && go test ./...`**all green**, both repos' content parses clean.
**+14 test functions** (before → after in the touched packages; a 9-case validation subtable inside):
Fixed pipeline (§8): **legacy short-circuit → tier filter (§2) → structural guards → equal-Abs
collapse (mandatory > optional) → containment dedup (keep ancestor) → sort by Abs.** Pure: no
`os`/`exec`/`filepath`/logging; slash algebra throughout (`RelPath` is forward-slash, resolved Abs is
an in-container Linux path — `filepath` on the Windows test host would flip separators and break the
containment prefix checks). Resolution: `RootHDD → path.Join(hddPath, rel)`,
`RootUserdata → path.Join(hddPath, "userdata", rel)`. Structural guards are load-bearing security: the
compose parser path.Cleans but does not reject `..`, so an unlisted writable `${HDD_PATH}/../x` bind
reaches the function classed mandatory; the guard moves it to `Skipped` (with the bare-drive-root and
reserved-`backups/` guards) instead of into a captured path.
- **Group A (classifier):** `TestClassify_ImmichShape` (explicit beats `:ro`), `_TwoLevelDefault`
(unlisted writable→mandatory, `:ro`→excluded), `_NilSpecLegacy`, `_BareRootFallsToDefault`,
`_SameRelPathBothRoots`.
- **Group B (validation):** `TestValidateBackupSpec_Defects` (9 cases: unknown/empty class, empty/
absolute/`..`/backslash/non-clean path, duplicate, no-matching-bind — each names the entry) + `_ValidAndNil`.
- **Group C (parser):** `TestParseClassifiableBinds_Variants` (ro/rw/ro,z/z, dedupe, bare root, both
roots, non-volume sections), `_CleanAndSubpaths` (false-prefix rejected), `_Missing`.
- **Group D (end-to-end, NO seams):** `TestClassifiedBinds_ValidFixture`, `_RejectedFixture` (RP-4),
`_NoBlockLegacy` — real files through the REAL LoadMetadata path.
- **Group E (catalog fidelity):** `TestCatalogBlocks_Fidelity` (immich, paperless-ngx, sonarr exact class sets).
## Tests — results
**Inertness proof (Scenario C):** the entire pre-existing suite is green with **zero test-logic
edits** — only the mandated nil-stub method was added to each fake (verified: `+14 1` across the 5
fake files, the `1` being a gofmt re-alignment).
`go build ./... && go vet ./... && go test ./...`**all green, both repos** (felhom.eu has no Go).
### Red-proofs (run → fail → revert → green) — all confirmed
New tests (8 total): `internal/appbackup` +7 (`PerTierSplit`, `LegacyInert`, `ExcludedInvisible`,
`StructuralGuards`, `LegitDotDotName`, `ContainmentAndCollision`, `CrossAppOverlaps`);
`internal/stacks` +1 (`CaptureSet_Wiring`). Full-suite package count unchanged, all `ok`.
| RP | Mutation | Failing assertion | Reverted |
|----|----------|-------------------|----------|
| RP-1 | disable the class-value check | `TestValidateBackupSpec_Defects/unknown_class` + `/empty_class` accept silently | green |
| RP-2 | `:ro` default overrides explicit | `TestClassify_ImmichShape`: media/photos forced excluded | green |
| RP-3 | flip unlisted-writable default to excluded | `TestClassify_TwoLevelDefault`: unlisted writable ≠ mandatory | green |
| RP-4 | LoadMetadata accepts `Backup` without validating | `TestClassifiedBinds_RejectedFixture`: bad block not degraded to legacy | green |
### §10 red-proofs (mutation FAIL → revert), every one verified
## Deploy + verify
| ID | Mutation | Test that must fail | Observed failure |
|---|---|---|---|
| **B** (SQ5) | legacy short-circuit resolves binds as mandatory | `LegacyInert` | legacy app resolved `[appdata/sonarr, media/tv]` into Paths (+HasClassification=true) |
| **A** (tier) | `TierOffsite` includes optional | `PerTierSplit` | offsite Paths gained `/userdata/media/photos` |
| **D** (guard) | traversal guard deleted | `StructuralGuards` | `/mnt/evil` (escaped root) present in Paths; 2 Skipped not 3 |
| **E1** (contain) | containment dedup disabled | `ContainmentAndCollision` | descendant `appdata/paperless/media` not dropped |
| **E2** (mand-wins) | mandatory strength = optional | `ContainmentAndCollision` | collapsed `/userdata/media` class degraded to optional |
| **G** (wiring) | tier constants swapped in the filter | `CaptureSet_Wiring` (end-to-end) | real-Manager secondary lost photos / offsite gained it — no fake absorbed the typo |
- Built `v0.132.0` on 180 (`build.sh 0.132.0 --push`, image 145M, digest `sha256:00c6f83e…`).
- Deployed via the bootstrap mechanism to **BOTH guests**`felhom-controller:0.132.0 … Up (healthy)`
on demo 9201 (felhom-pve) and the drill guest 9201 (nested PVE `drill-day0`, 192.168.0.152).
All mutations reverted; post-revert full suite green; no `RED-PROOF` residue in the new files.
## Live sync check (demo guest — the real server-side pipeline)
## Deploy / verify
Catalog `21e8df1` pushed AFTER the controller was live, then the controller's `doSync` pipeline
(same code path as the "Sablonok frissítése" button) was triggered by a bootstrap restart:
Built + pushed `felhom-controller:0.133.0` on 180; deployed to demo guest 9201 (bootstrap-managed).
**INERT-silence check:** startup clean, no new feature log lines at runtime (the package has zero call
sites). Per-guest verification recorded below.
- `[sync] Updated <app>/.felhom.yml` for **exactly the 13 apps**, then `[sync] Catalog sync complete`.
- **`backup block rejected` count: 0.** Zero `[ERROR] [stacks]` lines anywhere in the log. Controller
healthy. → **live proof that all 13 committed blocks parse, validate, and match their real composes.**
_Deploy verification (docker ps + logs) appended after the live deploy step — see the deploy commit._
## 13-app cross-check (shipped parser against the real catalog clone — every bind resolved `explicit`)
## Live-validation scope
| App | Resolved class set (root/relpath = class) |
|-----|-------------------------------------------|
| immich | hdd/appdata/immich=mandatory · userdata/media/photos=optional |
| paperless-ngx | hdd/appdata/paperless/media=mandatory · hdd/appdata/paperless/export=excluded · userdata/import/paperless=excluded |
| nextcloud | hdd/appdata/nextcloud=mandatory |
| calibre-web | userdata/media/books=mandatory · userdata/import/calibre=excluded |
| audiobookshelf | userdata/media/audiobooks=optional · userdata/media/podcasts=excluded |
| komga | userdata/media/comics=optional |
| navidrome | userdata/media/music=excluded |
| radarr | userdata/media/movies=excluded · userdata/downloads=excluded |
| sonarr | userdata/media/tv=excluded · userdata/downloads=excluded |
| emby | userdata/media=excluded |
| jellyfin | userdata/media=excluded |
| plex | userdata/media=excluded |
| romm | userdata/roms=optional · hdd/appdata/romm/resources=excluded |
Live validation beyond deploy-health is **inherently N/A** for an inert pure package: it has no
runtime surface, no UI, no behavior change. The real live legs belong to 3a (offsite) and 3b (tier-2)
acceptance, which consume this function.
Every bind resolves with origin `explicit` (full coverage — no writable bind left to the mandatory
default). Verified via a throwaway `go run` harness over the shipped `LoadMetadata →
ParseComposeClassifiableBinds → ClassifyBinds`, then removed (not committed).
## Observations (documented, NOT acted on)
## PENDING-VETO resolution
**audiobookshelf `media/audiobooks` = `optional`** (spike SQ2 proposed `excluded`). Committed optional
for consistency with komga/romm (curated, often personally-ripped → precious-decoupled). If Viktor
rules `excluded`, flip that ONE line in `templates/audiobookshelf/.felhom.yml`.
## Observations (noticed, NOT acted on)
- The classification is INERT by contract — offsite/tier-2/`.fab` still capture per today's legacy
behavior. **Task 3** (tier policy engine) is the next step: consume `Manager.ClassifiedBinds` /
`GetStackClassifiedBinds` to scope offsite/tier-2/`.fab` capture by class (mandatory→offsite,
excluded→never, optional→customer-selectable). The seam is wired + tested for it.
- Recovery units already carry `.felhom.yml` and git-sync already whitelists it, so the block reaches
deployed stacks + units with zero plumbing changes; no recovery-unit SchemaVersion bump (Task 3 owns
any content-semantics bump).
- All 13 catalog apps list every classifiable bind explicitly — the two-level *default* branch
(unlisted writable→mandatory) is exercised only by tests today, never by a real catalog app.
- **Parser-side traversal:** `ParseComposeClassifiableBinds` / `classifyRoot` path.Clean the compose
host token but do not reject a `..` that survives cleaning (`${HDD_PATH}/../x` → RelPath `../x`).
This is by design per the task (the parser stays a faithful extractor; the guard lives in 3-core),
and the structural guard here is what makes it safe. If a future task ever wants defence-in-depth,
the parser is the second place it could live — noted, not changed.
- `ClassifyBinds` emits legacy binds with an **empty** `Class`; `ComputeCaptureSet`'s legacy
short-circuit means those are never resolved, but `tierKeeps` also treats an empty class as
not-captured (defensive) — so even a future caller that skipped the short-circuit could not resolve
a classless bind. Belt-and-suspenders, intentional.
- The equal-Abs collapse can arise from two *spellings* of one path (`hdd:userdata/media` vs
`userdata:media`); today no catalog app does this, but the collapse + mandatory-wins rule makes it
safe if one ever does.