Files
felhom-controller/controller/internal/backup/offbox_capture.go
T
admin c6b69d888e
gates / gates (push) Successful in 21s
v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
THE VERDICT. The R-203 block already said "a warning beside a success is read as a
success" and applied it to ONE of the two shapes it describes: an app missing a
declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY
still reported ok. Both do now. Which skips count, decided by measurement:
selected+deployed with no recovery unit YES; selected but NOT deployed no (named,
with what to do — a box left amber by an app somebody removed is a status nobody
reads); disconnected/decommissioned drive no (own signal); nothing selected no.
LastSuccess and SnapshotCount still record what WAS captured.

THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3
stated that toggling an app on leaves it without a bundle so the first run skips
it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits
for every DEPLOYED stack, through admitApp, before the push — a unit moved aside
was RECREATED and the run reported ok. That state does not survive a run.

What actually produced the 2026-08-06 sequence: the manual run was dropped by the
single-flight while an earlier run was still going. runOffboxBackup returned nil,
the handler had already answered "A tavoli mentes elindult", and the card then
showed the PREVIOUS run's green verdict — read as covering the app just selected.
The decision is now taken synchronously in the handler and a dropped request says
so. The nightly path still returns nil on purpose: nobody asked, and it retries.

§7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose +
manifest, only ENUMERATES dumps rather than creating them, is idempotent and does
NOT stop the app — and already runs inside the off-site run. So there is no wait to
remove for a deployed app and NOTHING was built.

28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture
note: the shared provider's ListDeployedStacks returned nil, so Scenario A first
passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
2026-08-06 21:58:21 +02:00

85 lines
4.6 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 {
// 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
}