Files
felhom-controller/REPORT.md
T
admin 95f3180ab4 docs: REPORT — v0.132.0 backup classification (INERT) deploy + live sync evidence
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
2026-07-14 18:54:34 +02:00

8.0 KiB
Raw Blame History

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
    • :ro flag, ParseComposeUserdataMounts scanner shape, dedupe (first-:ro-wins).
  • internal/stacks/metadata.goMetadata.Backup field + the LoadMetadata validation choke point (bad block → nil + one [ERROR] → legacy) + Manager.ClassifiedBinds.
  • internal/appbackup/appdata.goStackDataProvider.GetStackClassifiedBinds.
  • internal/backup/appbackup_bridge.goClassifiedBind alias.
  • cmd/controller/main.gostackAdapter.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.

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.0 on 180 (build.sh 0.132.0 --push, image 145M, digest sha256:00c6f83e…).
  • Deployed via the bootstrap mechanism to BOTH guestsfelhom-controller:0.132.0 … Up (healthy) on demo 9201 (felhom-pve) and the drill guest 9201 (nested PVE drill-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.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.

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/.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.