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) // // 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) } // D25: every REGISTERED share per source drive, available or not — a registered share whose folder is missing // keeps its copy (and is listed as kept); only a share the household deleted or renamed is pruned. registeredByDrive := map[string][]settings.SMBShare{} if m.settings != nil { for _, sh := range m.settings.GetSMBShares() { if d := m.shareSourceDrive(sh.Path); d != "" { registeredByDrive[d] = append(registeredByDrive[d], sh) } } } touched := map[string]map[string]bool{} // destBase → the drive keys handled there this run 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 := m.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 rel, _ := m.diskRelation(g.sourceDrive, target.NamespaceRoot); rel == DiskSame { 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 dst := filepath.Join(destBase, filepath.FromSlash(rel)) if m.emptiedSourceRefused("share "+sh.Name, sh.Path, dst) { legRels = append(legRels, rel) // kept as it is warns = append(warns, m.note("note.tier2.emptied_kept", sh.Name)) continue } if err := mirror(sh.Path, dst); 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++ m.releaseKeptCopy(dst) } // D25: a registered share of this drive whose folder is missing keeps its copy (and is listed as kept). for _, sh := range registeredByDrive[g.sourceDrive] { if m.shareBackupAvailable(sh.Path) { continue } rel := g.key + "/" + sh.Name legRels = append(legRels, rel) m.keepCopy("share", sh.Name, m.storageLabel(g.sourceDrive), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel))) } // 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. // // R-1175: ONLY this group's own drive key. Walking every key on disk with only this group's legs removed // every OTHER group's copies on a shared target (after each run only the last group had a copy) and, on the // night a drive died, its only second copy. Keys no group handled this run are dealt with once, below. m.tier2ReconcileRoots(destBase, []string{g.key}, legRels) if touched[destBase] == nil { touched[destBase] = map[string]bool{} } touched[destBase][g.key] = true 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)) } for destBase, done := range touched { m.sharesTier2OtherKeys(destBase, done, registeredByDrive) } 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, "; ")) } // The no-unit-leg wrapper, deliberately: shares carry no recovery unit, so R-403's guard has // nothing to guard here (see recordTier2Success). 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 drive-key dirs under a shares destBase (the payload dir is not one). 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() && e.Name() != sharesPayloadDestRel { roots = append(roots, e.Name()) } } sort.Strings(roots) return roots } // sharesTier2OtherKeys handles the drive keys on destBase that no group mirrored this run (D25, R-1175): // - the key of a registered drive that is PRESENT: its shares were deleted, renamed or have missing folders — a // registered share's copy is kept (listed), a deleted/renamed share's copy is pruned (the household's own act); // - the key of a drive that is AWAY or DECOMMISSIONED, or of no registered drive at all: every copy under it is // kept and listed. Before 2026-10-21 these were pruned on the first run without them. func (m *Manager) sharesTier2OtherKeys(destBase string, done map[string]bool, registeredByDrive map[string][]settings.SMBShare) { byKey := map[string]settings.StoragePath{} if m.settings != nil { for _, sp := range m.settings.GetStoragePaths() { byKey[sharesDriveKey(sp.Path)] = sp } } for _, key := range sharesTier2DestRoots(destBase) { if done[key] { continue } keyDir := filepath.Join(destBase, key) sp, known := byKey[key] if known && !sp.Disconnected && !sp.Decommissioned { var keep []string for _, sh := range registeredByDrive[sp.Path] { rel := key + "/" + sh.Name keep = append(keep, rel) m.keepCopy("share", sh.Name, m.storageLabel(sp.Path), "folder_missing", destBase, filepath.Join(destBase, filepath.FromSlash(rel))) } m.tier2ReconcileRoots(destBase, []string{key}, keep) continue } reason, drive := "drive_gone", key if known { drive = m.storageLabel(sp.Path) if sp.Disconnected { reason = "drive_away" } } entries, _ := os.ReadDir(keyDir) for _, e := range entries { if e.IsDir() { m.keepCopy("share", e.Name(), drive, reason, destBase, filepath.Join(keyDir, e.Name())) } } } } // 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) }