diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f8b896..3d0d3a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
## Changelog
+### v0.132.0 — Backup classification: schema + parser + pure classifier (INERT; Task 2) (2026-07-14)
+
+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. Task 3 (tier policy engine) and Task 4 (manual `.fab` UI) are the
+consumers; today only a validation log pass touches it.
+
+- **Schema (`internal/appbackup/classify.go`, new):** `BackupSpec`/`BindSpec` model the `.felhom.yml`
+ `backup:` block (`userdata:`/`hdd:` lists of `{path, class}`); `ComposeBind` is a `${VAR}`-relative
+ host bind carrying the `:ro` flag. Classes: **mandatory** (COUPLED — restore-without is
+ broken-not-empty, SQ3), **optional** (DECOUPLED-precious), **excluded** (DECOUPLED-bulk/transient).
+- **Pure classifier `ClassifyBinds`:** the SQ5 **two-level default** — an explicit block entry ALWAYS
+ wins (an explicit `optional` on immich's `:ro` external library beats the reader default); an
+ unlisted **writable** bind defaults **mandatory** (the C6B-F1 capture direction, never silent-drop);
+ an unlisted **`:ro`** bind defaults **excluded** (reader rule). Returns `hasClassification` — **a
+ nil spec (no block) → every bind is `legacy` with NO class semantics**, so an unmigrated app's
+ behavior is byte-identical.
+- **Validation `ValidateBackupSpec` (whole-block-reject):** ANY defect — unknown/empty class (a typoed
+ `clas:` key leaves `""`), empty/absolute/`..`/backslash/non-clean path, duplicate `(root, path)`, or
+ an entry matching NO compose bind (a stale/typoed path must not silently shift the real bind onto
+ the mandatory default) — rejects the ENTIRE block with the first defect named. Never partial.
+- **Parser `ParseComposeClassifiableBinds` (`internal/stacks`):** copies the
+ `ParseComposeUserdataMounts` scanner shape but stays in `${VAR}`-relative space and preserves `:ro`
+ (why it does NOT reuse `ParseComposeHDDMounts`, which resolves absolutes and drops the mode). Deduped
+ on `(root, relpath)`, first-occurrence `:ro` wins; short-syntax only.
+- **Integration:** `Metadata` gains `Backup *appbackup.BackupSpec`; `LoadMetadata` is the SINGLE
+ validation choke point (catalog listing, deployed-stack scan, and git-sync all flow through it, so a
+ bad catalog push logs `[ERROR] ... backup block rejected in
: ` within one sync cycle
+ and the app degrades to legacy). `stacks.Manager.ClassifiedBinds` + a new
+ `StackDataProvider.GetStackClassifiedBinds` seam (delegated by `stackAdapter`, nil-stubbed in every
+ fake) exist so Task 3 consumes a **wired, end-to-end-tested** seam — the F-S3 lesson that wiring is
+ where typos hide.
+
+INERT by design: offsite/tier-2/`.fab`/deploy/sync are byte-identical — proven by the entire
+pre-existing test suite staying green with **zero test-logic edits** (only mandated nil-stub methods
+added to fakes). Recovery units already carry `.felhom.yml` and git-sync already whitelists it, so the
+block propagates to deployed stacks + units with zero plumbing changes; no recovery-unit SchemaVersion
+bump. The 13 catalog `backup:` blocks ship in the same `app-catalog-felhom.eu` change (this controller
+must be live first so the parser validates them on first sync). +14 test functions (Groups A–E,
+incl. a 9-case validation table); red-proofs RP-1..RP-4 all
+confirmed (validation, explicit-beats-ro precedence, capture-default direction, LoadMetadata→validate
+wiring). Controller-only; no MinAgent/hub coupling.
+
### v0.131.0 — F-S2 + F-S3: compose-derived appdata dir resolution (paperless-ngx → appdata/paperless) (2026-07-14)
The controller assumed an app's HDD appdata dir is always `appdata/`. paperless-ngx binds
diff --git a/CONTEXT.md b/CONTEXT.md
index 3f6c801..9d0feb5 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -7,7 +7,25 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
-Last updated: 2026-07-14 (v0.131.0 — F-S2 + F-S3 compose-derived appdata dir resolution)
+Last updated: 2026-07-14 (v0.132.0 — backup classification schema/parser/classifier, INERT)
+
+> **2026-07-14 — v0.132.0: backup classification (Task 2, INERT).** Task 2 of the
+> backup-classification-redesign arc (spike: `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. New `appbackup/classify.go`: `BackupSpec`/`BindSpec`
+> (the `.felhom.yml` `backup:` block), `ComposeBind` (`${VAR}`-relative + `:ro`), `ClassifyBinds`
+> (SQ5 two-level default: explicit beats `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded;
+> **no block → legacy/false**), `ValidateBackupSpec` (whole-block-reject on any defect). New
+> `stacks/classify_binds.go` `ParseComposeClassifiableBinds` (relative-space, keeps `:ro` — NOT
+> `ParseComposeHDDMounts`/`ExportDataMounts`, the classifier-input traps). `LoadMetadata` is the
+> SINGLE validation choke point (bad catalog block → nil + one `[ERROR]` within one sync cycle →
+> legacy). Wired seam `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds`
+> (nil-stubbed in every fake) so **Task 3 (tier policy engine)** consumes a tested seam, not a fresh
+> one. Inertness proven: full pre-existing suite green with ZERO test-logic edits. The 13 catalog
+> `backup:` blocks ship in the same `app-catalog-felhom.eu` change (controller deployed FIRST so the
+> parser validates on first sync). audiobookshelf PENDING-VETO: media/audiobooks ruled **optional**
+> (consistency with komga/romm) pending a Viktor veto to excluded. +14 tests, RP-1..RP-4 confirmed.
+> **Next: Task 3** consumes `ClassifiedBinds` to scope offsite/tier-2/`.fab` capture by class.
> **2026-07-14 — v0.131.0: F-S2 + F-S3 (compose-derived appdata dir resolution).** Task 1 of the
> backup-classification-redesign arc (spike: `felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`).
diff --git a/REUSE.md b/REUSE.md
index 607b025..37f42bc 100644
--- a/REUSE.md
+++ b/REUSE.md
@@ -69,6 +69,9 @@
| `resolveContainerState` / `aggregateState` | controller/internal/stacks/manager.go | `(dockerState, dockerStatus)` / `([]ContainerInfo)` | State classification | `.State` says "running" even when unhealthy — `.Status` parse is the fix |
| `Manager.logPostStartStatus` | controller/internal/stacks/manager.go | `(name, stackDir, env)` | Async post-start verification | compose up exits 0 on crash-loops; this is the detection. Goroutine + 3s, never blocks |
| `Manager.EnsureBaseStack` | controller/internal/stacks/infra.go | `() error` | Traefik/cloudflared/FileBrowser infra convergence | Renders from `internal/infra` templates |
+| `appbackup.ClassifyBinds` / `ValidateBackupSpec` | controller/internal/appbackup/classify.go | `(spec, binds) ([]ClassifiedBind, bool)` / `(spec, binds) error` | Backup-classification (Task 2, referential coupling) — pure | Two-level default: explicit wins over `:ro`; unlisted writable→mandatory, unlisted `:ro`→excluded; nil spec→legacy/false. Validate REJECTS the WHOLE block on any defect (whole-block semantics). INERT — no tier consumes it yet |
+| `ParseComposeClassifiableBinds` | controller/internal/stacks/classify_binds.go | `(composePath) []appbackup.ComposeBind` | `${VAR}`-relative binds + `:ro` for classification | Do NOT use `ParseComposeHDDMounts`/`ExportDataMounts` as classifier input (§traps) — they resolve absolutes, drop `:ro`, or union the userdata ROOT. Short-syntax only |
+| `Manager.ClassifiedBinds` + `StackDataProvider.GetStackClassifiedBinds` | controller/internal/stacks/metadata.go, appbackup/appdata.go | `(name) ([]appbackup.ClassifiedBind, bool)` | Per-stack classification through the REAL LoadMetadata validate path | The wired seam Task 3 consumes; LoadMetadata is the SINGLE validation choke point (bad block → nil + one ERROR → legacy) |
| `backup.Manager.DumpAppVolumesSafe` | controller/internal/backup/backup.go | `(stackName) error` | Volume tar of a live app | Stops → dumps → restarts; surfaces BOTH errors (app may be left stopped). Check `GetDockerVolumes()!=0` + `IsProtectedStack` BEFORE calling — it stops the stack before its own volume check (see `runVolumeDumps`) |
| `backup.Manager.ListRestorePoints` | controller/internal/backup/restore_points.go | `(stackName) ([]RestorePoint, bool)` | Restorable keep-side backups (the /api/backup/snapshots payload) | ONE point per app (the current unit); tier always 1 — never list Tier-2 (not restorable via /backup/restore) |
| `backup.Manager.RestoreTier2Files` | controller/internal/backup/tier2_restore.go | `(stackName) (filesRestored int, err error)` | In-place ADDITIVE-ONLY class-C file restore from the recorded Tier-2 copy (`POST /backup/tier2/restore`) | Never overwrites/deletes live files; refusals (Hungarian) before any stop; source = recorded `DestinationPath`, never re-selected |
@@ -173,6 +176,7 @@
| `backup.Manager.DumpAppVolumes` on a running DB app | Inconsistent tar of live DB volume | `DumpAppVolumesSafe` (stop → dump → restart, both errors surfaced) |
| `stacks.Manager.execCommand` / `composeExecCustomEnv` for NEW long-running calls | No context/timeout — a hung docker CLI blocks forever | `exec.CommandContext` + explicit timeout (copy `rsyncCopy` or appexport `composeExecEnv`) |
| `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else |
+| `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) |
| `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` |
## 4. Seams & interfaces (testing + cross-repo)
diff --git a/controller/README.md b/controller/README.md
index f0bda42..89ac00a 100644
--- a/controller/README.md
+++ b/controller/README.md
@@ -252,6 +252,17 @@ Each app can define rich metadata in `.felhom.yml`:
surfaces it on `/apps/{slug}` as a "Kezdeti belépési adatok" card (masked password + reveal/copy),
labelled as the *initial* credential (stays valid only until the customer changes it in-app). Hidden
when the container is down / file missing / parse fails. Reuse for any future self-seeding app.
+- `backup` (v0.132.0): the **referential-coupling classification** block (Task 2). Two optional lists,
+ `userdata:` (relative to `${USERDATA_PATH}`) and `hdd:` (relative to `${HDD_PATH}`), each of
+ `{path, class}` where `class ∈ {mandatory, optional, excluded}` (COUPLED / DECOUPLED-precious /
+ DECOUPLED-bulk). `LoadMetadata` validates the block against the app's compose binds and **rejects the
+ WHOLE block** (→ nil + one `[ERROR]`, app behaves as legacy) on any defect. Semantics
+ (`appbackup.ClassifyBinds`): an explicit entry wins over the `:ro` default; an unlisted writable bind
+ defaults `mandatory`, an unlisted `:ro` bind defaults `excluded`; **no block at all = legacy behavior
+ per tier**. **INERT** as of v0.132.0 — the schema/parser/classifier + the
+ `Manager.ClassifiedBinds` / `StackDataProvider.GetStackClassifiedBinds` seam exist, but no backup
+ tier consumes them yet (Task 3 = tier policy engine, Task 4 = manual `.fab` UI). See
+ `felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`.
The `/apps/{slug}` page renders hero section, screenshots, setup guide, and optional config form.
diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go
index 28b1c0c..0142bf3 100644
--- a/controller/cmd/controller/main.go
+++ b/controller/cmd/controller/main.go
@@ -1103,6 +1103,12 @@ func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
return s.ComposePath, true
}
+// GetStackClassifiedBinds delegates to the stacks manager's backup-classification resolver (Task 2).
+// INERT — no backup tier calls this yet; wired so Task 3 consumes a tested seam.
+func (a *stackAdapter) GetStackClassifiedBinds(name string) ([]backup.ClassifiedBind, bool) {
+ return a.mgr.ClassifiedBinds(name)
+}
+
func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary {
var result []backup.StackSummary
for _, s := range a.mgr.GetStacks() {
diff --git a/controller/internal/api/backup_snapshots_test.go b/controller/internal/api/backup_snapshots_test.go
index 5f1ae93..e918dcd 100644
--- a/controller/internal/api/backup_snapshots_test.go
+++ b/controller/internal/api/backup_snapshots_test.go
@@ -34,6 +34,9 @@ func (p *snapshotsStubProvider) RefreshAndIsRunning(string) bool { ret
func (p *snapshotsStubProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
+func (p *snapshotsStubProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
+ return nil, false
+}
func (p *snapshotsStubProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *snapshotsStubProvider) RecreateStackFromUnit(string, string, map[string]string) error {
return nil
diff --git a/controller/internal/appbackup/appdata.go b/controller/internal/appbackup/appdata.go
index b4afb43..67adae7 100644
--- a/controller/internal/appbackup/appdata.go
+++ b/controller/internal/appbackup/appdata.go
@@ -43,6 +43,11 @@ type StackDataProvider interface {
// dir, writes app.yaml from fullEnv (encrypting secret fields), and (re-)deploys it via
// `docker compose up -d`, which re-pulls the pinned image. Secrets are NEVER regenerated.
RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
+
+ // GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a
+ // (valid) backup block (Task 2, referential coupling). INERT — no tier consumes it yet; wired now
+ // so Task 3 gets a tested seam. Implemented by delegating to stacks.Manager.ClassifiedBinds.
+ GetStackClassifiedBinds(name string) ([]ClassifiedBind, bool)
}
// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
diff --git a/controller/internal/appbackup/classify.go b/controller/internal/appbackup/classify.go
new file mode 100644
index 0000000..7398062
--- /dev/null
+++ b/controller/internal/appbackup/classify.go
@@ -0,0 +1,197 @@
+package appbackup
+
+import (
+ "fmt"
+ "path"
+ "strings"
+)
+
+// Backup classification (referential coupling) — Task 2 of the backup-classification-redesign arc
+// (felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). This file is the SCHEMA
+// + PURE CLASSIFIER only; it is deliberately INERT — no backup tier consumes it yet. Task 3 (tier
+// policy engine) and Task 4 (manual .fab UI) are the consumers. Classes describe how a bind couples
+// to the app's referential state:
+//
+// - mandatory: COUPLED — restoring the app WITHOUT this bind yields a broken (not merely empty)
+// app, because the DB/state references the content (SQ3: immich DB-only restore = broken).
+// - optional: DECOUPLED-precious — absent ⇒ empty-not-broken, but the content is user-precious
+// (not re-downloadable): an external photo library, a curated comic/ROM set.
+// - excluded: DECOUPLED-bulk/transient — re-downloadable media, scraper caches, ingest inboxes,
+// transient export/download dirs; never shipped offsite, opt-in only for a manual .fab.
+
+// BindClass is the referential-coupling class of a single host bind.
+type BindClass string
+
+const (
+ ClassMandatory BindClass = "mandatory" // COUPLED: restore-without is broken, not empty (SQ3)
+ ClassOptional BindClass = "optional" // DECOUPLED-precious: empty-not-broken, not re-downloadable
+ ClassExcluded BindClass = "excluded" // DECOUPLED-bulk/transient: never offsite, .fab opt-in
+)
+
+// BindRoot names the deploy-time variable a bind's host path is relative to.
+type BindRoot string
+
+const (
+ RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH}
+ RootHDD BindRoot = "hdd" // relative to ${HDD_PATH}
+)
+
+// BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd.
+type BackupSpec struct {
+ Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"`
+ HDD []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"`
+}
+
+// BindSpec is one classified entry in a BackupSpec.
+type BindSpec struct {
+ Path string `yaml:"path" json:"path"`
+ Class BindClass `yaml:"class" json:"class"`
+}
+
+// ComposeBind is a ${VAR}-relative host bind extracted from docker-compose.yml (Part 2 parser). It
+// lives in relative ${VAR} space (NOT resolved to an absolute path) and carries the :ro flag, both of
+// which the classifier needs — this is why the classifier does NOT reuse ParseComposeHDDMounts (which
+// resolves absolutes and drops the mode).
+type ComposeBind struct {
+ Root BindRoot
+ RelPath string // path.Clean'd, forward-slash, relative; "" for a bare-root bind (${VAR} itself)
+ ReadOnly bool
+}
+
+// ClassOrigin records HOW a bind's class was decided — for logs/UI and to prove the precedence rule.
+type ClassOrigin string
+
+const (
+ OriginExplicit ClassOrigin = "explicit" // matched an entry in the backup block
+ OriginDefaultWritable ClassOrigin = "default_writable" // unlisted + writable → mandatory (capture)
+ OriginDefaultRO ClassOrigin = "default_ro" // unlisted + :ro → excluded (reader rule)
+ OriginLegacy ClassOrigin = "legacy" // no backup block at all → no class semantics
+)
+
+// ClassifiedBind pairs a compose bind with its resolved class + origin.
+type ClassifiedBind struct {
+ ComposeBind
+ Class BindClass
+ Origin ClassOrigin
+}
+
+// validClass reports whether c is one of the three known classes (empty is INVALID — a typoed
+// `clas:` key makes yaml.v3 silently leave Class "", which must be rejected, not defaulted).
+func validClass(c BindClass) bool {
+ switch c {
+ case ClassMandatory, ClassOptional, ClassExcluded:
+ return true
+ default:
+ return false
+ }
+}
+
+// ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns
+// the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the
+// app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy).
+//
+// Rejects: unknown/empty class; empty path; a path that is not already path.Clean'd, or is absolute,
+// or contains "..", or contains a backslash; a duplicate (root, path); an entry whose (root, path)
+// matches NO compose bind (a typo/stale entry must not silently shift the real bind onto the
+// mandatory default). Match is exact (Root, RelPath) equality.
+func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error {
+ if spec == nil {
+ return nil
+ }
+ present := make(map[BindRoot]map[string]bool)
+ for _, b := range binds {
+ if present[b.Root] == nil {
+ present[b.Root] = make(map[string]bool)
+ }
+ present[b.Root][b.RelPath] = true
+ }
+
+ seen := make(map[string]bool) // "\x00"
+ check := func(root BindRoot, list []BindSpec) error {
+ for _, e := range list {
+ where := fmt.Sprintf("%s[%q]", root, e.Path)
+ if !validClass(e.Class) {
+ return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class)
+ }
+ if e.Path == "" {
+ return fmt.Errorf("%s: empty path", where)
+ }
+ if strings.ContainsRune(e.Path, '\\') {
+ return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where)
+ }
+ if path.IsAbs(e.Path) {
+ return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root)
+ }
+ if e.Path != path.Clean(e.Path) {
+ return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(e.Path))
+ }
+ // path.Clean has run — ".." can only survive as a leading "../" segment.
+ if e.Path == ".." || strings.HasPrefix(e.Path, "../") {
+ return fmt.Errorf("%s: path escapes the root (..)", where)
+ }
+ key := string(root) + "\x00" + e.Path
+ if seen[key] {
+ return fmt.Errorf("%s: duplicate path in the backup block", where)
+ }
+ seen[key] = true
+ if !present[root][e.Path] {
+ return fmt.Errorf("%s: matches no compose bind (stale or typoed path)", where)
+ }
+ }
+ return nil
+ }
+ if err := check(RootUserdata, spec.Userdata); err != nil {
+ return err
+ }
+ return check(RootHDD, spec.HDD)
+}
+
+// ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The
+// second return reports whether the app carries a backup block at all.
+//
+// - spec == nil → every bind is emitted with Origin=legacy and an EMPTY Class (no class semantics),
+// and hasClassification=false. This is the block-ABSENT branch: nothing downstream may change
+// behavior for it (SQ5 two-level default — no block means today's per-tier legacy behavior).
+// - spec present → an explicit block entry ALWAYS wins, regardless of the bind's :ro flag (an
+// explicit `optional` on immich's :ro external library beats the reader default). An UNLISTED
+// bind defaults by mode: writable → mandatory (default_writable — the C6B-F1 direction: capture
+// rather than silently drop), read-only → excluded (default_ro — reader rule, SQ2).
+//
+// Pure. Assumes a validated spec (see ValidateBackupSpec) but never panics on an unvalidated one:
+// unmatched/invalid spec entries simply don't match any bind here.
+//
+// A bare-root bind (RelPath "") can never be matched by an explicit entry — an empty path is invalid
+// in the spec — so it always falls to the ro/writable default.
+func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []ClassifiedBind, hasClassification bool) {
+ out := make([]ClassifiedBind, 0, len(binds))
+ if spec == nil {
+ for _, b := range binds {
+ out = append(out, ClassifiedBind{ComposeBind: b, Origin: OriginLegacy})
+ }
+ return out, false
+ }
+ explicit := make(map[BindRoot]map[string]BindClass)
+ add := func(root BindRoot, list []BindSpec) {
+ for _, e := range list {
+ if explicit[root] == nil {
+ explicit[root] = make(map[string]BindClass)
+ }
+ explicit[root][e.Path] = e.Class
+ }
+ }
+ add(RootUserdata, spec.Userdata)
+ add(RootHDD, spec.HDD)
+
+ for _, b := range binds {
+ cb := ClassifiedBind{ComposeBind: b}
+ if cls, ok := explicit[b.Root][b.RelPath]; ok {
+ cb.Class, cb.Origin = cls, OriginExplicit
+ } else if b.ReadOnly {
+ cb.Class, cb.Origin = ClassExcluded, OriginDefaultRO
+ } else {
+ cb.Class, cb.Origin = ClassMandatory, OriginDefaultWritable
+ }
+ out = append(out, cb)
+ }
+ return out, true
+}
diff --git a/controller/internal/appbackup/classify_test.go b/controller/internal/appbackup/classify_test.go
new file mode 100644
index 0000000..bb8899c
--- /dev/null
+++ b/controller/internal/appbackup/classify_test.go
@@ -0,0 +1,165 @@
+package appbackup
+
+import (
+ "strings"
+ "testing"
+)
+
+// classOf finds the resolved class+origin for a (root, relpath) in a ClassifiedBind slice.
+func classOf(cbs []ClassifiedBind, root BindRoot, rel string) (BindClass, ClassOrigin, bool) {
+ for _, c := range cbs {
+ if c.Root == root && c.RelPath == rel {
+ return c.Class, c.Origin, true
+ }
+ }
+ return "", "", false
+}
+
+// --- Group A: classifier ---
+
+// TestClassify_ImmichShape is Scenario A: explicit classes resolve, and an EXPLICIT entry beats the
+// :ro reader-default (media/photos is :ro but ruled optional). Companion RP-2: making the ro-default
+// override explicit entries forces media/photos to excluded and fails the optional assertion.
+func TestClassify_ImmichShape(t *testing.T) {
+ binds := []ComposeBind{
+ {Root: RootHDD, RelPath: "appdata/immich", ReadOnly: false},
+ {Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, // :ro external library
+ }
+ spec := &BackupSpec{
+ HDD: []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}},
+ Userdata: []BindSpec{{Path: "media/photos", Class: ClassOptional}},
+ }
+ cbs, has := ClassifyBinds(spec, binds)
+ if !has {
+ t.Fatal("hasClassification should be true with a spec present")
+ }
+ if cls, org, ok := classOf(cbs, RootHDD, "appdata/immich"); !ok || cls != ClassMandatory || org != OriginExplicit {
+ t.Errorf("appdata/immich = %v/%v, want mandatory/explicit", cls, org)
+ }
+ // The crux: an explicit optional beats the :ro default_ro that would otherwise force excluded.
+ if cls, org, ok := classOf(cbs, RootUserdata, "media/photos"); !ok || cls != ClassOptional || org != OriginExplicit {
+ t.Errorf("media/photos (:ro, explicit optional) = %v/%v, want optional/explicit (explicit beats ro-default)", cls, org)
+ }
+}
+
+// TestClassify_TwoLevelDefault is Scenario B: with a block PRESENT, an unlisted writable bind
+// defaults mandatory (capture, the C6B-F1 direction) and an unlisted :ro bind defaults excluded
+// (reader rule). Companion RP-3: flipping the unlisted-writable default to excluded fails the
+// mandatory assertion.
+func TestClassify_TwoLevelDefault(t *testing.T) {
+ binds := []ComposeBind{
+ {Root: RootHDD, RelPath: "appdata/app", ReadOnly: false}, // listed
+ {Root: RootUserdata, RelPath: "data/extra", ReadOnly: false}, // UNLISTED writable
+ {Root: RootUserdata, RelPath: "media/ro", ReadOnly: true}, // UNLISTED :ro
+ }
+ spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/app", Class: ClassMandatory}}}
+ cbs, has := ClassifyBinds(spec, binds)
+ if !has {
+ t.Fatal("hasClassification should be true")
+ }
+ if cls, org, _ := classOf(cbs, RootUserdata, "data/extra"); cls != ClassMandatory || org != OriginDefaultWritable {
+ t.Errorf("unlisted writable = %v/%v, want mandatory/default_writable (capture direction)", cls, org)
+ }
+ if cls, org, _ := classOf(cbs, RootUserdata, "media/ro"); cls != ClassExcluded || org != OriginDefaultRO {
+ t.Errorf("unlisted :ro = %v/%v, want excluded/default_ro (reader rule)", cls, org)
+ }
+}
+
+// TestClassify_NilSpecLegacy is Scenario C: a nil spec → every bind is legacy with no class, and
+// hasClassification=false. This is the inertness gate at the classifier level.
+func TestClassify_NilSpecLegacy(t *testing.T) {
+ binds := []ComposeBind{{Root: RootUserdata, RelPath: "media", ReadOnly: true}}
+ cbs, has := ClassifyBinds(nil, binds)
+ if has {
+ t.Error("nil spec must report hasClassification=false")
+ }
+ if len(cbs) != 1 || cbs[0].Origin != OriginLegacy || cbs[0].Class != "" {
+ t.Errorf("nil-spec bind = %+v, want origin=legacy, empty class", cbs[0])
+ }
+}
+
+// TestClassify_BareRootFallsToDefault: a bare-root bind (RelPath "") can't be matched by any explicit
+// entry (empty paths are invalid), so it falls to the ro/writable default.
+func TestClassify_BareRootFallsToDefault(t *testing.T) {
+ spec := &BackupSpec{Userdata: []BindSpec{{Path: "media/x", Class: ClassOptional}}}
+ cbs, _ := ClassifyBinds(spec, []ComposeBind{
+ {Root: RootUserdata, RelPath: "", ReadOnly: false}, // bare ${USERDATA_PATH}
+ {Root: RootHDD, RelPath: "", ReadOnly: true}, // bare ${HDD_PATH} :ro
+ })
+ if cls, org, _ := classOf(cbs, RootUserdata, ""); cls != ClassMandatory || org != OriginDefaultWritable {
+ t.Errorf("bare writable root = %v/%v, want mandatory/default_writable", cls, org)
+ }
+ if cls, org, _ := classOf(cbs, RootHDD, ""); cls != ClassExcluded || org != OriginDefaultRO {
+ t.Errorf("bare :ro root = %v/%v, want excluded/default_ro", cls, org)
+ }
+}
+
+// TestClassify_SameRelPathBothRoots: userdata/x and hdd/x are DISTINCT binds — Root is part of
+// identity, so an explicit hdd entry must not classify the userdata bind.
+func TestClassify_SameRelPathBothRoots(t *testing.T) {
+ binds := []ComposeBind{
+ {Root: RootUserdata, RelPath: "shared", ReadOnly: false},
+ {Root: RootHDD, RelPath: "shared", ReadOnly: false},
+ }
+ spec := &BackupSpec{HDD: []BindSpec{{Path: "shared", Class: ClassExcluded}}}
+ cbs, _ := ClassifyBinds(spec, binds)
+ if cls, org, _ := classOf(cbs, RootHDD, "shared"); cls != ClassExcluded || org != OriginExplicit {
+ t.Errorf("hdd/shared = %v/%v, want excluded/explicit", cls, org)
+ }
+ if cls, org, _ := classOf(cbs, RootUserdata, "shared"); cls != ClassMandatory || org != OriginDefaultWritable {
+ t.Errorf("userdata/shared = %v/%v, want mandatory/default_writable (hdd entry must NOT match it)", cls, org)
+ }
+}
+
+// --- Group B: validation (Scenario D) — every defect rejects the WHOLE block; error names the entry ---
+
+func TestValidateBackupSpec_Defects(t *testing.T) {
+ // The compose binds the valid entries reference (so only the seeded defect is the failure).
+ binds := []ComposeBind{
+ {Root: RootUserdata, RelPath: "media/tv"},
+ {Root: RootHDD, RelPath: "appdata/x"},
+ }
+ cases := []struct {
+ name string
+ spec *BackupSpec
+ wantFrag string // substring the error must contain (the offending entry / rule)
+ }{
+ {"unknown class", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: "keepit"}}}, "invalid class"},
+ {"empty class (typoed key)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: ""}}}, "invalid class"},
+ {"empty path", &BackupSpec{HDD: []BindSpec{{Path: "", Class: ClassMandatory}}}, "empty path"},
+ {"absolute path", &BackupSpec{HDD: []BindSpec{{Path: "/etc/x", Class: ClassMandatory}}}, "absolute"},
+ {"dotdot path", &BackupSpec{HDD: []BindSpec{{Path: "../escape", Class: ClassMandatory}}}, "escapes"},
+ {"backslash path", &BackupSpec{HDD: []BindSpec{{Path: "appdata\\x", Class: ClassMandatory}}}, "backslash"},
+ {"non-clean path", &BackupSpec{HDD: []BindSpec{{Path: "appdata/./x", Class: ClassMandatory}}}, "non-clean"},
+ {"duplicate path", &BackupSpec{HDD: []BindSpec{
+ {Path: "appdata/x", Class: ClassMandatory}, {Path: "appdata/x", Class: ClassExcluded},
+ }}, "duplicate"},
+ {"no matching bind (typo)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tvv", Class: ClassExcluded}}}, "matches no compose bind"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := ValidateBackupSpec(tc.spec, binds)
+ if err == nil {
+ t.Fatalf("expected rejection, got nil")
+ }
+ if !strings.Contains(err.Error(), tc.wantFrag) {
+ t.Errorf("error %q must contain %q", err.Error(), tc.wantFrag)
+ }
+ })
+ }
+}
+
+// TestValidateBackupSpec_ValidAndNil: a clean block validates, and a nil spec is vacuously valid.
+func TestValidateBackupSpec_ValidAndNil(t *testing.T) {
+ binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/x"}, {Root: RootUserdata, RelPath: "media/tv"}}
+ spec := &BackupSpec{
+ HDD: []BindSpec{{Path: "appdata/x", Class: ClassMandatory}},
+ Userdata: []BindSpec{{Path: "media/tv", Class: ClassExcluded}},
+ }
+ if err := ValidateBackupSpec(spec, binds); err != nil {
+ t.Errorf("clean block should validate: %v", err)
+ }
+ if err := ValidateBackupSpec(nil, binds); err != nil {
+ t.Errorf("nil spec must be vacuously valid: %v", err)
+ }
+}
diff --git a/controller/internal/backup/appbackup_bridge.go b/controller/internal/backup/appbackup_bridge.go
index 60f0c59..90b81df 100644
--- a/controller/internal/backup/appbackup_bridge.go
+++ b/controller/internal/backup/appbackup_bridge.go
@@ -27,6 +27,7 @@ type AppBackupInfo = appbackup.AppBackupInfo
type AppDataPath = appbackup.AppDataPath
type AppDockerVolume = appbackup.AppDockerVolume
type RecoveryInfo = appbackup.RecoveryInfo
+type ClassifiedBind = appbackup.ClassifiedBind
// --- type aliases (dbdump) ---
diff --git a/controller/internal/backup/recovery_unit_test.go b/controller/internal/backup/recovery_unit_test.go
index dc3f492..912a128 100644
--- a/controller/internal/backup/recovery_unit_test.go
+++ b/controller/internal/backup/recovery_unit_test.go
@@ -34,6 +34,9 @@ func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.ru
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return f.info, true
}
+func (f *fakeRecoveryProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) {
+ return nil, false
+}
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
return f.secrets
}
diff --git a/controller/internal/backup/tier2_restore_test.go b/controller/internal/backup/tier2_restore_test.go
index 02fc17f..650a83d 100644
--- a/controller/internal/backup/tier2_restore_test.go
+++ b/controller/internal/backup/tier2_restore_test.go
@@ -42,7 +42,8 @@ func (f *t2rFakeProvider) RefreshAndIsRunning(string) bool { return true }
func (f *t2rFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{}, false
}
-func (f *t2rFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
+func (f *t2rFakeProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) { return nil, false }
+func (f *t2rFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (f *t2rFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
return nil
}
diff --git a/controller/internal/backup/volume_dumps_test.go b/controller/internal/backup/volume_dumps_test.go
index 0582d0b..2429a81 100644
--- a/controller/internal/backup/volume_dumps_test.go
+++ b/controller/internal/backup/volume_dumps_test.go
@@ -35,6 +35,9 @@ func (f *volDumpFakeProvider) RefreshAndIsRunning(string) bool { return true }
func (f *volDumpFakeProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
return RecoveryInfo{}, false
}
+func (f *volDumpFakeProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) {
+ return nil, false
+}
func (f *volDumpFakeProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (f *volDumpFakeProvider) RecreateStackFromUnit(string, string, map[string]string) error {
return nil
diff --git a/controller/internal/stacks/classify_binds.go b/controller/internal/stacks/classify_binds.go
new file mode 100644
index 0000000..fb84579
--- /dev/null
+++ b/controller/internal/stacks/classify_binds.go
@@ -0,0 +1,115 @@
+package stacks
+
+import (
+ "bufio"
+ "os"
+ "path"
+ "strings"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
+)
+
+// composeVarRoots maps the two deploy-time path variables to their classification root. A bind's
+// host side must reference one of these LITERALLY (the classifier works in ${VAR}-relative space —
+// it does NOT resolve envs, unlike ParseComposeHDDMounts). ${USERDATA_PATH} is /userdata
+// at deploy, but in the compose file the two are written distinctly, so they are distinct roots.
+var composeVarRoots = []struct {
+ varRef string
+ root appbackup.BindRoot
+}{
+ {"${USERDATA_PATH}", appbackup.RootUserdata},
+ {"${HDD_PATH}", appbackup.RootHDD},
+}
+
+// ParseComposeClassifiableBinds extracts the ${HDD_PATH}/${USERDATA_PATH}-relative host binds from a
+// docker-compose.yml, for backup classification (Part 2 of the classification arc). It copies the
+// ParseComposeUserdataMounts scanner shape (service-level `volumes:` section, `- ` short-syntax
+// lines, quote-trim, `SplitN(":",3)`) but stays in RELATIVE ${VAR} space and preserves the `:ro`
+// flag — both of which the classifier needs and which ParseComposeHDDMounts discards.
+//
+// RelPath is the path.Clean'd remainder after the variable (leading "/" stripped; "" for a bare-root
+// bind). ReadOnly is true iff the mode field (parts[2]) contains a `ro` token. Deduped on
+// (Root, RelPath) — the FIRST occurrence's ReadOnly wins (the catalog never mixes modes for one
+// path; noted so a future mixed case is a conscious change, not a silent one).
+//
+// Long-syntax volumes (`type: bind`) are NOT supported — parity with every existing compose parser;
+// the catalog uses short syntax only. Pure given the file bytes (no env resolution, no FS beyond the
+// read).
+func ParseComposeClassifiableBinds(composePath string) []appbackup.ComposeBind {
+ data, err := os.ReadFile(composePath)
+ if err != nil {
+ return nil
+ }
+ var binds []appbackup.ComposeBind
+ seen := make(map[string]bool) // "\x00"
+
+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
+ inVolumes := false
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if strings.HasPrefix(line, "volumes:") {
+ inVolumes = true
+ continue
+ }
+ if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
+ inVolumes = false
+ }
+ if !inVolumes || !strings.HasPrefix(line, "- ") {
+ continue
+ }
+ mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
+ parts := strings.SplitN(mountStr, ":", 3)
+ if len(parts) < 2 {
+ continue
+ }
+ root, relPath, ok := classifyRoot(parts[0])
+ if !ok {
+ continue
+ }
+ readOnly := len(parts) == 3 && modeIsReadOnly(parts[2])
+ key := string(root) + "\x00" + relPath
+ if seen[key] {
+ continue // first occurrence's ReadOnly wins
+ }
+ seen[key] = true
+ binds = append(binds, appbackup.ComposeBind{Root: root, RelPath: relPath, ReadOnly: readOnly})
+ }
+ return binds
+}
+
+// classifyRoot resolves a compose host-side token to its (root, relpath) if it references one of the
+// classification variables exactly or as a "/"-separated prefix. relPath is path.Clean'd, "" for a
+// bare root.
+func classifyRoot(hostPath string) (appbackup.BindRoot, string, bool) {
+ for _, v := range composeVarRoots {
+ var rem string
+ switch {
+ case hostPath == v.varRef:
+ rem = ""
+ case strings.HasPrefix(hostPath, v.varRef+"/"):
+ rem = strings.TrimPrefix(hostPath, v.varRef+"/")
+ default:
+ continue
+ }
+ if rem == "" {
+ return v.root, "", true
+ }
+ rel := path.Clean(rem)
+ if rel == "." {
+ rel = ""
+ }
+ return v.root, rel, true
+ }
+ return "", "", false
+}
+
+// modeIsReadOnly reports whether a docker volume mode field (e.g. "ro", "rw", "ro,z", "z") carries a
+// `ro` token.
+func modeIsReadOnly(mode string) bool {
+ for _, tok := range strings.Split(mode, ",") {
+ if strings.TrimSpace(tok) == "ro" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/controller/internal/stacks/classify_binds_test.go b/controller/internal/stacks/classify_binds_test.go
new file mode 100644
index 0000000..38bca55
--- /dev/null
+++ b/controller/internal/stacks/classify_binds_test.go
@@ -0,0 +1,104 @@
+package stacks
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
+)
+
+func writeClassCompose(t *testing.T, body string) string {
+ t.Helper()
+ dir := t.TempDir()
+ p := filepath.Join(dir, "docker-compose.yml")
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func findBind(binds []appbackup.ComposeBind, root appbackup.BindRoot, rel string) (appbackup.ComposeBind, bool) {
+ for _, b := range binds {
+ if b.Root == root && b.RelPath == rel {
+ return b, true
+ }
+ }
+ return appbackup.ComposeBind{}, false
+}
+
+// TestParseClassifiableBinds_Variants (Group C): short-syntax variants, quotes, mode tokens
+// (ro/rw/ro,z/z), dedupe, bare root, both roots, and non-volume sections ignored.
+func TestParseClassifiableBinds_Variants(t *testing.T) {
+ compose := `
+services:
+ app:
+ image: x:1
+ environment:
+ - HDD_PATH=${HDD_PATH} # NOT a volume — must be ignored
+ volumes:
+ - ${HDD_PATH}/appdata/immich:/upload
+ - "${USERDATA_PATH}/media/photos:/external:ro"
+ - ${USERDATA_PATH}/media/music:/music:ro,z
+ - ${USERDATA_PATH}/downloads:/dl:z
+ - ${USERDATA_PATH}/downloads:/dl2 # duplicate (root,relpath) — deduped, first wins
+ - ${HDD_PATH}:/hddroot # bare root
+ - ./local-only:/x # not a classifiable var — ignored
+ - named_vol:/data # named volume — ignored
+ ports:
+ - 8080:80 # ports section — not volumes
+`
+ binds := ParseComposeClassifiableBinds(writeClassCompose(t, compose))
+
+ // Expect exactly: hdd/appdata/immich, userdata/media/photos(ro), userdata/media/music(ro),
+ // userdata/downloads(writable, first occ), hdd/"" (bare root).
+ if len(binds) != 5 {
+ t.Fatalf("got %d binds, want 5: %+v", len(binds), binds)
+ }
+ if b, ok := findBind(binds, appbackup.RootHDD, "appdata/immich"); !ok || b.ReadOnly {
+ t.Errorf("appdata/immich = %+v, want writable", b)
+ }
+ if b, ok := findBind(binds, appbackup.RootUserdata, "media/photos"); !ok || !b.ReadOnly {
+ t.Errorf("media/photos = %+v, want ro", b)
+ }
+ if b, ok := findBind(binds, appbackup.RootUserdata, "media/music"); !ok || !b.ReadOnly {
+ t.Errorf("media/music (ro,z) = %+v, want ro", b)
+ }
+ // downloads: first occurrence was `:z` (writable) — dedupe keeps the first, so writable wins.
+ if b, ok := findBind(binds, appbackup.RootUserdata, "downloads"); !ok || b.ReadOnly {
+ t.Errorf("downloads = %+v, want single writable entry (first-wins dedupe)", b)
+ }
+ if b, ok := findBind(binds, appbackup.RootHDD, ""); !ok || b.ReadOnly {
+ t.Errorf("bare ${HDD_PATH} = %+v, want RelPath '' writable", b)
+ }
+}
+
+// TestParseClassifiableBinds_CleanAndSubpaths: nested paths clean correctly; a false-prefix var
+// (${HDD_PATH_X}) does NOT match.
+func TestParseClassifiableBinds_CleanAndSubpaths(t *testing.T) {
+ compose := `
+services:
+ app:
+ volumes:
+ - ${HDD_PATH}/appdata/paperless/media:/m
+ - ${HDD_PATH_EXTRA}/nope:/n # false prefix — must NOT match ${HDD_PATH}
+ - ${USERDATA_PATH}/import/calibre:/i:rw
+`
+ binds := ParseComposeClassifiableBinds(writeClassCompose(t, compose))
+ if len(binds) != 2 {
+ t.Fatalf("got %d binds, want 2 (false-prefix excluded): %+v", len(binds), binds)
+ }
+ if _, ok := findBind(binds, appbackup.RootHDD, "appdata/paperless/media"); !ok {
+ t.Errorf("nested hdd path missing: %+v", binds)
+ }
+ if b, ok := findBind(binds, appbackup.RootUserdata, "import/calibre"); !ok || b.ReadOnly {
+ t.Errorf("import/calibre (rw) = %+v, want writable", b)
+ }
+}
+
+// TestParseClassifiableBinds_Missing: an unreadable compose yields nil (no panic).
+func TestParseClassifiableBinds_Missing(t *testing.T) {
+ if b := ParseComposeClassifiableBinds(filepath.Join(t.TempDir(), "nope.yml")); b != nil {
+ t.Errorf("missing compose should yield nil, got %+v", b)
+ }
+}
diff --git a/controller/internal/stacks/classify_wiring_test.go b/controller/internal/stacks/classify_wiring_test.go
new file mode 100644
index 0000000..11628c0
--- /dev/null
+++ b/controller/internal/stacks/classify_wiring_test.go
@@ -0,0 +1,217 @@
+package stacks
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/config"
+)
+
+// newClassifyManager registers a single stack "app" whose .felhom.yml + docker-compose.yml are
+// written to a temp dir, so Manager.ClassifiedBinds runs the REAL LoadMetadata → validate → classify
+// path end-to-end (NO seams) — the F-S3 lesson: the wiring is where typos hide.
+func newClassifyManager(t *testing.T, felhomYML, compose string) *Manager {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(felhomYML), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ composePath := filepath.Join(dir, "docker-compose.yml")
+ if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return &Manager{
+ cfg: &config.Config{},
+ logger: log.New(os.Stderr, "", 0),
+ mu: sync.RWMutex{},
+ stacks: map[string]*Stack{"app": {Name: "app", ComposePath: composePath}},
+ }
+}
+
+func classSet(cbs []appbackup.ClassifiedBind) map[string]appbackup.BindClass {
+ m := map[string]appbackup.BindClass{}
+ for _, c := range cbs {
+ m[string(c.Root)+"/"+c.RelPath] = c.Class
+ }
+ return m
+}
+
+// --- Group D: end-to-end wiring (NO seams) ---
+
+// TestClassifiedBinds_ValidFixture (Group D, immich shape): a clean block resolves through the REAL
+// LoadMetadata path to the expected explicit classes.
+func TestClassifiedBinds_ValidFixture(t *testing.T) {
+ felhom := `display_name: Immich
+backup:
+ hdd:
+ - path: appdata/immich
+ class: mandatory
+ userdata:
+ - path: media/photos
+ class: optional
+`
+ compose := `services:
+ immich:
+ volumes:
+ - ${HDD_PATH}/appdata/immich:/usr/src/app/upload
+ - ${USERDATA_PATH}/media/photos:/external/photos:ro
+`
+ m := newClassifyManager(t, felhom, compose)
+ cbs, has := m.ClassifiedBinds("app")
+ if !has {
+ t.Fatal("valid block must classify (hasClassification=true)")
+ }
+ got := classSet(cbs)
+ if got["hdd/appdata/immich"] != appbackup.ClassMandatory {
+ t.Errorf("appdata/immich = %q, want mandatory", got["hdd/appdata/immich"])
+ }
+ if got["userdata/media/photos"] != appbackup.ClassOptional {
+ t.Errorf("media/photos = %q, want optional (explicit beats :ro default)", got["userdata/media/photos"])
+ }
+}
+
+// TestClassifiedBinds_RejectedFixture (Group D + RP-4): a block whose entry matches no compose bind
+// is rejected by the REAL LoadMetadata choke point → the app degrades to legacy (hasClassification
+// false, no class semantics). Companion RP-4: if LoadMetadata accepts meta.Backup WITHOUT validating,
+// this returns hasClassification=true and the assertion FAILS.
+func TestClassifiedBinds_RejectedFixture(t *testing.T) {
+ felhom := `display_name: Sonarr
+backup:
+ userdata:
+ - path: media/tvv
+ class: excluded
+`
+ compose := `services:
+ sonarr:
+ volumes:
+ - ${USERDATA_PATH}/media/tv:/tv
+`
+ m := newClassifyManager(t, felhom, compose)
+ cbs, has := m.ClassifiedBinds("app")
+ if has {
+ t.Fatal("a block with an unmatched path must be REJECTED → legacy (hasClassification=false)")
+ }
+ // The real bind is still returned, but as legacy (no class) — never silently shifted to a default.
+ for _, c := range cbs {
+ if c.Origin != appbackup.OriginLegacy || c.Class != "" {
+ t.Errorf("rejected-block bind = %+v, want origin=legacy, empty class", c)
+ }
+ }
+}
+
+// TestClassifiedBinds_NoBlockLegacy: an app with no backup block classifies as legacy (inertness).
+func TestClassifiedBinds_NoBlockLegacy(t *testing.T) {
+ felhom := `display_name: Navidrome`
+ compose := `services:
+ nav:
+ volumes:
+ - ${USERDATA_PATH}/media/music:/music:ro
+`
+ m := newClassifyManager(t, felhom, compose)
+ if _, has := m.ClassifiedBinds("app"); has {
+ t.Error("no backup block → hasClassification must be false (legacy)")
+ }
+}
+
+// --- Group E: catalog fidelity (≥3 real blocks) ---
+
+// TestCatalogBlocks_Fidelity pins the exact class sets of representative Part-4 blocks (immich,
+// paperless-ngx, sonarr) against representative composes — the same content committed to the catalog.
+// The full 13-app proof is the Part-4 cross-check against the live catalog clone (§13 + REPORT).
+func TestCatalogBlocks_Fidelity(t *testing.T) {
+ cases := []struct {
+ name string
+ felhom string
+ compose string
+ want map[string]appbackup.BindClass
+ }{
+ {
+ name: "immich",
+ felhom: `backup:
+ hdd:
+ - path: appdata/immich
+ class: mandatory
+ userdata:
+ - path: media/photos
+ class: optional
+`,
+ compose: `services:
+ immich:
+ volumes:
+ - ${HDD_PATH}/appdata/immich:/usr/src/app/upload
+ - ${USERDATA_PATH}/media/photos:/external/photos:ro
+`,
+ want: map[string]appbackup.BindClass{
+ "hdd/appdata/immich": appbackup.ClassMandatory,
+ "userdata/media/photos": appbackup.ClassOptional,
+ },
+ },
+ {
+ name: "paperless-ngx",
+ felhom: `backup:
+ hdd:
+ - path: appdata/paperless/media
+ class: mandatory
+ - path: appdata/paperless/export
+ class: excluded
+ userdata:
+ - path: import/paperless
+ class: excluded
+`,
+ compose: `services:
+ webserver:
+ volumes:
+ - ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
+ - ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
+ - ${USERDATA_PATH}/import/paperless:/usr/src/paperless/consume
+`,
+ want: map[string]appbackup.BindClass{
+ "hdd/appdata/paperless/media": appbackup.ClassMandatory,
+ "hdd/appdata/paperless/export": appbackup.ClassExcluded,
+ "userdata/import/paperless": appbackup.ClassExcluded,
+ },
+ },
+ {
+ name: "sonarr",
+ felhom: `backup:
+ userdata:
+ - path: media/tv
+ class: excluded
+ - path: downloads
+ class: excluded
+`,
+ compose: `services:
+ sonarr:
+ volumes:
+ - ${USERDATA_PATH}/media/tv:/tv
+ - ${USERDATA_PATH}/downloads:/downloads
+`,
+ want: map[string]appbackup.BindClass{
+ "userdata/media/tv": appbackup.ClassExcluded,
+ "userdata/downloads": appbackup.ClassExcluded,
+ },
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ m := newClassifyManager(t, tc.felhom, tc.compose)
+ cbs, has := m.ClassifiedBinds("app")
+ if !has {
+ t.Fatalf("%s block must classify clean", tc.name)
+ }
+ got := classSet(cbs)
+ if len(got) != len(tc.want) {
+ t.Errorf("%s: got %d binds, want %d: %v", tc.name, len(got), len(tc.want), got)
+ }
+ for k, want := range tc.want {
+ if got[k] != want {
+ t.Errorf("%s: %s = %q, want %q", tc.name, k, got[k], want)
+ }
+ }
+ })
+ }
+}
diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go
index 4e7c172..351520d 100644
--- a/controller/internal/stacks/metadata.go
+++ b/controller/internal/stacks/metadata.go
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gopkg.in/yaml.v3"
)
@@ -32,6 +33,11 @@ type Metadata struct {
// SMTPMapping declares how this app's compose env receives the managed app-email relay settings.
// Present only for apps that support outbound email; absent = the app has no email UI/injection.
SMTPMapping *SMTPMapping `yaml:"smtp_mapping,omitempty" json:"smtp_mapping,omitempty"`
+ // Backup is the referential-coupling classification block (Task 2). Present only for the 13
+ // bind-bearing apps; nil = legacy behavior (SQ5 two-level default). LoadMetadata REJECTS the whole
+ // block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
+ // degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
+ Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
}
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
@@ -250,9 +256,43 @@ func LoadMetadata(stackDir string) Metadata {
}
}
+ // Backup classification (Task 2): the SINGLE validation choke point. Catalog listing, the
+ // deployed-stack scan, and git-sync all flow through LoadMetadata, so a bad `backup:` block in a
+ // catalog push screams here within one sync cycle. On ANY defect (or an unreadable compose while a
+ // block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
+ // to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
+ // meta.Backup yet (Task 3/4).
+ if meta.Backup != nil {
+ composePath := filepath.Join(stackDir, "docker-compose.yml")
+ binds := ParseComposeClassifiableBinds(composePath)
+ if _, err := os.Stat(composePath); err != nil {
+ log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
+ meta.Backup = nil
+ } else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
+ log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
+ meta.Backup = nil
+ }
+ }
+
return meta
}
+// ClassifiedBinds resolves the backup classification for a stack: it reads .felhom.yml (through the
+// SAME LoadMetadata validation path, so a rejected block is already nil here) and its compose binds,
+// then applies the two-level default via appbackup.ClassifyBinds. The bool reports whether the app
+// carries a (valid) backup block at all. INERT — exists so Task 3 consumes a wired, end-to-end-tested
+// seam instead of building one (the F-S3 lesson: wiring is where seams hide typos).
+func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool) {
+ stack, ok := m.GetStack(name)
+ if !ok {
+ return nil, false
+ }
+ stackDir := filepath.Dir(stack.ComposePath)
+ meta := LoadMetadata(stackDir)
+ binds := ParseComposeClassifiableBinds(stack.ComposePath)
+ return appbackup.ClassifyBinds(meta.Backup, binds)
+}
+
// HasDeployFields returns true if the app has any user-facing deploy fields
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
func (m *Metadata) HasDeployFields() bool {
diff --git a/controller/internal/web/async_restore_test.go b/controller/internal/web/async_restore_test.go
index 7583e87..204b4bd 100644
--- a/controller/internal/web/async_restore_test.go
+++ b/controller/internal/web/async_restore_test.go
@@ -41,6 +41,9 @@ func (p *blockProvider) RefreshAndIsRunning(string) bool { return true }
func (p *blockProvider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
return backup.RecoveryInfo{}, false
}
+func (p *blockProvider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
+ return nil, false
+}
func (p *blockProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *blockProvider) RecreateStackFromUnit(string, string, map[string]string) error {
return nil