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