Files
felhom-controller/controller/internal/backup/offbox_capture.go
T

87 lines
4.9 KiB
Go

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 skips a missing source path with a warning and still writes a partial snapshot.
// 0.14.0 and 0.18.1 then exit 0; 0.19.x exits 3 (restic #4467 — measured 2026-10-16, R-1107), which the run reads
// as that app's failure. Either way the filter below keeps a declared-but-absent path out of argv, so only a path
// that vanishes between the stat and restic reaches it — and on 0.19 that race is now loud instead of silent.
// 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 — never left to restic's exit code.
// 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, gaps []string) {
if m.stackProvider == nil {
return nil, nil, nil // no provider wired → legacy world → unit only
}
binds, has := m.stackProvider.GetStackClassifiedBinds(stack)
if !has {
return nil, 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)}, nil
}
nsRoot := m.namespaceRoot(hdd)
cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot, m.stackProvider.GetImportRoot())
// 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".
//
// R-203: the class check mirrors tier2_capture.go's ("optional-missing is silent"). It is a NO-OP
// today — TierOffsite's tierKeeps() already admits ClassMandatory only, so cs.Paths cannot contain
// an optional path here — and it is written anyway so the two tiers read the same and so the
// verdict below can never be flipped by an unused optional folder if that filter ever widens.
for _, p := range cs.Paths {
if _, err := os.Stat(p.Abs); err != nil {
if p.Class == appbackup.ClassMandatory {
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 // optional-missing is silent (not a gap) — parity with Tier 2
}
extra = append(extra, p.Abs)
}
if len(gaps) > 0 {
// R-234 §7.4: this sits beside the whole-app gap message on the same card, and both now drive
// the same `incomplete` verdict — so it says what to do, not only what happened.
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s. Ellenőrizd, hogy a mappák megvannak-e a meghajtón; ha igen és ez a következő mentés után is látszik, szólj az üzemeltetőnek.",
stack, strings.Join(gaps, ", ")))
}
return extra, warns, gaps
}