feat(shares): R-7b Parts 1-2 — shares payload builder + tier-2 shares job (Model B')

Sibling shares source for the local cross-drive tier. Reuses the tier2Mirror seam,
selectTier2TargetFrom (narrow source-drive seam extracted from selectTier2Target),
tier2ReconcileRoots (pure extraction), tier2SafeRemove, the marker-LAST discipline
and the recordTier2* helpers. Per-app paths are untouched.

- shares_payload.go: deterministic _shares-manifest.json + best-effort passdb capture
- tier2_shares.go: per-source-drive legs -> cross-drive target, payload, marker LAST
- infra.SambaContainerName/SambaPassdbVolume/Mount: single source of truth for the
  container identity (renderer, stacks execs, backup execs, monitor all read it)
- RESERVED-NAME finding: ValidateSMBShareName did NOT exclude a leading underscore,
  so "_shares" was an accepted share name. Now refused; RunAllTier2 additionally
  skips a "_shares" stack loudly as defense in depth.
- fix: shareSourceDrive returned a slash-normalised path, which made the target
  selector's source-drive equality check miss (a group could target its own drive)
This commit is contained in:
2026-07-18 12:45:57 +02:00
parent 3dfc49e578
commit c81df55dcb
8 changed files with 1050 additions and 13 deletions
+33 -1
View File
@@ -86,6 +86,17 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
// silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path.
func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
sourceDrive := m.GetAppDrivePath(stackName)
if sourceDrive == "" {
return nil, fmt.Errorf("no source drive for %s", stackName)
}
return m.selectTier2TargetFrom(stackName, sourceDrive, fullSize, stateOnlySize)
}
// selectTier2TargetFrom is selectTier2Target with the source drive supplied EXPLICITLY. It exists so
// the R-7b shares source — whose "source drive" is the drive a group of shares lives on, not an app's
// GetAppDrivePath — can reuse this selection and its headroom math verbatim instead of forking it.
// The app path above is a thin wrapper; nothing about its behaviour changed.
func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
if sourceDrive == "" {
return nil, fmt.Errorf("no source drive for %s", stackName)
}
@@ -232,6 +243,13 @@ func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass {
// descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed
// stops occupying the secondary drive within one run). Runs strictly inside destBase.
func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
m.tier2ReconcileRoots(destBase, []string{"hdd", "userdata"}, legRels)
}
// tier2ReconcileRoots is tier2Reconcile with the top-level dest roots supplied explicitly — a pure
// extraction so the R-7b shares dest (whose roots are per-source-drive keys, not hdd/userdata) can
// reuse the SAME staleness classification and the SAME destBase-bounded removal guard.
func (m *Manager) tier2ReconcileRoots(destBase string, roots, legRels []string) {
var walk func(dirAbs, dirRel string)
walk = func(dirAbs, dirRel string) {
entries, err := os.ReadDir(dirAbs)
@@ -258,7 +276,7 @@ func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
}
}
}
for _, root := range []string{"hdd", "userdata"} {
for _, root := range roots {
walk(filepath.Join(destBase, root), root)
}
}
@@ -397,6 +415,14 @@ func (m *Manager) RunAllTier2() {
}
var n int
for _, stack := range m.stackProvider.ListDeployedStacks() {
// Reserved-name defense in depth (R-7b): the shares source owns backups/secondary/_shares and
// the _shares status record. Stack names come from the git-synced catalog, not customer input,
// so this cannot realistically fire — but if it ever did, the app would silently overwrite the
// shares tree, so it is refused loudly instead.
if stack.Name == SharesPseudoStack {
m.logger.Printf("[ERROR] [backup] Tier 2: stack %q uses the RESERVED shares key — skipped to protect the shares backup tree", stack.Name)
continue
}
// F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO
// tier-2 copy — a single controller-level copy on one device. They now flow through too: their
// recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD
@@ -416,6 +442,12 @@ func (m *Manager) RunAllTier2() {
n++
}
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n)
// R-7b: the SHARES source runs after the per-app loop, in the SAME orchestrator run. It is a
// sibling job — nothing above it changed — and its failure never fails the app tier.
if err := m.RunSharesTier2(); err != nil {
m.logger.Printf("[WARN] [backup] Tier 2 shares job failed: %v", err)
}
}
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---