Backup classification: schema + parser + pure classifier (INERT, v0.132.0)

Task 2 of the backup-classification-redesign arc. 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) consume it.

- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
  ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
  writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
  ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
  binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
  catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
  (delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
  now so Task 3 consumes a tested seam.

INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
This commit is contained in:
2026-07-14 18:46:32 +02:00
parent af98c53c82
commit 0649f9a3e6
18 changed files with 942 additions and 2 deletions
@@ -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
+5
View File
@@ -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.
+197
View File
@@ -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) // "<root>\x00<path>"
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
}
@@ -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)
}
}
@@ -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) ---
@@ -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
}
@@ -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
}
@@ -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
@@ -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 <HDD_PATH>/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) // "<root>\x00<relpath>"
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
}
@@ -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)
}
}
@@ -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)
}
}
})
}
}
+40
View File
@@ -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 {
@@ -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