Files
felhom-controller/controller/internal/appbackup/classify.go
T
admin 0649f9a3e6 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
2026-07-14 18:46:32 +02:00

198 lines
8.1 KiB
Go

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
}