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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user