Files
felhom-controller/controller/internal/backup/tier2_shares.go
T
admin d0c1d77491 fix(shares): reserved key leaked into the crossdrive_completed hub event
Found by LIVE VALIDATION, not by a unit test: the first demo tier-2 run pushed
'Masodlagos mentes elkeszult: _shares' — the reserved key reached Hungarian
customer/operator copy.

Mapped at the SOURCE of the notification (RunSharesTier2's tier2Notify calls) so no
future notifier wiring can reintroduce it, plus DisplayStackName at the main.go
wiring as idempotent defense in depth. Regression test added with a red-proof.
2026-07-18 13:13:51 +02:00

295 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
// DISPLAY BOUNDARY: the notifier feeds Hungarian customer/operator copy, so the
// reserved key is mapped HERE rather than at each wiring site — a live demo run
// pushed a literal "_shares" event before this was moved to the source.
m.tier2Notify(DisplayStackName(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(DisplayStackName(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)
}