58c703bd44
gates / gates (push) Successful in 8s
The gap was already detected and warned about, in Hungarian, naming the app and the folders -- that warning is what stopped the R-201 drill. The defect was that the run still reported `ok` beside it, and a warning standing beside a success is read as a success. last_status gains "incomplete": minted, because "ok" | "error" | "running" had nothing meaning "it ran, and this app is not fully protected". NOT "error" -- the rest of the run worked and what was captured is real, so SnapshotCount and the LastSuccess anchor still record it. Half a backup is not no backup. The gaps are now recorded STRUCTURALLY (offboxRunResult.mandatoryGaps), not only as prose, so the verdict has something to act on. It reaches the operator through the EXISTING per-run digest (backup_run_failures) rather than a new event type -- a new type is a two-repo change and the hub drops anything outside allowedEventTypes. The stat-filter gains the ClassMandatory check Tier 2 already had. It is a NO-OP today (TierOffsite admits mandatory only), so no customer-visible warning disappears -- demonstrated by widening the tier filter alone and watching the check hold the line. ANTICIPATED: calibre-web on demo-hp has exactly this gap, so its off-site status becomes incomplete the moment this ships. That is correct and is the point. Red-proofs: my first Scenario-C proof PASSED because the test only reached offboxCaptureSet while the mutation lives in runOffboxInternal -- a mutation the test cannot observe is not a red-proof, and the fix was the test. The run-level test now fails under both mutations (unreachable gap recording; unconditional ok).
83 lines
4.3 KiB
Go
83 lines
4.3 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 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, 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 {
|
|
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, gaps
|
|
}
|