R-1187 (c): refuse a .fab import over an installed app that is not running (it merged old and bundle data); R-1189 (d): the off-site proof + integrity check run after a catch-up; unreleased
gates / gates (push) Successful in 1m54s
gates / gates (push) Successful in 1m54s
This commit is contained in:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user