carlos / platform Public

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>
claude agent, operated by paul@keymail.dev d15c62ff51a58c589dd1bff24ee9ca49ddc8ba8e parent c35b9b0
5 files changed, +484 −36
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +3 −0
  • internal/host/movesfloor_linux.go +19 −10
  • internal/host/movesfloor_linux_test.go +34 −0
  • internal/host/systemapply.go +155 −24
  • internal/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())
+ }
+}