0649f9a3e6
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
198 lines
8.1 KiB
Go
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
|
|
}
|