package backup import ( "fmt" "os" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // Offsite capture-set resolution (Task 3a, architecture doc §2/§6). Turns an app's Task-3-core // TierOffsite capture set (recovery unit + MANDATORY userdata only) into the extra absolute paths // appended to the app's restic snapshot, plus the Hungarian customer warnings for LOUD capture gaps. // // SP-3.4 is law here: restic 0.14.0 does NOT error on a missing source path — it skips with a warning, // exits 0, and silently writes a partial snapshot. So a skipped/missing MANDATORY path is detected in // THIS function (the structural-guard Skipped list + an os.Stat filter) and surfaced in BOTH the // English log and the Hungarian LastWarning. A restic exit code proves nothing about a missing path. // offboxBlocked records an app whose enlarged (userdata-carrying) push was refused by the pre-push // quota gate. The unit-only push still proceeds (never a protection regression). estBytes is the // mandatory-set size estimate that would have been added. type offboxBlocked struct { stack string estBytes int64 } // offboxCaptureSet computes an app's OFFSITE mandatory capture paths to add to its recovery-unit // snapshot, plus any Hungarian warnings for capture gaps. It never returns optional/excluded paths // (the TierOffsite filter drops them — §2). Returns (nil, nil) for the legacy / no-provider / no-block // world: offsite stays UNIT-ONLY, byte-identical to pre-v0.134.0 (the SQ5 cost-regression guard). func (m *Manager) offboxCaptureSet(stack string) (extra []string, warns []string) { if m.stackProvider == nil { return nil, nil // no provider wired → legacy world → unit only } binds, has := m.stackProvider.GetStackClassifiedBinds(stack) if !has { return nil, nil // no backup block → legacy → unit only } // Resolve against the app's LIVE HDD_PATH (raw — NOT GetAppDrivePath, whose systemDataPath fallback // would resolve userdata onto the wrong drive). Empty ⇒ undeployed / no HDD (decision §2.4): // mandatory-path resolution needs the live HDD_PATH, so push unit-only + a loud WARN. hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)) if hdd == "" { m.logger.Printf("[WARN] [offbox] %s: not deployed — offsite push is unit-only (mandatory userdata not resolvable)", stack) return nil, []string{fmt.Sprintf("Figyelmeztetés: a(z) %s nincs telepítve — csak a mentési egység került a távoli mentésbe.", stack)} } nsRoot := m.namespaceRoot(hdd) cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot, m.stackProvider.GetImportRoot()) var gaps []string // Structurally-refused MANDATORY paths (traversal / bare drive-root / reserved backups/ zone) are // loud ERROR gaps — the path the customer thinks is protected is not in the snapshot. for _, sk := range cs.Skipped { if sk.Class == appbackup.ClassMandatory { m.logger.Printf("[ERROR] [offbox] %s: mandatory path refused by a structural guard (%s): %s/%s — NOT in the offsite snapshot", stack, sk.Reason, sk.Root, sk.RelPath) gaps = append(gaps, sk.RelPath) } } // Stat-filter (§2.5): a declared mandatory path absent on disk. restic would skip it SILENTLY // (SP-3.4), so drop it from argv AND warn — never a silent "looks backed up but isn't". for _, p := range cs.Paths { if _, err := os.Stat(p.Abs); err != nil { m.logger.Printf("[WARN] [offbox] %s: mandatory data path missing on disk, skipped from offsite: %s", stack, p.Abs) gaps = append(gaps, p.RelPath) continue } extra = append(extra, p.Abs) } if len(gaps) > 0 { warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s.", stack, strings.Join(gaps, ", "))) } return extra, warns }