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
+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 {