Files
felhom-controller/controller/internal/backup/tier2_shares.go
T
admin 9f436c8a3b v0.150.0 — green gate restored + the export link stops leaking the CSRF token
F7/R-53: app_export.html built the app's public URL as '<sub>.{{$.CSRFToken}}',
so the "Megnyitás" link was wrong for every app with a subdomain and a session
CSRF token was written into a URL. Template now uses {{$.Domain}}, and
exportPageHandler supplies the key — it builds its own data map instead of
going through baseData, which is where every other page gets it. The page's
real CSRF path (csrfH() reading the meta tag) is correct and untouched.

The 7 red internal/backup tests are green again, with no behaviour change.
TestTier2V2_* / TestSharesTier2* all failed for one environmental reason:
Tier-2's off-drive guard asks system.SamePhysicalDevice (st_dev equality)
whether a target is really a second disk, and every t.TempDir() here shares one
filesystem — so the guard correctly refused the fixture's "two drives" and the
tests never reached their subject ("nincs másik fizikai meghajtó").

Seam in the package's existing style: a nil-defaulted Manager.samePhysicalDevice
field + sameDevice wrapper, seven call sites routed through it. Nil resolves to
system.SamePhysicalDevice, so production is byte-for-byte unchanged; only the two
fixtures inject a fake modelling one drive per directory subtree. No assertion
weakened, nothing skipped/renamed/deleted; all 7 mutation-proved.

Also: the ssh->pct-exec ASCII-grep and heredoc-credential traps are now in
CLAUDE.md's live-validation section.
2026-07-20 09:42:24 +02:00

294 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"
)
// 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 m.sameDevice(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)
}