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) // // 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) }