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