Both guests healthy on 0.132.0; catalog 21e8df1 (13 blocks) synced live with ZERO backup-block rejections. 13-app cross-check table (every bind resolves explicit). RP-1..RP-4 confirmed. audiobookshelf PENDING-VETO = optional pending Viktor's ruling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
8.0 KiB
REPORT — Backup classification: schema + parser + classifier + catalog migration (controller v0.132.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.
Confirmed baselines
| 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).
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:roflag,ParseComposeUserdataMountsscanner shape, dedupe (first-:ro-wins).
internal/stacks/metadata.go—Metadata.Backupfield + the LoadMetadata validation choke point (bad block → nil + one[ERROR]→ legacy) +Manager.ClassifiedBinds.internal/appbackup/appdata.go—StackDataProvider.GetStackClassifiedBinds.internal/backup/appbackup_bridge.go—ClassifiedBindalias.cmd/controller/main.go—stackAdapter.GetStackClassifiedBindsdelegation.- 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.
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.
Tests
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):
- 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).
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).
Red-proofs (run → fail → revert → green) — all confirmed
| 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 |
Deploy + verify
- Built
v0.132.0on 180 (build.sh 0.132.0 --push, image 145M, digestsha256: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 PVEdrill-day0, 192.168.0.152).
Live sync check (demo guest — the real server-side pipeline)
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:
[sync] Updated <app>/.felhom.ymlfor exactly the 13 apps, then[sync] Catalog sync complete.backup block rejectedcount: 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.
13-app cross-check (shipped parser against the real catalog clone — every bind resolved explicit)
| 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 |
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).
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/
.fabstill capture per today's legacy behavior. Task 3 (tier policy engine) is the next step: consumeManager.ClassifiedBinds/GetStackClassifiedBindsto scope offsite/tier-2/.fabcapture by class (mandatory→offsite, excluded→never, optional→customer-selectable). The seam is wired + tested for it. - Recovery units already carry
.felhom.ymland 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.