dbcb306fcf
gates / gates (push) Successful in 8s
The box stops inferring the customer's intent from a container count and reads
what they actually asked for.
Part 1 — desired state. AppConfig gains a tri-state `desired_state`
(""/running/stopped), written ONLY by the customer's own action: the API action
switch, DeployStack, UpdateOptionalConfig's redeploy branch, and the .fab
import. Intent is written BEFORE the act and a failed write REFUSES the act.
StartStack/StopStack are deliberately not writers — 14 callers, only 2 are the
customer. bootrecon.isBootOrphan now reads intent instead of len(Containers)>0,
which closes R-157 mechanism B (a power cut or interrupted deploy left an app
with zero containers, read as a deliberate stop, and stranded silently).
ABSENT MEANS UNKNOWN, NEVER "running": every pre-v0.189.0 app.yaml reads absent,
so the legacy fallback is byte-identical to the old rule. A running-only startup
backfill converges the unambiguous cases; `stopped` is never inferred.
Part 2 — backup.AppStopGuard, a persisted marker over every stop→work→start
window (volume dump, offbox reconstitute, .fab export). Its own file, never
quiesce's. Written before the stop, cleared only after a restart that succeeded,
kept when one fails. Recover() completes before the boot reconciler is launched
and returns its outcome, which main.go reports on the existing backup_failed
event once the notifier exists. A defer is not the mechanism — a SIGKILL runs
none (Campaign 8 fault 10).
Also: SaveAppConfig rebuilt AppConfig field-by-field (the R-100 shape) and would
have dropped desired_state on every save across nine call sites. Replaced with
copy-and-overlay. Measured: app.yaml does not round-trip unknown YAML keys.
No hub change, no agent coupling, no user-visible string. 27/27 packages green;
7 red-proofs observed FAIL then restored.
396 lines
14 KiB
Go
396 lines
14 KiB
Go
package backup
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-166 part 2 — the app-stop crash marker.
|
|
//
|
|
// THE DISCIPLINE THAT MATTERS HERE (§10): a `defer` is not crash-safety, so a test that lets the
|
|
// deferred cleanup run proves nothing about a crash. Every "interrupted" test below simulates a
|
|
// SIGKILL by never reaching the restart — the marker is written, the process conceptually dies, and
|
|
// a FRESH guard over the SAME file does the recovering. That is exactly what Campaign 8 fault 10
|
|
// established on live hardware: a SIGKILL runs no deferred function, and what brought the stacks
|
|
// back was the marker read at startup.
|
|
|
|
type fakeStarter struct {
|
|
starts []string
|
|
failWith map[string]error
|
|
}
|
|
|
|
func (f *fakeStarter) StartStack(name string) error {
|
|
f.starts = append(f.starts, name)
|
|
if err := f.failWith[name]; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newGuard(t *testing.T, dir string) (*AppStopGuard, *fakeStarter) {
|
|
t.Helper()
|
|
s := &fakeStarter{}
|
|
g := NewAppStopGuard(filepath.Join(dir, "appstop-state.json"), log.New(io.Discard, "", 0))
|
|
g.SetStarter(s)
|
|
return g, s
|
|
}
|
|
|
|
func markerPath(dir string) string { return filepath.Join(dir, "appstop-state.json") }
|
|
|
|
func markerExists(t *testing.T, dir string) bool {
|
|
t.Helper()
|
|
_, err := os.Stat(markerPath(dir))
|
|
if err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
// --- Scenario E — a crash mid-backup brings the app back -----------------------------------------
|
|
|
|
func TestRecover_InterruptedVolumeDump_RestartsTheAppAndClearsTheMarker(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// --- process 1: an operation stops the app and is KILLED. No End(), no defer, no cleanup. ---
|
|
g1, _ := newGuard(t, dir)
|
|
if err := g1.Begin("volume-dump:immich", ReasonVolumeDump, []string{"immich"}); err != nil {
|
|
t.Fatalf("Begin: %v", err)
|
|
}
|
|
if !markerExists(t, dir) {
|
|
t.Fatal("Begin did not write a marker — nothing would survive the kill")
|
|
}
|
|
// <SIGKILL here> — g1 is abandoned deliberately; nothing else is called on it.
|
|
|
|
// --- process 2: a fresh controller starts and recovers from the file alone. ---
|
|
g2, starter := newGuard(t, dir)
|
|
res := g2.Recover()
|
|
|
|
if len(starter.starts) != 1 || starter.starts[0] != "immich" {
|
|
t.Fatalf("started %v, want exactly [immich] — the app was left stranded by the interrupted backup", starter.starts)
|
|
}
|
|
if res == nil || len(res.Restarted) != 1 || res.Restarted[0] != "immich" {
|
|
t.Fatalf("recovery result = %+v, want immich restarted", res)
|
|
}
|
|
if res.Reason != ReasonVolumeDump {
|
|
t.Fatalf("reason = %q, want %q — the operator must be told WHICH operation was interrupted", res.Reason, ReasonVolumeDump)
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("the marker survived a successful recovery — the next boot would restart the app again")
|
|
}
|
|
// The operator-facing text must name the interruption, not merely report a restart.
|
|
if msg := res.Message(); msg == "" || !strings.Contains(msg, "interrupted") {
|
|
t.Fatalf("operator message %q does not say the operation was interrupted", msg)
|
|
}
|
|
}
|
|
|
|
func TestRecover_NoMarker_IsASilentNoOp(t *testing.T) {
|
|
dir := t.TempDir()
|
|
g, starter := newGuard(t, dir)
|
|
if res := g.Recover(); res != nil {
|
|
t.Fatalf("Recover reported %+v on a box with no marker", res)
|
|
}
|
|
if len(starter.starts) != 0 {
|
|
t.Fatalf("started %v with no marker present", starter.starts)
|
|
}
|
|
}
|
|
|
|
func TestRecover_FailedRestart_KEEPSTheMarkerForTheNextStartup(t *testing.T) {
|
|
// The single most important failure behaviour: clearing a marker whose restart failed would
|
|
// erase the only durable record that an app is owed one. The app is genuinely still down.
|
|
dir := t.TempDir()
|
|
g1, _ := newGuard(t, dir)
|
|
if err := g1.Begin("volume-dump:immich", ReasonVolumeDump, []string{"immich", "nextcloud"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
g2, starter := newGuard(t, dir)
|
|
starter.failWith = map[string]error{"immich": errors.New("compose up: no such image")}
|
|
res := g2.Recover()
|
|
|
|
if len(res.Failed) != 1 || res.Failed[0] != "immich" {
|
|
t.Fatalf("failed=%v, want [immich]", res.Failed)
|
|
}
|
|
if len(res.Restarted) != 1 || res.Restarted[0] != "nextcloud" {
|
|
t.Fatalf("restarted=%v, want [nextcloud] — one app failing must not abort the others", res.Restarted)
|
|
}
|
|
if !markerExists(t, dir) {
|
|
t.Fatal("the marker was cleared even though a restart FAILED — the next startup would not retry")
|
|
}
|
|
if msg := res.Message(); !strings.Contains(msg, "NOT be restarted") {
|
|
t.Fatalf("operator message %q does not report the failure", msg)
|
|
}
|
|
if d := res.Detail(); !strings.Contains(d, "restart_failed") || !strings.Contains(d, "immich") {
|
|
t.Fatalf("detail %q does not name which app failed", d)
|
|
}
|
|
}
|
|
|
|
func TestRecover_IsIdempotentAcrossRepeatedStartups(t *testing.T) {
|
|
dir := t.TempDir()
|
|
g1, _ := newGuard(t, dir)
|
|
if err := g1.Begin("op", ReasonOffboxReconstitute, []string{"immich"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g2, s2 := newGuard(t, dir)
|
|
g2.Recover()
|
|
g3, s3 := newGuard(t, dir)
|
|
g3.Recover()
|
|
|
|
if len(s2.starts) != 1 {
|
|
t.Fatalf("first recovery started %v", s2.starts)
|
|
}
|
|
if len(s3.starts) != 0 {
|
|
t.Fatalf("a SECOND startup restarted %v again — the marker was not cleared", s3.starts)
|
|
}
|
|
}
|
|
|
|
func TestRecover_CorruptMarkerIsQuarantinedNotSilentlySkipped(t *testing.T) {
|
|
// §9.4: never a silent skip. A corrupt marker cannot be acted on, but it must leave a trace —
|
|
// otherwise a genuinely interrupted operation vanishes without evidence.
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(markerPath(dir), []byte("{not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g, starter := newGuard(t, dir)
|
|
if res := g.Recover(); res != nil {
|
|
t.Fatalf("a corrupt marker produced a recovery result %+v", res)
|
|
}
|
|
if len(starter.starts) != 0 {
|
|
t.Fatalf("apps were started from a corrupt marker: %v", starter.starts)
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("the corrupt marker was left in place — it would be re-read forever")
|
|
}
|
|
quarantined, _ := filepath.Glob(markerPath(dir) + ".corrupt-*")
|
|
if len(quarantined) != 1 {
|
|
t.Fatalf("the corrupt marker was not quarantined (found %d) — it was silently dropped", len(quarantined))
|
|
}
|
|
}
|
|
|
|
func TestRecover_NoStarterWiredKeepsTheMarker(t *testing.T) {
|
|
// D-b's safety rule: never worse than not having the file. With no starter the guard cannot act,
|
|
// so it must keep the record for a startup that can, rather than clear it and lose the app.
|
|
dir := t.TempDir()
|
|
g1, _ := newGuard(t, dir)
|
|
if err := g1.Begin("op", ReasonVolumeDump, []string{"immich"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
g2 := NewAppStopGuard(markerPath(dir), log.New(io.Discard, "", 0)) // deliberately no SetStarter
|
|
if res := g2.Recover(); res != nil {
|
|
t.Fatalf("recovered without a starter: %+v", res)
|
|
}
|
|
if !markerExists(t, dir) {
|
|
t.Fatal("the marker was cleared with no starter wired — the app would never come back")
|
|
}
|
|
}
|
|
|
|
func TestNilGuardIsInert(t *testing.T) {
|
|
// A caller that was never wired must degrade to pre-v0.189.0 behaviour, not panic.
|
|
var g *AppStopGuard
|
|
if err := g.Begin("op", ReasonVolumeDump, []string{"x"}); err != nil {
|
|
t.Fatalf("nil guard Begin returned %v", err)
|
|
}
|
|
g.End()
|
|
if res := g.Recover(); res != nil {
|
|
t.Fatalf("nil guard recovered %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestMarkerContentsAreDiagnosable(t *testing.T) {
|
|
dir := t.TempDir()
|
|
g, _ := newGuard(t, dir)
|
|
if err := g.Begin("volume-dump:immich", ReasonVolumeDump, []string{"immich"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.ReadFile(markerPath(dir))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m AppStopMarker
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("the marker on disk is not readable JSON: %v", err)
|
|
}
|
|
if !m.Active || m.OpID != "volume-dump:immich" || m.Reason != ReasonVolumeDump ||
|
|
len(m.Stacks) != 1 || m.Stacks[0] != "immich" || m.StartedAt.IsZero() {
|
|
t.Fatalf("the marker does not record enough to diagnose the interruption: %+v", m)
|
|
}
|
|
// 0600 — it names customer apps.
|
|
fi, err := os.Stat(markerPath(dir))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("marker mode = %v, want 0600", fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestBeginWithNoStacksWritesNothing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
g, _ := newGuard(t, dir)
|
|
if err := g.Begin("op", ReasonVolumeDump, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("a marker was written for an operation that stops nothing")
|
|
}
|
|
}
|
|
|
|
// --- Scenarios E/F — DumpAppVolumesSafe, the primary site ----------------------------------------
|
|
|
|
// inspectingProvider is the StackDataProvider slice DumpAppVolumesSafe touches. It records whether
|
|
// the marker file EXISTED at each step — the positive observable for the ordering property. An
|
|
// absent log line is not evidence (standing rule 3); the file's presence at the moment of the stop
|
|
// is.
|
|
//
|
|
// GetDockerVolumes returns nothing, so the dump itself is a no-op and no Docker is involved — the
|
|
// stop/start bracket around it is what is under test.
|
|
type inspectingProvider struct {
|
|
StackDataProvider
|
|
markerFile string
|
|
events []string
|
|
stopErr error
|
|
startErr error
|
|
markerPresentAtStop bool
|
|
markerAtStartCall bool
|
|
// panicOnVolumes simulates a hard abort (SIGKILL/power cut) at the point the dump begins: the
|
|
// unwind skips the restart statement, exactly as a kill would.
|
|
panicOnVolumes bool
|
|
}
|
|
|
|
func (p *inspectingProvider) GetDockerVolumes(string) []string {
|
|
if p.panicOnVolumes {
|
|
panic("simulated hard abort mid-dump")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *inspectingProvider) StopStack(name string) error {
|
|
_, err := os.Stat(p.markerFile)
|
|
p.markerPresentAtStop = err == nil
|
|
p.events = append(p.events, "stop:"+name)
|
|
return p.stopErr
|
|
}
|
|
|
|
func (p *inspectingProvider) StartStack(name string) error {
|
|
_, err := os.Stat(p.markerFile)
|
|
p.markerAtStartCall = err == nil
|
|
p.events = append(p.events, "start:"+name)
|
|
return p.startErr
|
|
}
|
|
|
|
func newDumpManager(t *testing.T, dir string, p *inspectingProvider) *Manager {
|
|
t.Helper()
|
|
lg := log.New(io.Discard, "", 0)
|
|
m := &Manager{logger: lg, stackProvider: p, systemDataPath: dir}
|
|
m.appStop = NewAppStopGuard(markerPath(dir), lg)
|
|
return m
|
|
}
|
|
|
|
func TestDumpAppVolumesSafe_MarkerCoversTheWholeStopStartWindow(t *testing.T) {
|
|
// Scenario F, the happy path: the marker is on disk BEFORE the stop, still on disk for the whole
|
|
// time the app is down, and GONE once the restart succeeds.
|
|
dir := t.TempDir()
|
|
p := &inspectingProvider{markerFile: markerPath(dir)}
|
|
m := newDumpManager(t, dir, p)
|
|
|
|
if err := m.DumpAppVolumesSafe("immich"); err != nil {
|
|
t.Fatalf("DumpAppVolumesSafe: %v", err)
|
|
}
|
|
|
|
if !p.markerPresentAtStop {
|
|
t.Fatal("the marker was NOT on disk when the app was stopped — a crash one instruction later " +
|
|
"strands the app, which is the entire failure this marker exists to prevent")
|
|
}
|
|
if !p.markerAtStartCall {
|
|
t.Fatal("the marker was already gone while the app was still down")
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("the marker survived a dump whose restart succeeded — the next boot would restart the app again")
|
|
}
|
|
if len(p.events) != 2 || p.events[0] != "stop:immich" || p.events[1] != "start:immich" {
|
|
t.Fatalf("events=%v, want [stop:immich start:immich]", p.events)
|
|
}
|
|
}
|
|
|
|
func TestDumpAppVolumesSafe_Interrupted_RecoveryBringsTheAppBack(t *testing.T) {
|
|
// Scenario E end-to-end THROUGH THE PRODUCTION PATH, and WITHOUT running any cleanup.
|
|
//
|
|
// The abort is real: GetDockerVolumes panics, which unwinds out of DumpAppVolumesSafe AFTER the
|
|
// marker was written and the app stopped, and BEFORE the restart statement — and because that
|
|
// restart is a plain statement, not a defer, it never runs. That is the shape of a hard kill.
|
|
//
|
|
// The earlier version of this test called m.appStop.Begin itself, which meant it proved the
|
|
// marker type worked and NOT that DumpAppVolumesSafe uses it — it survived the red-proof that
|
|
// deleted the production Begin call. Driving the real function is what makes the proof bite.
|
|
//
|
|
// RED-PROOF: delete the `m.appStop.Begin(...)` call from DumpAppVolumesSafe and this test fails —
|
|
// nothing is written, so nothing is recovered. Demonstrated in REPORT.md §5.
|
|
dir := t.TempDir()
|
|
p := &inspectingProvider{markerFile: markerPath(dir), panicOnVolumes: true}
|
|
m := newDumpManager(t, dir, p)
|
|
|
|
func() {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Error("the simulated abort did not fire — this test proves nothing")
|
|
}
|
|
}()
|
|
_ = m.DumpAppVolumesSafe("immich")
|
|
}()
|
|
|
|
if !p.markerPresentAtStop {
|
|
t.Fatal("the app was stopped before any marker existed")
|
|
}
|
|
if p.markerAtStartCall {
|
|
t.Fatal("the restart ran despite the abort — the simulation is wrong, not the code")
|
|
}
|
|
|
|
// <the controller is gone> — a fresh one starts and recovers from the file alone.
|
|
g, starter := newGuard(t, dir)
|
|
res := g.Recover()
|
|
|
|
if len(starter.starts) != 1 || starter.starts[0] != "immich" {
|
|
t.Fatalf("started %v — the app stopped by the interrupted dump was not brought back", starter.starts)
|
|
}
|
|
if res == nil || res.Reason != ReasonVolumeDump {
|
|
t.Fatalf("recovery did not name the volume dump as the interrupted operation: %+v", res)
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("the marker was not cleared after a successful recovery")
|
|
}
|
|
}
|
|
|
|
func TestDumpAppVolumesSafe_FailedRestartKeepsTheMarker(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := &inspectingProvider{markerFile: markerPath(dir), startErr: errors.New("compose up failed")}
|
|
m := newDumpManager(t, dir, p)
|
|
|
|
if err := m.DumpAppVolumesSafe("immich"); err == nil {
|
|
t.Fatal("a failed restart must surface as an error")
|
|
}
|
|
if !markerExists(t, dir) {
|
|
t.Fatal("the marker was cleared even though the restart FAILED — the app is still down and " +
|
|
"nothing records that it is owed a restart")
|
|
}
|
|
}
|
|
|
|
func TestDumpAppVolumesSafe_FailedStopClearsTheMarker(t *testing.T) {
|
|
// Nothing was stopped, so nothing is owed a restart. A stranded marker here would cost a
|
|
// spurious restart at the next startup AND a false "a backup was interrupted" alert.
|
|
dir := t.TempDir()
|
|
p := &inspectingProvider{markerFile: markerPath(dir), stopErr: errors.New("stack is protected")}
|
|
m := newDumpManager(t, dir, p)
|
|
|
|
if err := m.DumpAppVolumesSafe("traefik"); err == nil {
|
|
t.Fatal("a failed stop must surface as an error")
|
|
}
|
|
if markerExists(t, dir) {
|
|
t.Fatal("a marker was left behind for an app that was never stopped")
|
|
}
|
|
}
|