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:
@@ -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 A–F (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.
|
||||
|
||||
Reference in New Issue
Block a user