c81df55dcb
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)
292 lines
12 KiB
Go
292 lines
12 KiB
Go
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)
|
||
}
|