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
+291
View File
@@ -0,0 +1,291 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// Tier-2 shares job — R-7b Part 2, the local cross-drive leg of Model B.
//
// It runs AFTER the per-stack tier-2 loop, inside the SAME orchestrator run, and it is a SIBLING of
// that loop, never a modification of it: RunTier2 and its per-app dest tree are untouched (the B
// invariant, enforced by TestSharesTier2LeavesPerAppTreeUntouched). What it reuses instead is every
// primitive the per-app path proved: the tier2Mirror seam, selectTier2TargetFrom's target choice and
// headroom math, tier2ReconcileRoots' staleness pruning, tier2SafeRemove's destBase-bounded removal,
// the marker-written-LAST discipline, and the recordTier2* status helpers.
//
// Layout — backups/secondary/_shares/ on the target drive:
//
// .felhom-tier2-layout marker, content "2", written LAST (after every leg + reconcile)
// _payload/ the share definitions + credential copy (shares_payload.go)
// <sourceDriveKey>/<share>/ one mirrored leg per share, grouped by the drive it came from
//
// Shares are grouped BY SOURCE DRIVE because a household's shares can span several drives and each
// group needs its own cross-drive target: a leg's target may never be that leg's own source drive.
// sharesPayloadDestRel is the payload's relpath inside the shares dest (a reserved root name; the
// leading underscore cannot collide with a drive key because drive keys are derived from paths).
const sharesPayloadDestRel = "_payload"
// sharesDriveKey turns an absolute source-drive path into ONE safe dest path segment. The full path
// is encoded (not just its basename) so two drives whose mountpoints share a basename — /mnt/a/data
// and /mnt/b/data — can never map onto the same dest subtree and silently overwrite each other.
// Deterministic and stable across runs, which is what lets the reconcile pass recognise its own dirs.
func sharesDriveKey(drive string) string {
s := strings.Trim(filepath.ToSlash(drive), "/")
if s == "" {
return "root"
}
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}
// shareSourceDrive resolves the registered storage root a share's folder lives under. Returns "" when
// the share sits under no registered root — such a share has no meaningful "other drive" and is
// skipped with a warning rather than guessed at.
func (m *Manager) shareSourceDrive(path string) string {
if m.settings == nil {
return ""
}
p := filepath.ToSlash(path)
best := ""
for _, sp := range m.settings.GetStoragePaths() {
root := filepath.ToSlash(sp.Path)
if root == "" {
continue
}
// Longest match wins, so a nested registered root claims its own shares. Containment is
// compared in slash form, but the returned value is the REGISTRY'S OWN string: the target
// selector compares the source drive against sp.Path by equality, and handing it a normalised
// variant would make that comparison miss and let a group target its own source drive.
if (p == root || strings.HasPrefix(p, root+"/")) && len(root) > len(best) {
best = sp.Path
}
}
return best
}
// sharesTier2Group is one source drive's worth of shares plus the target chosen for it.
type sharesTier2Group struct {
sourceDrive string
key string
shares []classifiedShare
}
// RunSharesTier2 mirrors every classified, available share to a cross-drive target and stages the
// payload beside it. Best-effort and idempotent, exactly like RunTier2: an absent target is an
// honest recorded status, not an error. Returns the first hard copy error.
func (m *Manager) RunSharesTier2() error {
if !m.sharesEnabled() {
// Feature off or no shares registered: a clean no-op. Deliberately NOT a recorded status —
// writing one would make the „Megosztás" page claim a backup tier for a feature in use by
// nobody, and would create the dest tree for zero shares.
return nil
}
if m.settings != nil {
if cd := m.settings.GetCrossDriveConfig(SharesPseudoStack); cd != nil && cd.UserDisabled {
m.logger.Printf("[INFO] [shares] tier-2 skipped — disabled by customer")
return nil
}
}
shares := m.classifiedShares()
if len(shares) == 0 {
// Every registered share is on a drive that is away / a folder that vanished. That is a real
// operational state the customer must see, not silence.
m.recordTier2NoTarget(SharesPseudoStack, "egyetlen megosztás mappája sem érhető el — ellenőrizd a meghajtókat")
m.logger.Printf("[WARN] [shares] tier-2: no available share folders — nothing mirrored")
return nil
}
// Group by source drive (deterministic order so logs and dest trees are reproducible).
byDrive := map[string]*sharesTier2Group{}
var warns []string
for _, sh := range shares {
drive := m.shareSourceDrive(sh.Path)
if drive == "" {
m.logger.Printf("[WARN] [shares] tier-2: share %s is not under a registered storage root — skipped", sh.Name)
warns = append(warns, fmt.Sprintf("A(z) „%s” megosztás nem regisztrált adatmeghajtón van — kimaradt a 2. mentésből.", sh.Name))
continue
}
g := byDrive[drive]
if g == nil {
g = &sharesTier2Group{sourceDrive: drive, key: sharesDriveKey(drive)}
byDrive[drive] = g
}
g.shares = append(g.shares, sh)
}
var groups []*sharesTier2Group
for _, g := range byDrive {
groups = append(groups, g)
}
sort.Slice(groups, func(i, j int) bool { return groups[i].sourceDrive < groups[j].sourceDrive })
if len(groups) == 0 {
m.recordTier2NoTarget(SharesPseudoStack, "a megosztások egyike sincs regisztrált adatmeghajtón")
return nil
}
// Payload once per run — both tiers read the same staged directory.
payloadDir, _, perr := m.buildSharesPayload()
if perr != nil {
m.logger.Printf("[WARN] [shares] tier-2: payload staging failed — mirroring files without the definition manifest: %v", perr)
warns = append(warns, "A megosztás-beállítások mentése nem sikerült — a fájlok mentése megtörtént.")
payloadDir = ""
}
mirror := m.tier2Mirror
if mirror == nil {
mirror = rsyncMirror
}
start := time.Now()
var (
totalSize int64
lastTarget *Tier2Target
noTargetWhy []string
mirrored int
)
for _, g := range groups {
// Two sizes, mirroring RunTier2's contract: full = every share in the group; state-only = the
// MANDATORY (Felhőmentés-on) subset, which is what the SSD headroom guard must fit.
var fullSize, stateOnlySize int64
for _, sh := range g.shares {
sz := dirSizeBytes(sh.Path)
fullSize += sz
if sh.mandatory {
stateOnlySize += sz
}
}
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
if err != nil {
why := tier2NoTargetReason(err)
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
continue
}
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
if system.SamePhysicalDevice(g.sourceDrive, target.NamespaceRoot) {
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
continue
}
legs := g.shares
if target.StateOnly {
kept := legs[:0]
dropped := false
for _, sh := range legs {
if sh.mandatory {
kept = append(kept, sh)
} else {
dropped = true
}
}
legs = kept
if dropped {
warns = append(warns, "A belső SSD-re csak a felhőmentésre jelölt megosztások férnek el — a többi nem került másolásra.")
}
}
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", SharesPseudoStack)
legRels := make([]string, 0, len(legs)+1)
for _, sh := range legs {
rel := g.key + "/" + sh.Name
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
m.recordTier2Failure(SharesPseudoStack, target, err)
if m.tier2Notify != nil {
m.tier2Notify(SharesPseudoStack, target.Label, time.Since(start), err)
}
return fmt.Errorf("tier2 shares mirror %s: %w", sh.Name, err)
}
legRels = append(legRels, rel)
totalSize += dirSizeBytes(sh.Path)
mirrored++
}
// The payload rides every target so each one is independently restorable.
if payloadDir != "" {
if err := mirror(payloadDir, filepath.Join(destBase, sharesPayloadDestRel)); err != nil {
m.logger.Printf("[WARN] [shares] tier-2: payload mirror to %s failed (files are copied): %v", destBase, err)
} else {
legRels = append(legRels, sharesPayloadDestRel)
}
}
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
// dest therefore has no marker and the restore path refuses it until the next good run.
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
if err := m.writeTier2Marker(destBase); err != nil {
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
}
lastTarget = target
m.logger.Printf("[INFO] [shares] tier-2 copied %d share(s) from %s → %s (%s)",
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
}
dur := time.Since(start)
if lastTarget == nil {
reason := strings.Join(noTargetWhy, "; ")
if reason == "" {
reason = "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
}
m.recordTier2NoTarget(SharesPseudoStack, reason)
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for any share group — %s", reason)
return nil
}
if len(noTargetWhy) > 0 {
warns = append(warns, "Néhány meghajtón lévő megosztásnak nincs másodlagos célja: "+strings.Join(noTargetWhy, "; "))
}
m.recordTier2Success(SharesPseudoStack, lastTarget, totalSize, strings.Join(warns, " "), dur)
if m.tier2Notify != nil {
m.tier2Notify(SharesPseudoStack, lastTarget.Label, dur, nil)
}
m.logger.Printf("[INFO] [shares] tier-2 run complete: %d share leg(s), %s, %s",
mirrored, humanizeBytes(totalSize), dur.Round(time.Second))
return nil
}
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
// is no longer registered still gets visited (and pruned) instead of lingering forever.
func sharesTier2DestRoots(destBase string) []string {
entries, err := os.ReadDir(destBase)
if err != nil {
return nil
}
var roots []string
for _, e := range entries {
if e.IsDir() {
roots = append(roots, e.Name())
}
}
sort.Strings(roots)
return roots
}
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
// presence means "every leg and the reconcile completed".
func (m *Manager) writeTier2Marker(destBase string) error {
return os.WriteFile(filepath.Join(destBase, tier2LayoutMarker), []byte(tier2LayoutVersion), 0o644)
}
// SharesTier2Status returns the recorded shares tier-2 status for the „Megosztás" page (nil when the
// job has never run). It is the SAME CrossDriveBackup record the per-app rows use, keyed by the
// reserved pseudo-stack.
func (m *Manager) SharesTier2Status() *settings.CrossDriveBackup {
if m.settings == nil {
return nil
}
return m.settings.GetCrossDriveConfig(SharesPseudoStack)
}