diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 351520d..bf78213 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -283,6 +283,12 @@ func LoadMetadata(stackDir string) Metadata { // 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) { + // samba (R-7) is controller-generated infra with no .felhom.yml and absolute share binds — its + // classification comes from the shares registry instead (see samba_classify.go). Every other + // stack takes the unchanged catalog path below. + if name == SambaStackName { + return m.sambaClassifiedBinds() + } stack, ok := m.GetStack(name) if !ok { return nil, false diff --git a/controller/internal/stacks/samba_classify.go b/controller/internal/stacks/samba_classify.go new file mode 100644 index 0000000..3638f43 --- /dev/null +++ b/controller/internal/stacks/samba_classify.go @@ -0,0 +1,66 @@ +package stacks + +import ( + "path/filepath" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" +) + +// Backup classification for the samba stack (R-7 slice 1, [R4]). +// +// samba is CONTROLLER-GENERATED infra: it has no .felhom.yml and its compose binds are absolute +// share paths, so the catalog path (LoadMetadata → ParseComposeClassifiableBinds → ClassifyBinds) +// cannot classify it. Its classification comes from the SHARES REGISTRY instead: +// +// share.Offsite == true → ClassMandatory → TierOffsite (offsite) AND TierSecondary (tier-2) +// share.Offsite == false → ClassOptional → TierSecondary only (never offsite) +// +// The smb.conf / passdb mounts are deliberately NOT emitted — they are config, not customer data. +// +// SCOPE NOTE (the reported design fork, see REPORT.md): making this seam correct does NOT by itself +// put share data into a live tier-2/offsite run. Both engines are structured around a per-app +// RECOVERY UNIT — backup.RunTier2 short-circuits on `os.Stat(unitDir)` before it ever calls +// GetStackClassifiedBinds, and the offsite runner enumerates settings.GetOffboxApps(). A share-only +// infra stack has neither, and teaching them about one is more than an enumeration tweak, so per the +// task's STOP clause it was reported rather than improvised inside the engines. +func (m *Manager) sambaClassifiedBinds() ([]appbackup.ClassifiedBind, bool) { + if m.settings == nil { + return nil, false + } + shares := m.settings.GetSMBShares() + out := make([]appbackup.ClassifiedBind, 0, len(shares)) + for _, sh := range shares { + cls := appbackup.ClassOptional + if sh.Offsite { + cls = appbackup.ClassMandatory + } + out = append(out, appbackup.ClassifiedBind{ + ComposeBind: appbackup.ComposeBind{ + Root: appbackup.RootHDD, + RelPath: m.shareRelPath(sh.Path), + ReadOnly: sh.ReadOnly, + }, + Class: cls, + Origin: appbackup.OriginExplicit, // the customer's per-share Felhőmentés toggle IS explicit + }) + } + return out, true +} + +// shareRelPath expresses a share's absolute path relative to its owning registered storage root, so +// the emitted bind matches the (Root, RelPath) + root-path resolution shape the capture-set helpers +// use. A share under no registered root falls back to the path minus its leading slash (never +// absolute — the structural guards reject absolute RelPaths). +func (m *Manager) shareRelPath(path string) string { + p := filepath.ToSlash(path) + if m.settings != nil { + for _, sp := range m.settings.GetStoragePaths() { + root := filepath.ToSlash(sp.Path) + if strings.HasPrefix(p, root+"/") { + return strings.TrimPrefix(p, root+"/") + } + } + } + return strings.TrimPrefix(p, "/") +} diff --git a/controller/internal/stacks/samba_classify_test.go b/controller/internal/stacks/samba_classify_test.go new file mode 100644 index 0000000..7748fae --- /dev/null +++ b/controller/internal/stacks/samba_classify_test.go @@ -0,0 +1,114 @@ +package stacks + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Scenario D: the shares registry drives samba's backup classification, and the resulting binds flow +// through the REAL ComputeCaptureSet tier filter — mandatory lands in BOTH offsite and tier-2, +// optional in tier-2 ONLY. No backup-engine internals are touched by this test or by the code. +func TestSambaClassifiedBinds_TierMembership(t *testing.T) { + m, sett, root, _ := newSambaManager(t) + storageRoot := filepath.Join(root, "drive") + + // Two shares under one registered storage root: one with Felhőmentés ON, one OFF. + docs := filepath.Join(storageRoot, "shares", "dokumentumok") + films := filepath.Join(storageRoot, "shares", "filmek") + for _, d := range []string{docs, films} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := sett.AddStoragePath(settings.StoragePath{Path: storageRoot, Schedulable: true}); err != nil { + t.Fatal(err) + } + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: docs, Offsite: true}); err != nil { + t.Fatal(err) + } + if err := sett.AddSMBShare(settings.SMBShare{Name: "filmek", Path: films, Offsite: false}); err != nil { + t.Fatal(err) + } + + binds, has := m.ClassifiedBinds(SambaStackName) + if !has { + t.Fatal("samba must report a classification") + } + if len(binds) != 2 { + t.Fatalf("expected 2 classified binds, got %d: %+v", len(binds), binds) + } + + byRel := map[string]appbackup.BindClass{} + for _, b := range binds { + byRel[b.RelPath] = b.Class + } + if got := byRel["shares/dokumentumok"]; got != appbackup.ClassMandatory { + t.Errorf("Felhőmentés ON must be mandatory, got %q", got) + } + if got := byRel["shares/filmek"]; got != appbackup.ClassOptional { + t.Errorf("Felhőmentés OFF must be optional, got %q", got) + } + + // Tier membership through the real helper. + offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot) + secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot) + + if !hasRel(offsite, "shares/dokumentumok") { + t.Errorf("mandatory share must be in the OFFSITE set: %+v", offsite.Paths) + } + // The negative half — the part a broken mapping would silently flip. + if hasRel(offsite, "shares/filmek") { + t.Errorf("optional share must NOT be in the offsite set: %+v", offsite.Paths) + } + if !hasRel(secondary, "shares/dokumentumok") || !hasRel(secondary, "shares/filmek") { + t.Errorf("tier-2 must carry BOTH mandatory and optional: %+v", secondary.Paths) + } +} + +func hasRel(cs appbackup.CaptureSet, rel string) bool { + for _, p := range cs.Paths { + if filepath.ToSlash(p.RelPath) == rel { + return true + } + } + return false +} + +// The smb.conf / passdb mounts are config, not customer data — they must never be classified. +func TestSambaClassifiedBinds_ExcludesConfigMounts(t *testing.T) { + m, sett, root, _ := newSambaManager(t) + shareDir := seedShare(t, sett, root, "dokumentumok") + if err := sett.AddSMBShare(settings.SMBShare{Name: "dokumentumok", Path: shareDir, Offsite: true}); err != nil { + t.Fatal(err) + } + binds, has := m.ClassifiedBinds(SambaStackName) + if !has { + t.Fatal("expected classification") + } + for _, b := range binds { + if strings.Contains(b.RelPath, "smb.conf") || strings.Contains(b.RelPath, "samba-passdb") || + strings.Contains(b.RelPath, "/var/lib/samba") { + t.Errorf("config/passdb mount must not be classified: %+v", b) + } + } + if len(binds) != 1 { + t.Errorf("only the share should be classified, got %d binds: %+v", len(binds), binds) + } +} + +// No shares → an empty (but present) classification; never a nil/false that would read as "legacy". +func TestSambaClassifiedBinds_NoShares(t *testing.T) { + m, _, _, _ := newSambaManager(t) + binds, has := m.ClassifiedBinds(SambaStackName) + if !has { + t.Error("samba must always report a classification, even with no shares") + } + if len(binds) != 0 { + t.Errorf("expected no binds, got %+v", binds) + } +}