3c49dc8ea4
gates / gates (push) Successful in 12s
R-399: monitoring.integrity.read_data_subset defaults to 100%. A pack damaged without changing its size made plain `restic check` report "no errors were found" on demo-hp 2026-08-30; every read-data form caught it. Cost on that 134 MB store: 35.0s structure vs 39.2s at 100%. "off" (any case) is the off token; empty means not-configured, therefore the default; a malformed value falls back to the DEFAULT, never to structure. A completed check over 5 minutes logs a WARN naming the duration, the depth and R-401 — operator log only, no hub event, no depth change. The depth is now recorded with the verdict (LastIntegrityDepth; empty = NOT RECORDED, never "structure"). R-400: 24 debug-page references, 17 dispatched, 7 dead — three of which fetched on page LOAD, so those panels were permanently blank. backup/crossdrive implemented; backup/infra, hub/infra-push, dr/infra-status, storage/watchdog-status and both storage/simulate-* deleted with their panels and JavaScript. scripts/debug_route_gate.py fails in both directions and is registered after the seven were resolved. 18 referenced, 18 dispatched, none orphaned. Corrections: the dead-field warning in report/types.go said the controller runs no integrity check and the notifiers are called from nowhere — both false since v0.227.0. controller.yaml.example gains its missing integrity: block. integrityCheckTimeout's "ships OFF" comment rewritten.
194 lines
8.2 KiB
Go
194 lines
8.2 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// ── R-399 — the off-site check reads the DATA, not just the catalogue ────────────────────────────
|
|
//
|
|
// THE MEASUREMENT THESE TESTS DEFEND. On demo-hp, 2026-08-30, a pack in a real 134 MB store was
|
|
// damaged WITHOUT changing its size. Plain `restic check` — the structure-and-index check that every
|
|
// box in the fleet ran — reported `no errors were found` and exited clean. Every `--read-data*` form
|
|
// caught it. Cost of the deeper run on that store: 39.2 s versus 35.0 s.
|
|
//
|
|
// So the assertions below are on the ARGUMENT LIST, never on the absence of an error. "The check
|
|
// passed" is exactly what the broken configuration produced; only the argv can tell the two apart.
|
|
|
|
// readDataArg returns the --read-data* argument the check passed to restic, or "" when it passed none.
|
|
func readDataArg(argv []string) string {
|
|
for _, a := range argv {
|
|
if strings.HasPrefix(a, "--read-data") {
|
|
return a
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// A1 — the fleet's actual configuration: no integrity block at all.
|
|
func TestR399_AbsentConfigRunsFullDepth(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
// Deliberately touch nothing: this is a box whose controller.yaml has no `integrity:` key.
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
argv := cap.checkArgv()
|
|
if argv == nil {
|
|
t.Fatal("no check ran")
|
|
}
|
|
if got := readDataArg(argv); got != "--read-data-subset=100%" {
|
|
t.Fatalf("an unconfigured box ran at depth %q, want --read-data-subset=100%%; argv=%v\n"+
|
|
"A structure-only run is what every box did before R-399, and it PASSED a size-preserving "+
|
|
"pack corruption on real hardware — the store's rot would be found at restore time",
|
|
got, argv)
|
|
}
|
|
if res.ReadDataSubset != "100%" {
|
|
t.Errorf("the result did not record the depth it actually ran at: %+v", res)
|
|
}
|
|
}
|
|
|
|
// A2 — an empty string is NOT the off switch. Empty means "not configured", so it means the default.
|
|
func TestR399_EmptyStringIsNotOff(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = ""
|
|
m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=100%" {
|
|
t.Fatalf("an empty value was treated as OFF (%q) — emptiness must mean 'not configured', or "+
|
|
"there is no way to distinguish an unset key from a deliberate downgrade", got)
|
|
}
|
|
}
|
|
|
|
// A3 — the off switch. A setting with no off switch is not a setting.
|
|
func TestR399_OffTokenRunsStructureOnly(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = "off"
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if got := readDataArg(cap.checkArgv()); got != "" {
|
|
t.Fatalf("the off token still downloaded pack data (%q) — there would be no way to switch the "+
|
|
"deep check off without editing code", got)
|
|
}
|
|
if res.ReadDataSubset != "" {
|
|
t.Errorf("a structure-only run recorded a subset: %q", res.ReadDataSubset)
|
|
}
|
|
if code := IntegrityDepthCode(res.ReadDataSubset); code != "structure" {
|
|
t.Errorf("structure depth must be RECORDED as %q, not as an empty string a reader has to "+
|
|
"interpret; got %q", "structure", code)
|
|
}
|
|
}
|
|
|
|
// A4 — an operator writing "OFF" or "Off" in a YAML file means the same thing.
|
|
func TestR399_OffTokenIsCaseInsensitive(t *testing.T) {
|
|
for _, tok := range []string{"OFF", "Off", " oFf "} {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = tok
|
|
m.CheckOffboxIntegrity(context.Background())
|
|
if got := readDataArg(cap.checkArgv()); got != "" {
|
|
t.Errorf("%q was not recognised as the off token (argv carried %q)", tok, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A5 — an explicit value wins over both the default and the off token.
|
|
func TestR399_ExplicitValueWins(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = "10%"
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=10%" {
|
|
t.Fatalf("an explicit 10%% ran as %q — a chosen value must not be overridden by a default", got)
|
|
}
|
|
if res.ReadDataSubset != "10%" {
|
|
t.Errorf("result recorded %q, want 10%%", res.ReadDataSubset)
|
|
}
|
|
}
|
|
|
|
// A6 — a typo falls back to the DEFAULT, not to structure depth.
|
|
//
|
|
// The direction is the whole point. Passing "banana" through fails the entire check; downgrading to
|
|
// structure would silently remove the protection R-399 exists to add, which is R-357's shape exactly —
|
|
// a guard that opens quietly. Falling back to the default keeps the protection and still says loudly
|
|
// that the config is wrong.
|
|
func TestR399_MalformedFallsBackToTheDefault(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
m.cfg.Monitoring.Integrity.ReadDataSubset = "banana"
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
argv := cap.checkArgv()
|
|
for _, a := range argv {
|
|
if strings.Contains(a, "banana") {
|
|
t.Fatalf("a malformed value was handed to restic (%q) — restic rejects it and the WHOLE "+
|
|
"check fails, so one typo would stop the store being verified at all", a)
|
|
}
|
|
}
|
|
if got := readDataArg(argv); got != "--read-data-subset=100%" {
|
|
t.Fatalf("a typo downgraded the check to %q instead of falling back to the default 100%% — "+
|
|
"a guard that opens quietly on a typo is R-357's failure", got)
|
|
}
|
|
if res.ReadDataSubset != "100%" {
|
|
t.Errorf("result recorded %q after a refused value, want the default", res.ReadDataSubset)
|
|
}
|
|
log := cap.logBuf.String()
|
|
if !strings.Contains(log, "WARN") || !strings.Contains(log, "banana") {
|
|
t.Errorf("a refused config value must WARN and NAME the bad value; log was:\n%s", log)
|
|
}
|
|
}
|
|
|
|
// A7 — the depth is stated in the outcome, or the result cannot be judged afterwards.
|
|
func TestR399_DepthIsStatedInTheOutcome(t *testing.T) {
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
res := m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if !strings.Contains(cap.logBuf.String(), "100%") {
|
|
t.Errorf("the PASSED log line does not say how deep the check looked:\n%s", cap.logBuf.String())
|
|
}
|
|
// And it is PERSISTED with the verdict, so a later reader of the stored state can judge it too.
|
|
m.RecordIntegrityVerdict(res)
|
|
tgt := m.settings.GetOffboxTarget()
|
|
if tgt == nil || tgt.LastIntegrityDepth != "100%" {
|
|
t.Fatalf("the stored verdict does not carry its depth: %+v — 'checked, OK' means two different "+
|
|
"things at structure depth and at 100%%", tgt)
|
|
}
|
|
}
|
|
|
|
// A8 — THE SEAM TEST. Everything above sets the config field directly; this one proves the default
|
|
// survives the PRODUCTION resolution path: real YAML bytes -> config.LoadFromBytes -> the accessor ->
|
|
// the argv. A default that only works when a test hand-builds the struct is the inert-seam failure
|
|
// this project has shipped four times.
|
|
func TestR399_DefaultResolvesThroughTheRealConfigPath(t *testing.T) {
|
|
// A minimal but VALID controller.yaml — LoadFromBytes validates, and a config that fails
|
|
// validation would never reach a running box, so a fixture that skipped the required keys would
|
|
// not be the production path.
|
|
const yaml = `
|
|
customer:
|
|
id: "demo-hp"
|
|
domain: "felhom.example.hu"
|
|
monitoring:
|
|
enabled: true
|
|
thresholds:
|
|
disk_warn_percent: 80
|
|
`
|
|
loaded, err := config.LoadFromBytes([]byte(yaml))
|
|
if err != nil {
|
|
t.Fatalf("the fixture config does not parse: %v", err)
|
|
}
|
|
if loaded.Monitoring.Integrity.ReadDataSubset != "" {
|
|
t.Fatalf("fixture wrong: the parsed config already carries a subset %q, so this test would "+
|
|
"prove nothing about the ABSENT case", loaded.Monitoring.Integrity.ReadDataSubset)
|
|
}
|
|
|
|
m, cap := newIntegrityManager(t, okRepo(nil))
|
|
// Only the parsed Monitoring block is transplanted; Paths must stay pointing at the test's temp
|
|
// dir. The seam under test is config-parse -> Monitoring.Integrity -> integrityReadDataSubset.
|
|
m.cfg.Monitoring = loaded.Monitoring
|
|
m.CheckOffboxIntegrity(context.Background())
|
|
|
|
if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=100%" {
|
|
t.Fatalf("a real controller.yaml with no `integrity:` block resolved to depth %q, want "+
|
|
"--read-data-subset=100%% — that is every box in the fleet today", got)
|
|
}
|
|
}
|