moves: every activation and every forward finish checks under floor.lock with the holds frozen; refused steps put the root passes back (Task 13 fix round 2)
- guardActivation always takes floor.lock, creating .moves for it, so a first floor armed by the agent cannot land between the rule and the rename. With no floor armed, a lock that cannot be had is one log line and the step goes ahead as main's (F15). - Forward recovery finishes a gated journal only under guardActivation, after stagePlan; a floor raised meanwhile sends it back to carlos.prev. - Every creating open of .moves fsyncs the data dir, every time. - quiesce records which root-pass triggers were active. A restore refused before its rename starts them again and keeps the journal. A refused install starts them before it drops the journal; if that fails, the retry goes to rearm-pending.json (not the journal, whose units would pick carlos.prev), the run fails with a reason saying so, and every run starts by starting what is pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+484
−36
docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+3 −0internal/host/movesfloor_linux.go+19 −10internal/host/movesfloor_linux_test.go+34 −0internal/host/systemapply.go+155 −24internal/host/systemapply_test.go+273 −2
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 1e1e2f8f1..61104121e 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -132,6 +132,9 @@ Columns: id, file, text, status. |
| | MV-N90 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the registry cannot say whether this box is one: <error> | pending | |
| | MV-N91 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the running release is unknown | pending | |
| | MV-N92 | internal/host/systemapply.go | moves protocol floor: could not write it on this box: <error> | pending | |
| +| MV-N93 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>. the next apply or recovery starts them | pending | |
| +| MV-N94 | internal/host/systemapply.go | <refusal>; the root passes paused for the install could not be started again: <error>, and the retry could not be recorded: <error>. the journal stays for the next recovery | pending | |
| +| MV-N95 | internal/host/systemapply.go | <refusal>; the root passes paused for the restore could not be started again: <error> | pending | |
| ## Plan 2 |
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go| index 14fc5cbba..29c654a22 100644 |
| --- a/internal/host/movesfloor_linux.go |
| +++ b/internal/host/movesfloor_linux.go |
| @@ -38,13 +38,16 @@ const ( |
| // openMovesDir opens <root>/.moves. With create it makes the directory |
| // when missing; without, a missing directory is os.ErrNotExist. |
| // |
| -// Crash ordering for a directory created here: mkdir, then fsync of the |
| -// parent (root) so the new entry is on disk, and only then any file inside |
| -// it, each of which is renamed into place and followed by an fsync of |
| -// .moves itself. A power loss can therefore lose a floor only together with |
| -// the write that reported it, never after: a floor write that returned nil |
| -// survives, directory entry and all. A failed parent fsync is the write's |
| -// error, which the callers treat as a floor that could not be written. |
| +// Crash ordering (fix round 2): with create, the parent (root) is fsynced |
| +// EVERY time, after .moves is known to exist, whoever made it. A writer |
| +// never relies on having created the directory itself: another writer may |
| +// have made it and not yet synced the parent, or failed to. Only then is |
| +// any file written inside, each renamed into place and followed by an fsync |
| +// of .moves. So a floor write that returned nil survives a power loss, |
| +// directory entry and all. A failed fsync is the open's error, which the |
| +// writers report as a floor that could not be written (errFloorWrite, which |
| +// defers an apply) and the activation guard as a lock it could not take. |
| +// The cost is one fsync of the data dir per write. |
| func openMovesDir(root string, create bool) (*movesDir, error) { |
| name := movesDirName |
| rfd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0) |
| @@ -58,9 +61,6 @@ func openMovesDir(root string, create bool) (*movesDir, error) { |
| if err := syscall.Mkdirat(rfd, name, 0o755); err != nil && !errors.Is(err, syscall.EEXIST) { |
| return nil, fmt.Errorf("mkdir %s: %w", filepath.Join(root, name), err) |
| } |
| - if err := syscall.Fsync(rfd); err != nil { |
| - return nil, fmt.Errorf("fsync %s: %w", root, err) |
| - } |
| if fd, err = syscall.Openat(rfd, name, flags, 0); err == nil { |
| fchownToService(fd) |
| } |
| @@ -68,9 +68,18 @@ func openMovesDir(root string, create bool) (*movesDir, error) { |
| if err != nil { |
| return nil, fmt.Errorf("open %s: %w", filepath.Join(root, name), err) |
| } |
| + if create { |
| + if err := syncParent(rfd); err != nil { |
| + syscall.Close(fd) |
| + return nil, fmt.Errorf("fsync %s: %w", root, err) |
| + } |
| + } |
| return &movesDir{fd: fd, path: filepath.Join(root, name), lock: -1}, nil |
| } |
| +// syncParent fsyncs the data dir. A variable so a test can count the calls. |
| +var syncParent = func(fd int) error { return syscall.Fsync(fd) } |
| + |
| // Close releases the lock (closing its fd) and the directory. |
| func (d *movesDir) Close() { |
| if d.lock >= 0 { |
diff --git a/internal/host/movesfloor_linux_test.go b/internal/host/movesfloor_linux_test.go| new file mode 100644 |
| index 000000000..2b99c3b4f |
| --- /dev/null |
| +++ b/internal/host/movesfloor_linux_test.go |
| @@ -0,0 +1,34 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| +) |
| + |
| +// Every creating open of .moves fsyncs the data dir, not only the one that |
| +// made the directory (fix round 2, finding 4): a second writer must not |
| +// report a floor durable on the strength of a first writer's unsynced mkdir. |
| +func TestEveryFloorWriteSyncsTheDataDir(t *testing.T) { |
| + n := 0 |
| + orig := syncParent |
| + syncParent = func(fd int) error { n++; return orig(fd) } |
| + defer func() { syncParent = orig }() |
| + dd := t.TempDir() |
| + armLocalFloor(t, dd, "v0.30.0") |
| + first := n |
| + armLocalFloor(t, dd, "v0.31.0") |
| + if first == 0 || n <= first { |
| + t.Fatalf("data dir fsyncs: %d after the first write, %d after the second", first, n) |
| + } |
| + syncParent = func(int) error { return errors.New("EIO") } |
| + if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.32.0"}); err == nil { |
| + t.Fatal("a write whose data dir fsync failed reported success") |
| + } |
| + if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.31.0" { |
| + t.Fatalf("floor after a failed fsync = %+v", f) |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 88b12494b..fd2856e2e 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -225,6 +225,10 @@ type systemApplier struct { |
| // store is the deploy store verifyPointer opened, kept for armFloor. |
| // Nil on --recover, which reads no store. |
| store release.Store |
| + // beforeSwap, when set, runs inside guardActivation after the rule has |
| + // allowed and just before the step it guards: a test's schedule hook. |
| + // Nil in production. |
| + beforeSwap func() |
| } |
| func (a *systemApplier) rootDir() string { return systemRootDir(a.optDir) } |
| @@ -271,6 +275,9 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| // Whatever this run does, a finished or recovered update leaves no |
| // update-begun marker behind (spec §5.10). |
| defer a.settleUpdateBegun() |
| + // Root passes an earlier run paused and could not start again come |
| + // first: they are the box's convergence, whatever this run is for. |
| + a.rearmPending() |
| recovered, err := a.recoverIfNeeded(ctx) |
| if err != nil { |
| @@ -435,10 +442,9 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| } |
| // Stop the root passes that exec the old binary against the old edge |
| // before the rename; one that is already running is killed, and those |
| - // passes are idempotent. |
| - if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil { |
| - a.ops.logf("system apply: quiesce: %v (continuing)", err) |
| - } |
| + // passes are idempotent. Which of their triggers were active is kept, so |
| + // a refused rename can put them back. |
| + prior := a.quiesce() |
| // The activation rule again, at the step it guards (moves spec §11; fix |
| // round 1): the check before staging can have gone stale, since the |
| // agent may have raised the floor or held a row since. Here the floor |
| @@ -448,7 +454,7 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| }); err != nil { |
| var refusal activationRefused |
| if errors.As(err, &refusal) { |
| - return a.abandonInstall(req, err) |
| + return a.abandonInstall(req, err, prior) |
| } |
| return fail("install", err) |
| } |
| @@ -486,22 +492,115 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| // abandonInstall undoes an install that the activation guard refused at the |
| // rename, when nothing but the quiesce has happened: the binary, the units |
| -// and carlos.prev are all as they were. The journal goes (a recovery from |
| -// it would restore carlos.prev over a binary that never changed), the |
| -// quiesced timers and paths are started again, and the refusal is |
| -// recorded like any other. |
| -func (a *systemApplier) abandonInstall(req systemRequest, err error) error { |
| - a.removeJournal() |
| - var armed []string |
| +// and carlos.prev are all as they were. |
| +// |
| +// The paused triggers are started again FIRST, with the journal still |
| +// open, and the journal is removed only once they are (fix round 2). When |
| +// starting them fails, what is owed goes to rearm-pending.json, durably, |
| +// before the journal goes, and the run fails with a reason that says so; |
| +// every later run starts with rearmPending, and this failure fires the |
| +// recover unit at once. The journal itself is not what is kept: while it |
| +// exists the system units pick carlos.prev for that recovery, and a |
| +// carlos.prev older than this build reads an install journal as an |
| +// interrupted install and restores itself over a binary that never changed. |
| +// Only when even the retry state cannot be written does the journal stay. |
| +func (a *systemApplier) abandonInstall(req systemRequest, refusal error, prior []string) error { |
| + rerr := a.rearm(prior) |
| + if rerr == nil { |
| + a.removeJournal() |
| + return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, refusal)) |
| + } |
| + reason := fmt.Errorf("%w: %v; the root passes paused for the install could not be started again: %v. the next apply or recovery starts them", |
| + errSystemRefused, refusal, rerr) |
| + if err := a.notePendingRearm(prior); err != nil { |
| + reason = fmt.Errorf("%w: %v; the root passes paused for the install could not be started again: %v, and the retry could not be recorded: %v. the journal stays for the next recovery", |
| + errSystemRefused, refusal, rerr, err) |
| + } else { |
| + a.removeJournal() |
| + } |
| + _ = a.record(req, reason) |
| + return fmt.Errorf("system apply: %s: %v", req.Version, reason) |
| +} |
| + |
| +// rearmPendingFile records root-pass triggers a refused step paused and |
| +// could not start again (abandonInstall), for the next run to start. |
| +const rearmPendingFile = "rearm-pending.json" |
| + |
| +type rearmPending struct { |
| + Units []string `json:"units"` |
| + At time.Time `json:"at"` |
| +} |
| + |
| +// notePendingRearm adds units to rearm-pending.json, durably. |
| +func (a *systemApplier) notePendingRearm(units []string) error { |
| + var p rearmPending |
| + if err := readJSONFile(a.file(rearmPendingFile), &p); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + p = rearmPending{} |
| + } |
| + for _, u := range units { |
| + if !slices.Contains(p.Units, u) { |
| + p.Units = append(p.Units, u) |
| + } |
| + } |
| + p.At = a.ops.now().UTC() |
| + return writeJSONDurable(a.file(rearmPendingFile), p, 0o600) |
| +} |
| + |
| +// rearmPending starts what rearm-pending.json records and removes it once |
| +// they are started. A failure is logged and the file stays for the next run. |
| +func (a *systemApplier) rearmPending() { |
| + var p rearmPending |
| + if err := readJSONFile(a.file(rearmPendingFile), &p); err != nil { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + a.ops.logf("system apply: %s: %v", rearmPendingFile, err) |
| + } |
| + return |
| + } |
| + if err := a.rearm(p.Units); err != nil { |
| + a.ops.logf("system apply: the root passes paused by a refused install could not be started again: %v; the next run tries again", err) |
| + return |
| + } |
| + if err := removeDurable(a.file(rearmPendingFile)); err != nil { |
| + a.ops.logf("system apply: remove %s: %v", rearmPendingFile, err) |
| + } |
| +} |
| + |
| +// quiesce stops the root passes (systemQuiesceUnits) and returns the |
| +// timers and paths among them that were active, for rearm to start again |
| +// if the step they were paused for is refused. When their state cannot be |
| +// read, all of them are returned: active is what an armed box has. |
| +func (a *systemApplier) quiesce() []string { |
| + var triggers []string |
| for _, u := range systemQuiesceUnits { |
| if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") { |
| - armed = append(armed, u) |
| + triggers = append(triggers, u) |
| } |
| } |
| - if _, serr := a.ops.systemctl(append([]string{"start"}, armed...)...); serr != nil { |
| - a.ops.logf("system apply: re-arm after a refused install: %v", serr) |
| + prior := triggers |
| + // is-active exits non-zero when any unit is not active; the output still |
| + // has one state per unit, in order. |
| + out, _ := a.ops.systemctl(append([]string{"is-active"}, triggers...)...) |
| + if states := strings.Fields(out); len(states) == len(triggers) { |
| + prior = nil |
| + for i, u := range triggers { |
| + if states[i] == "active" || states[i] == "activating" || states[i] == "reloading" { |
| + prior = append(prior, u) |
| + } |
| + } |
| } |
| - return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| + if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil { |
| + a.ops.logf("system apply: quiesce: %v (continuing)", err) |
| + } |
| + return prior |
| +} |
| + |
| +// rearm starts units again (nothing to do for none). |
| +func (a *systemApplier) rearm(units []string) error { |
| + if len(units) == 0 { |
| + return nil |
| + } |
| + _, err := a.ops.systemctl(append([]string{"start"}, units...)...) |
| + return err |
| } |
| // finish is steps 11–12, and what recovery runs forward from phase gated. |
| @@ -715,15 +814,32 @@ func refuse(format string, args ...any) error { |
| // lock, since reading it runs a binary. A refusal is an activationRefused; |
| // swap's own error comes back as it is. |
| func (a *systemApplier) guardActivation(version, running string, swap func() error) error { |
| + guarded := func() error { |
| + if a.beforeSwap != nil { |
| + a.beforeSwap() |
| + } |
| + return swap() |
| + } |
| ran := false |
| - err := withFloorLock(a.dataDir, false, func(*movesDir) error { |
| + // create: the lock is taken even on a box with no floor yet, so an |
| + // agent arming the first floor waits for this step rather than landing |
| + // between the rule and the swap (fix round 2). |
| + err := withFloorLock(a.dataDir, true, func(*movesDir) error { |
| ran = true |
| - return a.activationRule(version, func() string { return running }, swap) |
| + return a.activationRule(version, func() string { return running }, guarded) |
| }) |
| - if !ran { |
| + if ran { |
| + return err |
| + } |
| + // The lock could not be had. With a floor armed (or one that cannot be |
| + // read) that is a refusal. With none, the F15 dormancy rule: one log |
| + // line and the step goes ahead as main's would; an agent that cannot |
| + // create the lock cannot arm a floor either. |
| + if a.floorArmed() { |
| return refuse("moves protocol floor: the floor on this box cannot be read: %v", err) |
| } |
| - return err |
| + a.ops.logf("system apply: moves protocol floor lock: %v (no floor is armed; continuing)", err) |
| + return guarded() |
| } |
| // activationRule is the rule itself. swap, when not nil, runs only once the |
| @@ -1231,9 +1347,7 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo |
| } |
| // The same quiesce the install did: a root pass that is mid-flight |
| // against the failed build must not run on across the swap back. |
| - if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil { |
| - a.ops.logf("system apply: quiesce: %v (continuing)", err) |
| - } |
| + prior := a.quiesce() |
| // carlos.prev is copied beside the binary first, outside any lock, and |
| // renamed over it under the activation guard, so the rule's inputs are |
| // held still for a rename rather than a whole binary copy. |
| @@ -1248,6 +1362,15 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo |
| return syncDir(a.optDir) |
| }); err != nil { |
| os.Remove(restore) |
| + // Refused before the rename (the floor or a hold changed during the |
| + // copy): nothing was restored, so the paused triggers go back to how |
| + // they were. The caller keeps the journal. |
| + var refusal activationRefused |
| + if errors.As(err, &refusal) { |
| + if rerr := a.rearm(prior); rerr != nil { |
| + err = fmt.Errorf("%w; the root passes paused for the restore could not be started again: %v", err, rerr) |
| + } |
| + } |
| return bootstrapPlan{}, err |
| } |
| // The unit set to put back is whatever the binary being restored ships, |
| @@ -1374,7 +1497,15 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) { |
| if err != nil { |
| return false, fmt.Errorf("system apply: recovery: %w", err) |
| } |
| - return true, a.finish(req, j.Target, plan) |
| + // Finishing the journal is the forward path's activation: the |
| + // rule again, under the guard, at the step itself, since the |
| + // floor can rise while the plan is staged (fix round 2). |
| + err = a.guardActivation(j.Target, j.Target, func() error { return a.finish(req, j.Target, plan) }) |
| + var refusal activationRefused |
| + if !errors.As(err, &refusal) { |
| + return true, err |
| + } |
| + a.ops.logf("system apply: recovery: %s passed its gate but %v; rolling back instead", j.Target, err) |
| } |
| } |
| plan, err := a.restoreFiles(ctx, j) |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index 146dbf62c..894cd5080 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -1811,8 +1811,12 @@ func TestFloorIsDormantWithoutTheObject(t *testing.T) { |
| if *opened != 0 { |
| t.Fatalf("an unarmed install opened the registry %d times", *opened) |
| } |
| - if _, err := os.Stat(filepath.Join(f.a.dataDir, ".moves")); err == nil { |
| - t.Fatal("an unarmed install wrote floor files") |
| + // The activation guard creates .moves for its lock on every box (fix |
| + // round 2); an unarmed install writes no floor into it. |
| + for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} { |
| + if _, err := os.Stat(filepath.Join(f.a.dataDir, name)); err == nil { |
| + t.Fatalf("an unarmed install wrote %s", name) |
| + } |
| } |
| if _, err := os.Stat(filepath.Join(f.a.optDir, moves.PrevAllowedFile)); err == nil { |
| t.Fatal("an unarmed install wrote a verdict") |
| @@ -2010,3 +2014,270 @@ func TestInstallRenameWaitsForTheFloorLock(t *testing.T) { |
| t.Fatalf("result %+v, on disk %q", r, f.onDisk()) |
| } |
| } |
| + |
| +// ---- fix round 2: one invariant. No executable is renamed into place, and |
| +// no journal is finished, unless the floor and hold check ran under |
| +// floor.lock and WithHoldsFrozen immediately before that step. ---- |
| + |
| +// unitActivity fakes systemctl's view of the root passes' triggers, so a |
| +// test can see which were active before a step and after it. |
| +func (f *applyFixture) unitActivity(active map[string]bool) { |
| + systemctl := f.a.ops.systemctl |
| + f.a.ops.systemctl = func(args ...string) (string, error) { |
| + switch args[0] { |
| + case "is-active": |
| + if len(args) > 1 && args[1] != systemEdgeUnit { |
| + var out []string |
| + for _, u := range args[1:] { |
| + if active[u] { |
| + out = append(out, "active") |
| + } else { |
| + out = append(out, "inactive") |
| + } |
| + } |
| + f.calls = append(f.calls, "systemctl "+strings.Join(args, " ")) |
| + return strings.Join(out, "\n") + "\n", nil |
| + } |
| + case "stop": |
| + for _, u := range args[1:] { |
| + active[u] = false |
| + } |
| + case "start": |
| + for _, u := range args[1:] { |
| + active[u] = true |
| + } |
| + } |
| + return systemctl(args...) |
| + } |
| +} |
| + |
| +func copyActivity(m map[string]bool) map[string]bool { |
| + c := map[string]bool{} |
| + for k, v := range m { |
| + c[k] = v |
| + } |
| + return c |
| +} |
| + |
| +// First arming, during staging: the agent arms a floor on a box that had |
| +// none when the apply began. The rename refuses. |
| +func TestFirstFloorArmedDuringStagingRefusesTheRename(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| + f.duringStaging(func() { |
| + if err := writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.33.0"}); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "moves protocol floor: v0.32.0 is below v0.33.0") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if f.onDisk() != "v0.31.0" { |
| + t.Fatalf("on disk %q", f.onDisk()) |
| + } |
| +} |
| + |
| +// First arming, between the rule and the rename: the guard holds floor.lock |
| +// even on a box with no floor yet (it creates .moves for it), so the agent's |
| +// first arming waits until the rename is done and cannot slip in between. |
| +func TestFirstArmingWaitsForTheActivationLock(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| + armed := make(chan error, 1) |
| + f.a.beforeSwap = func() { |
| + go func() { armed <- writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.40.0"}) }() |
| + time.Sleep(300 * time.Millisecond) |
| + if _, err := os.Stat(filepath.Join(f.a.dataDir, moves.FloorFile)); err == nil { |
| + t.Error("a first floor was armed between the activation check and the rename") |
| + } |
| + } |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if err := <-armed; err != nil { |
| + t.Fatal(err) |
| + } |
| + if lf, _ := readLocalFloor(f.a.dataDir); lf == nil || lf.MinRelease != "v0.40.0" { |
| + t.Fatalf("the agent's arming after the rename = %+v", lf) |
| + } |
| +} |
| + |
| +// Forward recovery re-checks at the step it guards: a floor raised while |
| +// recovery stages the gated build's plan stops it being finished forward. |
| +func TestForwardRecoveryRechecksAfterStagePlan(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.30.0", "v0.31.0") |
| + f.brokenBox("v0.31.0", "v0.30.0", "v0.30.0") |
| + f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: "v0.30.0", StartedAt: f.now}) |
| + armLocalFloor(t, f.a.dataDir, "v0.30.0") |
| + bin := systemBinaryPath(f.a.optDir) |
| + run := f.a.ops.run |
| + f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) { |
| + if name == bin && len(args) > 0 && args[0] == "bootstrap" { |
| + if err := writeLocalFloor(f.a.dataDir, moves.Floor{Min: 1, MinRelease: "v0.32.0"}); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + return run(ctx, d, name, args...) |
| + } |
| + f.edgeStartedThisBoot = false |
| + err := f.a.Run(context.Background(), true) |
| + if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" { |
| + t.Fatalf("finished forward below a floor raised during stagePlan: %+v", ap) |
| + } |
| + if err == nil || !strings.Contains(err.Error(), "moves protocol floor") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j == nil { |
| + t.Fatal("journal removed") |
| + } |
| +} |
| + |
| +// A restore refused after the quiesce (the floor rose during the copy) puts |
| +// the root passes' triggers back as they were, and keeps the journal. |
| +func TestRefusedRestoreLeavesTheRootPassesAsTheyWere(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.32.0", "v0.33.0") |
| + armLocalFloor(t, f.a.dataDir, "v0.30.0") |
| + j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0") |
| + active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": false, "carlos-restart.path": true} |
| + before := copyActivity(active) |
| + f.unitActivity(active) |
| + systemctl := f.a.ops.systemctl |
| + f.a.ops.systemctl = func(args ...string) (string, error) { |
| + out, err := systemctl(args...) |
| + if args[0] == "stop" { |
| + armLocalFloor(t, f.a.dataDir, "v0.33.0") |
| + } |
| + return out, err |
| + } |
| + _, err := f.a.restoreFiles(context.Background(), &j) |
| + if err == nil || !strings.Contains(err.Error(), "moves protocol floor: v0.31.0 is below v0.33.0") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + for u, was := range before { |
| + if active[u] != was { |
| + t.Errorf("%s active = %v after the refusal, was %v", u, active[u], was) |
| + } |
| + } |
| + if f.onDisk() != "v0.32.0" { |
| + t.Fatalf("on disk %q", f.onDisk()) |
| + } |
| + if _, err := os.Stat(systemBinaryPath(f.a.optDir) + ".restore"); err == nil { |
| + t.Fatal("the restore copy was left behind") |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j == nil { |
| + t.Fatal("the recovery journal was dropped") |
| + } |
| +} |
| + |
| +// An install refused at the rename starts the paused triggers again first, |
| +// and drops the journal only once they are started. |
| +func TestRefusedInstallRearmsBeforeDroppingTheJournal(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| + putFloor(t, f.store, "v0.30.0") |
| + active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": true, "carlos-restart.path": false} |
| + before := copyActivity(active) |
| + f.unitActivity(active) |
| + systemctl := f.a.ops.systemctl |
| + f.a.ops.systemctl = func(args ...string) (string, error) { |
| + if args[0] == "start" { |
| + if j, _ := readSystemJournal(f.a.optDir); j == nil { |
| + t.Error("the journal was dropped before the paused passes were started again") |
| + } |
| + } |
| + return systemctl(args...) |
| + } |
| + f.duringStaging(func() { armLocalFloor(t, f.a.dataDir, "v0.33.0") }) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + for u, was := range before { |
| + if active[u] != was { |
| + t.Errorf("%s active = %v, was %v", u, active[u], was) |
| + } |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j != nil { |
| + t.Fatalf("journal left: %+v", j) |
| + } |
| +} |
| + |
| +// When starting them again fails, the run says so and fails, the retry |
| +// state stays on disk, and the next run (here the OnFailure recovery) |
| +// starts them. The journal does not stay: its units would pick carlos.prev, |
| +// and an older carlos.prev would restore itself over a binary that never |
| +// changed. |
| +func TestRefusedInstallWhoseRearmFailsSaysSoAndRetries(t *testing.T) { |
| + f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| + putFloor(t, f.store, "v0.30.0") |
| + active := map[string]bool{"carlos-adopt.timer": true, "carlos-adopt.path": true, "carlos-stage.path": true, "carlos-restart.path": true} |
| + before := copyActivity(active) |
| + f.unitActivity(active) |
| + failStart := true |
| + systemctl := f.a.ops.systemctl |
| + f.a.ops.systemctl = func(args ...string) (string, error) { |
| + if args[0] == "start" && failStart { |
| + return "", errors.New("unit masked") |
| + } |
| + return systemctl(args...) |
| + } |
| + f.duringStaging(func() { armLocalFloor(t, f.a.dataDir, "v0.33.0") }) |
| + err := f.a.Run(context.Background(), false) |
| + if err == nil || !strings.Contains(err.Error(), "could not be started again") { |
| + t.Fatalf("Run err = %v", err) |
| + } |
| + r := f.result() |
| + if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "could not be started again: unit masked") { |
| + t.Fatalf("result = %+v", r) |
| + } |
| + if _, err := os.Stat(f.a.file(rearmPendingFile)); err != nil { |
| + t.Fatalf("no retry state: %v", err) |
| + } |
| + if j, _ := readSystemJournal(f.a.optDir); j != nil { |
| + t.Fatalf("journal kept: %+v", j) |
| + } |
| + if f.onDisk() != "v0.31.0" { |
| + t.Fatalf("on disk %q", f.onDisk()) |
| + } |
| + |
| + failStart = false |
| + if err := f.a.Run(context.Background(), true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(f.a.file(rearmPendingFile)); err == nil { |
| + t.Fatal("retry state left after the passes started") |
| + } |
| + for u, was := range before { |
| + if active[u] != was { |
| + t.Errorf("%s active = %v, was %v", u, active[u], was) |
| + } |
| + } |
| + if f.onDisk() != "v0.31.0" { |
| + t.Fatalf("the retry changed the binary: %q", f.onDisk()) |
| + } |
| +} |
| + |
| +// F15's dormancy rule for the lock: on a box with no floor, an activation |
| +// that cannot create .moves for its lock (a read-only data dir here) logs |
| +// it and goes ahead as main's would. |
| +func TestUnarmedBoxWithoutALockDirUpdatesAsMain(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("root ignores the read-only mode") |
| + } |
| + f, _ := floorFixture(t, "v0.31.0", "v0.32.0") |
| + if err := os.Chmod(f.a.dataDir, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.Chmod(f.a.dataDir, 0o755) }) |
| + if err := f.a.Run(context.Background(), false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" { |
| + t.Fatalf("result %+v, on disk %q", r, f.onDisk()) |
| + } |
| +} |