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