diff --git a/CHANGELOG.md b/CHANGELOG.md index a02cd63..abcd997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## Unreleased — two data-loss candidates fixed (R-1187 c, R-1189 d), 2026-10-22 night + +**MinAgent: 0.158.1** (unchanged). Committed, not released (night brief 2026-10-22: no controller release tonight). + +- **R-1187 (c) — a `.fab` import over an installed app that is NOT running is refused.** It used to „overwrite config + in place": only a RUNNING app's volumes were removed first, and `docker cp` adds and overwrites but never deletes, so + the old data MERGED with the bundle's, reported as success. Proven on 9202 (felhom.eu + `audits/night-2026-10-22/P3-candidates/`): a marker written after the export survived an import into the stopped + app; the same import into the running app replaced it (the control). Now `executeImport` refuses before anything + is written: „Az alkalmazás telepítve van, de nem fut … Az alkalmazás érintetlen. Indítsd el (vagy távolítsd el), és + próbáld újra." `TestR1187c_AStoppedAppIsNotImportedOver`, red-proved. +- **R-1189 (d) — the off-site proof and the integrity check run after a catch-up.** They are daily jobs at 05:30 and + 06:00 that the scheduler never runs late, so a box that is off at those hours was never checked, and the comment + claimed „checked the next day it is on". `nightchain.CatchUp.After` now runs once after the catch-up's legs, wired in + main.go to `runOffsiteProof` and the due-gated `runOffsiteIntegrityCheck` (force=false). Tests + `TestR1189d_TheChecksRunAfterTheCatchUpLegs` (nightchain) and `TestR1189d_TheCatchUpIsWiredToTheChecks`, both + red-proved. + ## Unreleased — a template may exclude a named volume from every backup (R-49, operator ruling D21 = B, 2026-10-22) **MinAgent: 0.158.1** (unchanged). Committed, not released (night brief 2026-10-22: no controller release tonight). diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index f3cc685..52ac18a 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1557,6 +1557,13 @@ func main() { Event: func(missedAt time.Time) { notifier.NotifyBackupCatchUp(missedAt.In(budapestLoc()).Format("15:04")) }, + // R-1189 (d): the off-site proof (05:30) and the integrity check (06:00) are daily jobs the scheduler + // never runs late; a box that is off at those hours comes back through this catch-up, so they run + // here — the proof picks one due snapshot, the integrity check runs only when it is due (max age). + After: func(ctx context.Context) { + runOffsiteProof(ctx, backupMgr, notifier, logger) + runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false) + }, Logger: logger, Loc: budapestLoc(), } @@ -1599,7 +1606,8 @@ func main() { // // DAILY JOB, WEEKLY BEHAVIOUR, and that is the point. It asks "is the last successful check // older than the max age?" rather than "is it Sunday?", so a box that was switched off on its - // check day is checked the next day it is on. R-341 is exactly the other shape: a dated check + // check day is checked the next day it is on AT 06:00 — or, if it is off at that hour, at its next + // catch-up (R-1189 d: the catch-up's After hook runs this check and the proof). R-341 is exactly the other shape: a dated check // quietly missed and never caught up. No `Weekly` primitive was added to the scheduler — // due-ness is smaller, catches up, and is what R-86 already chose for restore-tests. // diff --git a/controller/cmd/controller/r1189d_wiring_test.go b/controller/cmd/controller/r1189d_wiring_test.go new file mode 100644 index 0000000..d874cee --- /dev/null +++ b/controller/cmd/controller/r1189d_wiring_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// R-1189 (d): the catch-up in main.go carries the After hook that runs the off-site proof and the due-gated integrity +// check (internal/nightchain TestR1189d_* pins that After runs; this pins that production wires it). +// RED-PROOF: the After block removed from main.go → this fails. +func TestR1189d_TheCatchUpIsWiredToTheChecks(t *testing.T) { + src, err := os.ReadFile("main.go") + if err != nil { + t.Fatal(err) + } + s := string(src) + i := strings.Index(s, "catchUp := &nightchain.CatchUp{") + if i < 0 { + t.Fatal("the catch-up literal is gone from main.go") + } + lit := s[i:] + lit = lit[:strings.Index(lit, "\n\t\t\t}\n")] + a := strings.Index(lit, "After: func(ctx context.Context) {") + if a < 0 || !strings.Contains(lit[a:], "runOffsiteProof(") || !strings.Contains(lit[a:], "runOffsiteIntegrityCheck(ctx, backupMgr, notifier, logger, false)") { + t.Fatal("the catch-up must run the off-site proof and the due-gated (force=false) integrity check after its legs") + } +} diff --git a/controller/internal/appexport/r1187c_test.go b/controller/internal/appexport/r1187c_test.go new file mode 100644 index 0000000..f8f930c --- /dev/null +++ b/controller/internal/appexport/r1187c_test.go @@ -0,0 +1,51 @@ +package appexport + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +// R-1187 (c): a bundle imported over an app that is installed but NOT running is refused before anything is written — +// before the fix it „overwrote config in place" and merged the old volume data with the bundle's (proven on 9202 +// 2026-10-22: a marker written after the export survived the import, reported as success). +// RED-PROOF: without the refusal the job succeeds over the stopped app — seen failing 2026-10-22. +func TestR1187c_AStoppedAppIsNotImportedOver(t *testing.T) { + calls := swapDockerExec(t, func(c dockerCall, stdin io.Reader, stdout io.Writer) (string, error) { return "", nil }) + tree := t.TempDir() + os.MkdirAll(filepath.Join(tree, "config"), 0755) + os.WriteFile(filepath.Join(tree, "config", "docker-compose.yml"), []byte("services: {}\n"), 0644) + man := &Manifest{Version: ManifestVersion, AppName: "kuma", DisplayName: "Kuma", ConfigFiles: []string{"docker-compose.yml"}} + data, err := man.Marshal() + if err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(tree, "manifest.json"), data, 0644) + fab := filepath.Join(t.TempDir(), "kuma.fab") + if err := createTarGz(fab, tree); err != nil { + t.Fatal(err) + } + stacksDir := t.TempDir() + stackDir := filepath.Join(stacksDir, "kuma") + os.MkdirAll(stackDir, 0755) + os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("LIVE"), 0644) + + prov := &rtProvider{stackDir: stackDir, stacksDir: stacksDir, deployed: true, running: false} + e := NewExporter(prov, log.New(io.Discard, "", 0), "test") + if err := e.StartImport(ImportRequest{FABPath: fab}); err != nil { + t.Fatalf("StartImport: %v", err) + } + msg := jobErr(waitJob(t, e)) + if msg == "" || !strings.Contains(msg, "nem fut") { + t.Fatalf("an import over a stopped app must be refused with the reason, got %q", msg) + } + if b, _ := os.ReadFile(filepath.Join(stackDir, "docker-compose.yml")); string(b) != "LIVE" { + t.Fatalf("the app's files were changed: %q", b) + } + if prov.stopped != 0 || prov.removed != 0 || prov.started || len(*calls) != 0 { + t.Fatalf("a refused import must change nothing: stopped=%d removed=%d started=%t docker=%v", prov.stopped, prov.removed, prov.started, *calls) + } +} diff --git a/controller/internal/appexport/restore.go b/controller/internal/appexport/restore.go index 54eec26..900cf9a 100644 --- a/controller/internal/appexport/restore.go +++ b/controller/internal/appexport/restore.go @@ -358,6 +358,18 @@ func (e *Exporter) executeImport(req ImportRequest, job *Job) { } e.debugf("existing stack: deployed=%v running=%v", isDeployed, isRunning) + // R-1187 (c) (data-loss hunt 2026-10-20; proven on 9202 2026-10-22): an import over an app that is installed but + // NOT running used to „overwrite config in place" — its volumes were never removed, and `docker cp` only adds and + // overwrites, so the result was the old data MERGED with the bundle's (a stale SQLite -wal beside the bundle's .db + // is a corruption shape), reported as success. Only a running app's volumes are removed first. So this import is + // refused here, before anything is written; the household starts the app (or removes it) and imports again. + // Pinned by TestR1187c_AStoppedAppIsNotImportedOver. + if isDeployed && !isRunning { + e.logger.Printf("[WARN] Import: refused — %s is installed but not running; importing over it would merge the old data with the bundle's (R-1187 c)", manifest.AppName) + e.failJob(job, step, "Az alkalmazás telepítve van, de nem fut, ezért a csomagot nem töltjük vissza: a régi adatai összekeveredhetnének a csomagéval. Az alkalmazás érintetlen. Indítsd el (vagy távolítsd el), és próbáld újra.") + return + } + if isDeployed { if isRunning { e.logger.Printf("[INFO] Import: removing existing stack %s (with volumes)", manifest.AppName) diff --git a/controller/internal/nightchain/nightchain.go b/controller/internal/nightchain/nightchain.go index b2da325..16e8189 100644 --- a/controller/internal/nightchain/nightchain.go +++ b/controller/internal/nightchain/nightchain.go @@ -224,10 +224,14 @@ type CatchUp struct { Legs map[Leg]func(context.Context) error // the leg bodies — WITHOUT the update leg QuiesceBusy func() bool // a whole-guest backup holds the apps now Event func(missedAt time.Time) // the household's timeline line - Logger *log.Logger - Delay time.Duration - Loc *time.Location - Now func() time.Time + // After (R-1189 d) runs once after the legs of a catch-up that ran: the off-site proof (05:30) and the integrity + // check (06:00) are daily jobs the scheduler never runs late, so a box that is off at those hours was never + // checked — the catch-up is where such a box comes back. nil = nothing. + After func(ctx context.Context) + Logger *log.Logger + Delay time.Duration + Loc *time.Location + Now func() time.Time // Sleep waits d or until ctx ends (false). A seam for tests. Sleep func(ctx context.Context, d time.Duration) bool @@ -336,6 +340,10 @@ func (c *CatchUp) run(ctx context.Context, why string) { names = append(names, string(leg)) } c.Ledger.markCatchUp(c.now()) + if c.After != nil { + c.Logger.Printf("[INFO] [catch-up] running the after-catch-up checks (the off-site proof and the due-gated integrity check, R-1189 d)") + c.After(ctx) + } sort.Strings(names) c.Logger.Printf("[INFO] [catch-up] done: %s in %s (missed at %s)", strings.Join(names, ", "), c.now().Sub(start).Round(time.Second), lastW.In(c.Loc).Format("2006-01-02 15:04")) diff --git a/controller/internal/nightchain/r1189d_test.go b/controller/internal/nightchain/r1189d_test.go new file mode 100644 index 0000000..b328a15 --- /dev/null +++ b/controller/internal/nightchain/r1189d_test.go @@ -0,0 +1,45 @@ +package nightchain + +import ( + "context" + "sync" + "testing" +) + +// R-1189 (d): the after-catch-up checks run once, AFTER every leg of the catch-up (the off-site proof and the +// integrity check are daily jobs the scheduler never runs late; the catch-up is where a box off at 05:30–06:00 comes +// back). A box with nothing missed never runs them from here (its 05:30/06:00 jobs do). +// RED-PROOF: with the After call removed from run(), the first assertion fails — seen failing 2026-10-22. +func TestR1189d_TheChecksRunAfterTheCatchUpLegs(t *testing.T) { + h := newHarness(t, at(1, 12, 0), at(5, 9, 0)) + h.l.MarkEnded(LegDBDump, at(4, 2, 31)) + h.l.MarkEnded(LegTier2, at(4, 3, 31)) + h.l.MarkEnded(LegOffsite, at(4, 4, 20)) + var mu sync.Mutex + var after []int // how many legs had run when After was called + h.c.After = func(context.Context) { + mu.Lock() + after = append(after, len(h.ran)) + mu.Unlock() + } + if legs := h.c.Evaluate(context.Background(), "start"); len(legs) != 3 { + t.Fatalf("no catch-up scheduled: %v", legs) + } + h.wait(t) + mu.Lock() + defer mu.Unlock() + if len(after) != 1 || after[0] != 3 { + t.Fatalf("After calls %v, want exactly one, after all 3 legs", after) + } + + h2 := newHarness(t, at(1, 12, 0), at(5, 11, 0)) + h2.l.MarkEnded(LegDBDump, at(5, 2, 31)) + h2.l.MarkEnded(LegTier2, at(5, 3, 31)) + h2.l.MarkEnded(LegOffsite, at(5, 4, 20)) + called := false + h2.c.After = func(context.Context) { called = true } + h2.c.Evaluate(context.Background(), "start") + if called { + t.Fatal("no catch-up ran, so the after-catch-up checks must not run") + } +}