carlos / platform Public

moves: the floor holds across staging, forward recovery, concurrent refreshes and a crash (Task 13 fix round 1)

- The install and restoreFiles put the executable in place under
  guardActivation: the floor lock (.moves/floor.lock, taken by every floor
  writer and every judgement of carlos.prev) and, for a downgrade, the held
  rows frozen by registry.WithHoldsFrozen (BEGIN IMMEDIATE). A refusal at
  the rename abandons the install: journal removed, quiesced passes
  re-armed, recorded refused.
- Forward recovery from a gated journal checks the target against the
  armed floor; below it, recovery goes back to carlos.prev, which
  restoreFiles judges in turn.
- Floor writers serialise on .moves/floor.lock and never lower a floor
  (a lower min or min_release is refused; lowering is a manual step on
  each box). floor.min-release is written first and floor.json last; every
  Go reader, the edges file included, trusts floor.json alone.
- Creating .moves fsyncs its parent before any file is written in it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c35b9b056a68e1b390f3814470b07118287474e7 parent 3a64ac1
8 files changed, +787 −123
  • internal/host/movesfloor.go +168 −59
  • internal/host/movesfloor_linux.go +96 −29
  • internal/host/movesfloor_other.go +13 −4
  • internal/host/movesfloor_test.go +112 −6
  • internal/host/systemapply.go +152 −25
  • internal/host/systemapply_test.go +158 −0
  • internal/registry/hold_test.go +46 −0
  • internal/registry/registry.go +42 −0
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
index c168a96c5..37cc95f8b 100644
--- a/internal/host/movesfloor.go
+++ b/internal/host/movesfloor.go
@@ -31,8 +31,12 @@ import (
// - <DataDir>/.moves/floor.json and the one-line floor.min-release: the
// floor as this box last read it. Root's apply writes them from the
// store before it stages anything (armFloor); the agent refreshes them
-// every hour (movesFloorLoop). Never deleted once written: a store that
-// no longer answers, or no longer holds the object, leaves them alone.
+// every hour (movesFloorLoop). Both write under .moves/floor.lock, and a
+// floor only rises: a lower published floor is refused, and lowering
+// one is an operator's manual step on each box (floorRegression). Never
+// deleted by this code: a store that no longer answers, or no longer
+// holds the object, leaves them alone. floor.json is the one every Go
+// reader trusts; floor.min-release is for the units (writeFloorFiles).
// - <optDir>/carlos.prev.version: carlos.prev's release, written by root
// whenever it replaces carlos.prev (decidePrev), or learned by running
// `carlos.prev version` when it is missing and a floor is armed.
@@ -98,25 +102,76 @@ func readLocalFloor(dataDir string) (*moves.Floor, error) {
return &fl, nil
}
-// writeLocalFloor writes both local floor files, floor.json first. The
-// order matters only across a crash between the two: floor.json alone arms
-// the Go checks (activationAllowed, judgePrev) while the units still pick
-// carlos.prev as main does, and that carlos.prev then refuses itself in
-// restoreFiles if it is below the floor. The next apply or refresh writes
-// the second file.
+// movesDirName is the directory both local floor files live in, below
+// the data dir.
+var movesDirName = filepath.Dir(moves.FloorFile)
+
+// withFloorLock runs fn holding .moves/floor.lock (see floorLockFile). With
+// create, .moves is made when missing (a writer arming the first floor).
+// Without it, a missing .moves means no floor is armed and nothing can be
+// locked: fn runs with a nil dir. An error opening or locking is returned
+// without running fn.
+//
+// Lock order, everywhere both are taken: the carlos.prev lock (root only),
+// then this one, then the registry's write lock (WithHoldsFrozen). The
+// agent takes only this one and holds nothing else while it does.
+func withFloorLock(dataDir string, create bool, fn func(d *movesDir) error) error {
+ d, err := openMovesDir(dataDir, create)
+ if errors.Is(err, os.ErrNotExist) && !create {
+ return fn(nil)
+ }
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ if err := d.Lock(); err != nil {
+ return err
+ }
+ return fn(d)
+}
+
+// writeLocalFloor writes both local floor files under the floor lock.
func writeLocalFloor(dataDir string, f moves.Floor) error {
if !validFloorRelease(f.MinRelease) {
return fmt.Errorf("min_release %q is not a release", f.MinRelease)
}
+ return withFloorLock(dataDir, true, func(d *movesDir) error { return writeFloorFiles(d, f) })
+}
+
+// writeFloorFiles writes floor.min-release FIRST and floor.json LAST, under
+// the floor lock. Every Go reader (activationAllowed, judgePrev, the edges
+// file) trusts floor.json alone; floor.min-release exists for the units'
+// shell, which can only compare files. So after a crash between the two,
+// floor.min-release can only be AHEAD of floor.json (floors only rise, see
+// floorRegression), and ahead means it matches no verdict judged against
+// the JSON: the units run the current binary, whose own check then uses the
+// JSON. The two can never disagree in the direction that would let the
+// units pick a carlos.prev the Go checks would refuse.
+func writeFloorFiles(d *movesDir, f moves.Floor) error {
body, err := json.Marshal(f)
if err != nil {
return err
}
- dir := filepath.Dir(moves.FloorFile)
- if err := writeBeneathDurable(dataDir, dir, filepath.Base(moves.FloorFile), append(body, '\n'), 0o644); err != nil {
+ if err := d.WriteDurable(filepath.Base(moves.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil {
return err
}
- return writeBeneathDurable(dataDir, dir, filepath.Base(moves.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644)
+ return d.WriteDurable(filepath.Base(moves.FloorFile), append(body, '\n'), 0o644)
+}
+
+// floorRegression says why remote may not replace local, or nil. A floor
+// only rises on a box: a lower protocol minimum, or a min_release that is
+// lower or does not compare, is refused. Lowering a floor is an operator's
+// manual step, box by box: delete <DataDir>/.moves/floor.json and
+// floor.min-release (and publish the lower object), and the next apply or
+// refresh arms what is published.
+func floorRegression(local, remote *moves.Floor) error {
+ if remote.Min < local.Min {
+ return fmt.Errorf("the published floor's protocol minimum %d is below this box's %d; lowering a floor is done by hand on each box", remote.Min, local.Min)
+ }
+ if ok, _ := moves.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok {
+ return fmt.Errorf("the published min_release %q is below this box's %s; lowering a floor is done by hand on each box", remote.MinRelease, local.MinRelease)
+ }
+ return nil
}
// errFloorWrite marks a floor the store holds that this box could not
@@ -128,34 +183,62 @@ var errFloorWrite = errors.New("could not write it on this box")
// read the published floor and make the local files say the same. It
// returns the floor on disk afterwards, nil when none is armed.
//
-// - published and usable: written (skipped when already identical);
+// - published, usable and not lower: written, under the floor lock, with
+// the comparison made under the same lock, so an older observation that
+// finishes after a newer one cannot lower the floor;
+// - lower than the floor on disk: refused (floorRegression), logged;
// - absent from the store: what is on disk stays (once armed, armed);
// - the store unreadable, or the object unusable: what is on disk stays,
// and the error is returned to be logged.
+//
+// A local floor.json that cannot be read is replaced by a usable published
+// floor: it holds no value to compare, and the published one is the
+// authority it was copied from.
func syncLocalFloor(ctx context.Context, s release.Store, dataDir string) (*moves.Floor, error) {
- local, lerr := readLocalFloor(dataDir)
if s == nil {
+ local, lerr := readLocalFloor(dataDir)
return local, errors.Join(errors.New("no deployment store to read the floor from"), lerr)
}
remote, rerr := moves.ReadFloor(ctx, s)
switch {
case rerr != nil:
+ local, lerr := readLocalFloor(dataDir)
return local, errors.Join(rerr, lerr)
case remote == nil:
- return local, lerr
+ return readLocalFloor(dataDir)
case !validFloorRelease(remote.MinRelease):
+ local, lerr := readLocalFloor(dataDir)
return local, errors.Join(fmt.Errorf("the published min_release %q is not a release; the floor on this box stays as it was", remote.MinRelease), lerr)
}
- if lerr == nil && local != nil && *local == *remote {
- if line, ok := readBeneathLine(dataDir, moves.FloorMinReleaseFile); ok && line == remote.MinRelease {
- return local, nil
+ var out *moves.Floor
+ ran := false
+ err := withFloorLock(dataDir, true, func(d *movesDir) error {
+ ran = true
+ local, lerr := readLocalFloor(dataDir)
+ if lerr == nil && local != nil {
+ if err := floorRegression(local, remote); err != nil {
+ out = local
+ return err
+ }
+ if *local == *remote {
+ if line, ok := readBeneathLine(dataDir, moves.FloorMinReleaseFile); ok && line == remote.MinRelease {
+ out = local
+ return nil
+ }
+ }
}
+ if err := writeFloorFiles(d, *remote); err != nil {
+ out, _ = readLocalFloor(dataDir)
+ return fmt.Errorf("%w: %v", errFloorWrite, err)
+ }
+ out = remote
+ return nil
+ })
+ if !ran {
+ out, _ = readLocalFloor(dataDir)
+ return out, fmt.Errorf("%w: %v", errFloorWrite, err)
}
- if err := writeLocalFloor(dataDir, *remote); err != nil {
- local, _ = readLocalFloor(dataDir)
- return local, fmt.Errorf("%w: %v", errFloorWrite, err)
- }
- return remote, nil
+ return out, err
}
// readBeneathLine reads a one-line file under root without following a
@@ -214,27 +297,35 @@ func removeDurable(path string) error {
return syncDir(filepath.Dir(path))
}
+// holdFreezer runs fn with the rows an operation holds, held still for
+// fn's length (registry.WithHoldsFrozen's shape).
+type holdFreezer func(fn func(held []registry.Route) error) error
+
// judgePrev is the one judge of carlos.prev (moves spec §11): it writes the
// verdict when carlos.prev is at or above the armed floor and no registry
// row is held by an operation, and removes it otherwise. With no floor
// armed it touches neither the registry nor carlos.prev, and leaves no
// verdict (the units do not read one then).
func judgePrev(optDir, dataDir string, reg *registry.Registry) error {
- return judgePrevWith(optDir, dataDir, func() ([]registry.Route, error) {
+ return judgePrevWith(optDir, dataDir, func(fn func([]registry.Route) error) error {
if reg == nil {
- return nil, errors.New("no registry")
+ return errors.New("no registry")
}
- return reg.HoldRows()
+ return reg.WithHoldsFrozen(fn)
}, nil)
}
-// judgePrevWith is judgePrev with its two inputs as functions: holds lists
-// the held rows, and learn (nil to skip) answers carlos.prev's version when
-// the sidecar is missing; what it learns is written as the sidecar.
+// judgePrevWith is judgePrev with its two inputs as functions: freeze gives
+// the held rows, held still while the verdict is written, and learn (nil to
+// skip) answers carlos.prev's version when the sidecar is missing; what it
+// learns is written as the sidecar. Learning runs carlos.prev, which can
+// take seconds, so it happens before any lock is taken.
//
-// Every refusal removes the verdict before it returns, so an error here
-// never leaves an old verdict standing.
-func judgePrevWith(optDir, dataDir string, holds func() ([]registry.Route, error), learn func() string) error {
+// The judgement itself runs under the floor lock with the holds frozen, so
+// the verdict it writes is for the floor and the holds as they stand when
+// it lands. Every refusal removes the verdict before it returns, so an
+// error here never leaves an old verdict standing.
+func judgePrevWith(optDir, dataDir string, freeze holdFreezer, learn func() string) error {
deny := func(cause error) error {
if err := removeDurable(prevAllowedPath(optDir)); err != nil {
return errors.Join(cause, fmt.Errorf("remove the verdict on carlos.prev: %w", err))
@@ -244,36 +335,49 @@ func judgePrevWith(optDir, dataDir string, holds func() ([]registry.Route, error
if _, err := os.Stat(systemPrevPath(optDir)); err != nil {
return deny(nil)
}
- floor, err := readLocalFloor(dataDir)
- if err != nil {
- return deny(fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err))
- }
- if floor == nil {
- return deny(nil)
- }
- version, ok := readLine(prevVersionPath(optDir))
- if !ok && learn != nil {
- if v := learn(); validSystemVersion(v) {
- if err := writePrevVersion(optDir, v); err != nil {
- return deny(err)
+ if _, ok := readLine(prevVersionPath(optDir)); !ok && learn != nil {
+ if f, _ := readLocalFloor(dataDir); f != nil {
+ if v := learn(); validSystemVersion(v) {
+ if err := writePrevVersion(optDir, v); err != nil {
+ return deny(err)
+ }
}
- version, ok = v, true
}
}
- if !ok {
- return deny(nil)
- }
- if at, _ := moves.ReleaseAtOrAbove(version, floor.MinRelease); !at {
- return deny(nil)
- }
- rows, err := holds()
- if err != nil {
- return deny(fmt.Errorf("held rows: %w", err))
- }
- if len(rows) > 0 {
- return deny(nil)
+ ran := false
+ err := withFloorLock(dataDir, false, func(*movesDir) error {
+ ran = true
+ floor, err := readLocalFloor(dataDir)
+ if err != nil {
+ return deny(fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err))
+ }
+ if floor == nil {
+ return deny(nil)
+ }
+ version, ok := readLine(prevVersionPath(optDir))
+ if !ok {
+ return deny(nil)
+ }
+ if at, _ := moves.ReleaseAtOrAbove(version, floor.MinRelease); !at {
+ return deny(nil)
+ }
+ called := false
+ err = freeze(func(held []registry.Route) error {
+ called = true
+ if len(held) > 0 {
+ return deny(nil)
+ }
+ return writeFileDurable(prevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
+ })
+ if !called {
+ return deny(fmt.Errorf("held rows: %w", err))
+ }
+ return err
+ })
+ if !ran {
+ return deny(fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err))
}
- return writeFileDurable(prevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
+ return err
}
// prevLockFile serialises the two writers of carlos.prev's sidecar and
@@ -329,7 +433,7 @@ func rootJudgePrev(reg *registry.Registry) error {
}
}
return withPrevLock(optDir, func() error {
- return judgePrevWith(optDir, dd, func() ([]registry.Route, error) { return reg.HoldRows() }, func() string {
+ return judgePrevWith(optDir, dd, reg.WithHoldsFrozen, func() string {
out, err := productionSysOps().run(context.Background(), systemVersionWait, systemPrevPath(optDir), "version")
if err != nil {
return ""
@@ -365,7 +469,12 @@ func movesFloorLoop(ctx context.Context, s release.Store, dataDir string) {
// verdict, which the door reads as below the floor).
func withMovesFields(f edgesFile, dataDir, optDir string) edgesFile {
f.MovesProtocol = moves.ProtocolVersion
- f.MovesFloorMinRelease, _ = readLine(filepath.Join(dataDir, moves.FloorMinReleaseFile))
+ // From floor.json, the file every Go reader trusts: floor.min-release
+ // is written first and can be ahead of it (writeFloorFiles).
+ f.MovesFloorMinRelease = ""
+ if fl, err := readLocalFloor(dataDir); err == nil && fl != nil {
+ f.MovesFloorMinRelease = fl.MinRelease
+ }
f.PrevRelease = ""
if _, err := os.Stat(systemPrevPath(optDir)); errors.Is(err, os.ErrNotExist) {
f.PrevRelease = "none"
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go
index fb059f382..14fc5cbba 100644
--- a/internal/host/movesfloor_linux.go
+++ b/internal/host/movesfloor_linux.go
@@ -12,50 +12,117 @@ import (
"path/filepath"
"strconv"
"syscall"
+ "time"
)
-// writeBeneathDurable writes data to root/dir/name the way writeDurable
-// does (a temp file, fsync, rename, fsync of the directory), but every step
-// is taken relative to a directory opened with O_NOFOLLOW, so neither dir
-// nor name can be a symlink. It is how root writes the local floor files
-// under /data, a tree the sandboxed agent owns and could plant a symlink
-// in. dir is created (0755) when it is missing; when root creates it, it
-// is handed to the service user, because the agent's hourly refresh writes
-// the same files.
+// movesDir is <DataDir>/.moves, opened with O_NOFOLLOW below a data dir
+// root trusts, so nothing root reads or writes through it can be a
+// symlink the sandboxed agent planted. Every operation on the floor files
+// goes through one: the lock and the writes are all relative to its fd.
+type movesDir struct {
+ fd int
+ path string
+ lock int // the flocked floor.lock fd, or -1
+}
+
+// floorLockFile serialises every writer of the floor files (root's apply,
+// the agent's refresh) with every reader whose answer must not change under
+// it (root's judgement of carlos.prev and its activation guard).
+const floorLockFile = "floor.lock"
+
+const (
+ floorLockWait = 2 * time.Minute
+ floorLockPoll = 100 * time.Millisecond
+)
+
+// openMovesDir opens <root>/.moves. With create it makes the directory
+// when missing; without, a missing directory is os.ErrNotExist.
//
-// The temp name is random, so root's apply and the agent's refresh never
-// write into each other's temp file.
-func writeBeneathDurable(root, dir, name string, data []byte, mode os.FileMode) error {
+// 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.
+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)
if err != nil {
- return fmt.Errorf("open %s: %w", root, err)
+ return nil, fmt.Errorf("open %s: %w", root, err)
}
defer syscall.Close(rfd)
- dirFlags := syscall.O_RDONLY | syscall.O_DIRECTORY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
- dfd, err := syscall.Openat(rfd, dir, dirFlags, 0)
- if errors.Is(err, syscall.ENOENT) {
- if err := syscall.Mkdirat(rfd, dir, 0o755); err != nil && !errors.Is(err, syscall.EEXIST) {
- return fmt.Errorf("mkdir %s: %w", filepath.Join(root, dir), err)
+ flags := syscall.O_RDONLY | syscall.O_DIRECTORY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
+ fd, err := syscall.Openat(rfd, name, flags, 0)
+ if errors.Is(err, syscall.ENOENT) && create {
+ 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 dfd, err = syscall.Openat(rfd, dir, dirFlags, 0); err == nil {
- fchownToService(dfd)
+ 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)
+ }
+ }
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", filepath.Join(root, name), err)
+ }
+ return &movesDir{fd: fd, path: filepath.Join(root, name), lock: -1}, nil
+}
+
+// Close releases the lock (closing its fd) and the directory.
+func (d *movesDir) Close() {
+ if d.lock >= 0 {
+ syscall.Close(d.lock)
+ d.lock = -1
}
+ syscall.Close(d.fd)
+}
+
+// Lock takes floor.lock, waiting up to floorLockWait. Opened read-only (a
+// read-only fd can be flocked), so the agent can lock a file root created
+// and root a file the agent created; O_NOFOLLOW and O_NONBLOCK so a symlink
+// or a FIFO planted in its place cannot redirect or park root.
+func (d *movesDir) Lock() error {
+ fd, err := syscall.Openat(d.fd, floorLockFile, syscall.O_RDONLY|syscall.O_CREAT|syscall.O_NOFOLLOW|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0o644)
if err != nil {
- return fmt.Errorf("open %s: %w", filepath.Join(root, dir), err)
+ return fmt.Errorf("open %s: %w", filepath.Join(d.path, floorLockFile), err)
+ }
+ var st syscall.Stat_t
+ if err := syscall.Fstat(fd, &st); err != nil || st.Mode&syscall.S_IFMT != syscall.S_IFREG {
+ syscall.Close(fd)
+ return fmt.Errorf("%s is not a regular file", filepath.Join(d.path, floorLockFile))
}
- defer syscall.Close(dfd)
+ deadline := time.Now().Add(floorLockWait)
+ for {
+ err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB)
+ if err == nil {
+ d.lock = fd
+ return nil
+ }
+ if !errors.Is(err, syscall.EWOULDBLOCK) || time.Now().After(deadline) {
+ syscall.Close(fd)
+ return fmt.Errorf("lock %s: %w", filepath.Join(d.path, floorLockFile), err)
+ }
+ time.Sleep(floorLockPoll)
+ }
+}
+// WriteDurable writes data to .moves/name the way writeDurable does (a
+// temp file, fsync, rename, fsync of the directory), all relative to the
+// directory's fd. The temp name is random, so two writers never share one.
+func (d *movesDir) WriteDurable(name string, data []byte, mode os.FileMode) error {
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return err
}
tmp := name + ".tmp." + hex.EncodeToString(suffix[:])
- fd, err := syscall.Openat(dfd, tmp, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(mode.Perm()))
+ fd, err := syscall.Openat(d.fd, tmp, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, uint32(mode.Perm()))
if err != nil {
- return fmt.Errorf("create %s: %w", filepath.Join(root, dir, tmp), err)
+ return fmt.Errorf("create %s: %w", filepath.Join(d.path, tmp), err)
}
- f := os.NewFile(uintptr(fd), filepath.Join(root, dir, tmp))
+ f := os.NewFile(uintptr(fd), filepath.Join(d.path, tmp))
werr := func() error {
if _, err := f.Write(data); err != nil {
return err
@@ -69,14 +136,14 @@ func writeBeneathDurable(root, dir, name string, data []byte, mode os.FileMode)
werr = cerr
}
if werr == nil {
- werr = syscall.Renameat(dfd, tmp, dfd, name)
+ werr = syscall.Renameat(d.fd, tmp, d.fd, name)
}
if werr != nil {
- syscall.Unlinkat(dfd, tmp)
- return fmt.Errorf("write %s: %w", filepath.Join(root, dir, name), werr)
+ syscall.Unlinkat(d.fd, tmp)
+ return fmt.Errorf("write %s: %w", filepath.Join(d.path, name), werr)
}
- if err := syscall.Fsync(dfd); err != nil {
- return fmt.Errorf("fsync %s: %w", filepath.Join(root, dir), err)
+ if err := syscall.Fsync(d.fd); err != nil {
+ return fmt.Errorf("fsync %s: %w", d.path, err)
}
return nil
}
diff --git a/internal/host/movesfloor_other.go b/internal/host/movesfloor_other.go
index 6aca8f058..0390a1d66 100644
--- a/internal/host/movesfloor_other.go
+++ b/internal/host/movesfloor_other.go
@@ -4,9 +4,18 @@ package host
import "os"
-// writeBeneathDurable refuses off Linux, like openBeneath: the floor files
-// are written by root's apply and the agent, both of which run on a Linux
-// box only (see systemapply_other.go).
-func writeBeneathDurable(root, dir, name string, data []byte, mode os.FileMode) error {
+// The floor files are written by root's apply and the agent, both of which
+// run on a Linux box only (see systemapply_other.go). Off Linux every
+// operation refuses, like openBeneath.
+
+type movesDir struct{}
+
+func openMovesDir(root string, create bool) (*movesDir, error) { return nil, errSystemUnsupported }
+
+func (d *movesDir) Close() {}
+
+func (d *movesDir) Lock() error { return errSystemUnsupported }
+
+func (d *movesDir) WriteDurable(name string, data []byte, mode os.FileMode) error {
return errSystemUnsupported
}
diff --git a/internal/host/movesfloor_test.go b/internal/host/movesfloor_test.go
index 5d5970cae..45522b9b0 100644
--- a/internal/host/movesfloor_test.go
+++ b/internal/host/movesfloor_test.go
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
@@ -31,12 +32,15 @@ func armLocalFloor(t *testing.T, dataDir, minRelease string) {
}
}
-func noHolds() ([]registry.Route, error) { return nil, nil }
+// The judge's held-row inputs, shaped like registry.WithHoldsFrozen.
+func noHolds(fn func([]registry.Route) error) error { return fn(nil) }
-func oneHold() ([]registry.Route, error) {
- return []registry.Route{{Host: "app.example.com", Status: "moving", HoldOp: "op1"}}, nil
+func oneHold(fn func([]registry.Route) error) error {
+ return fn([]registry.Route{{Host: "app.example.com", Status: "moving", HoldOp: "op1"}})
}
+func unreadableHolds(func([]registry.Route) error) error { return errors.New("locked") }
+
func TestLocalFloorFilesRoundTrip(t *testing.T) {
dd := t.TempDir()
f, err := readLocalFloor(dd)
@@ -166,13 +170,13 @@ func TestJudgePrevWritesTheVerdictOnlyAtOrAboveTheFloorWithoutAHold(t *testing.T
})
for _, tc := range []struct {
name, floor, prev string
- holds func() ([]registry.Route, error)
+ holds holdFreezer
}{
{"below the floor", "v0.30.0", "v0.29.0", noHolds},
{"a prerelease", "v0.30.0", "v0.30.0-rc.1", noHolds},
{"no sidecar", "v0.30.0", "", noHolds},
{"a held row", "v0.30.0", "v0.31.0", oneHold},
- {"the registry unreadable", "v0.30.0", "v0.31.0", func() ([]registry.Route, error) { return nil, errors.New("locked") }},
+ {"the registry unreadable", "v0.30.0", "v0.31.0", unreadableHolds},
} {
t.Run(tc.name, func(t *testing.T) {
opt, dd := setup(t, tc.floor, tc.prev)
@@ -216,7 +220,7 @@ func TestJudgePrevIsDormantWithoutAFloor(t *testing.T) {
opt, dd := t.TempDir(), t.TempDir()
os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
consulted := false
- holds := func() ([]registry.Route, error) { consulted = true; return nil, nil }
+ holds := func(fn func([]registry.Route) error) error { consulted = true; return fn(nil) }
ran := false
if err := judgePrevWith(opt, dd, holds, func() string { ran = true; return "v0.29.0" }); err != nil {
t.Fatal(err)
@@ -251,3 +255,105 @@ func TestMovesFloorRefreshArmsFromTheStore(t *testing.T) {
t.Fatal("a failed refresh said nothing")
}
}
+
+// The floor never goes down on its own (fix round 1, finding 3). Lowering a
+// floor is an operator's manual step on each box: delete the two local files.
+func TestSyncLocalFloorRefusesARegression(t *testing.T) {
+ ctx := context.Background()
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ armLocalFloor(t, dd, "v0.31.0")
+ putFloor(t, s, "v0.30.0")
+ if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("a lower min_release = %+v, %v", f, err)
+ }
+ // A lower protocol minimum at the same release is a regression too.
+ if err := writeLocalFloor(dd, moves.Floor{Min: moves.ProtocolVersion + 1, MinRelease: "v0.31.0"}); err != nil {
+ t.Fatal(err)
+ }
+ s.Delete(ctx, moves.FloorKey)
+ putFloor(t, s, "v0.31.0")
+ if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.Min != moves.ProtocolVersion+1 {
+ t.Fatalf("a lower min = %+v, %v", f, err)
+ }
+ if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" {
+ t.Fatalf("floor.min-release = %q", b)
+ }
+}
+
+// racingStore answers an old floor, but only after a newer refresh has run
+// to completion: an older observation finishing last.
+type racingStore struct {
+ release.Store
+ newer func()
+}
+
+func (r racingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ r.newer()
+ return r.Store.Get(ctx, key)
+}
+
+func TestAnOlderRefreshFinishingLastDoesNotLowerTheFloor(t *testing.T) {
+ ctx := context.Background()
+ dd := t.TempDir()
+ old, newer := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ putFloor(t, old, "v0.30.0")
+ putFloor(t, newer, "v0.31.0")
+ race := racingStore{Store: old, newer: func() {
+ if f, err := syncLocalFloor(ctx, newer, dd); err != nil || f == nil || f.MinRelease != "v0.31.0" {
+ t.Errorf("newer refresh = %+v, %v", f, err)
+ }
+ }}
+ syncLocalFloor(ctx, race, dd)
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("the older refresh lowered the floor to %+v", f)
+ }
+ if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" {
+ t.Fatalf("floor.min-release = %q", b)
+ }
+}
+
+// Root's apply and the agent's refresh write the floor under one lock file
+// in .moves, so neither sees the other's half-written pair.
+func TestFloorWritersTakeTheFloorLock(t *testing.T) {
+ ctx := context.Background()
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ armLocalFloor(t, dd, "v0.30.0")
+ putFloor(t, s, "v0.31.0")
+ held, free := make(chan struct{}), make(chan struct{})
+ go withFloorLock(dd, false, func(*movesDir) error {
+ close(held)
+ <-free
+ return nil
+ })
+ <-held
+ done := make(chan struct{})
+ go func() { syncLocalFloor(ctx, s, dd); close(done) }()
+ select {
+ case <-done:
+ t.Fatal("a floor write finished while another writer held the lock")
+ case <-time.After(300 * time.Millisecond):
+ }
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("written under another writer's lock: %+v", f)
+ }
+ close(free)
+ <-done
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("after the lock = %+v", f)
+ }
+}
+
+// Readers trust floor.json alone; floor.min-release is written first, so
+// after a crash between the two it can only be ahead, and only the units
+// read it (where ahead means no verdict matches: the current binary runs).
+func TestFloorReadersTrustOnlyTheJSON(t *testing.T) {
+ dd, opt := t.TempDir(), t.TempDir()
+ armLocalFloor(t, dd, "v0.30.0")
+ os.WriteFile(filepath.Join(dd, moves.FloorMinReleaseFile), []byte("v0.31.0\n"), 0o644)
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("readLocalFloor = %+v", f)
+ }
+ if got := withMovesFields(edgesFile{}, dd, opt).MovesFloorMinRelease; got != "v0.30.0" {
+ t.Fatalf("edges file publishes %q, want the JSON's v0.30.0", got)
+ }
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 91c6d606a..88b12494b 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -420,6 +420,9 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea
}
return a.record(req, err)
}
+ // The release on disk, for the activation guard at the rename: read now,
+ // because reading it runs the binary and the guard holds locks.
+ running := a.onDiskBuild(ctx)
// PromotedAt rides along for the whole transaction: it is half of the
// tuple a recovery has to answer for.
j := systemJournal{Phase: systemPhaseInstalling, Target: target, PromotedAt: req.PromotedAt, PrevBuild: a.prevBuild(ctx),
@@ -436,7 +439,17 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea
if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil {
a.ops.logf("system apply: quiesce: %v (continuing)", err)
}
- if err := os.Rename(staged, systemBinaryPath(a.optDir)); err != nil {
+ // 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
+ // and the holds are held still across the rename.
+ if err := a.guardActivation(target, running, func() error {
+ return os.Rename(staged, systemBinaryPath(a.optDir))
+ }); err != nil {
+ var refusal activationRefused
+ if errors.As(err, &refusal) {
+ return a.abandonInstall(req, err)
+ }
return fail("install", err)
}
if err := os.Chown(systemBinaryPath(a.optDir), 0, 0); err != nil && !errors.Is(err, os.ErrPermission) {
@@ -471,6 +484,26 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea
return a.finish(req, target, newPlan)
}
+// 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
+ for _, u := range systemQuiesceUnits {
+ if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") {
+ armed = append(armed, 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)
+ }
+ return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
+}
+
// finish is steps 11–12, and what recovery runs forward from phase gated.
func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapPlan) error {
reason := ""
@@ -568,7 +601,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
}
}
}
- return a.floorStep(judgePrevWith(a.optDir, a.dataDir, a.holdRows, func() string { return a.prevBuild(ctx) }))
+ return a.floorStep(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }))
}
err := withPrevLock(a.optDir, steps)
if !ran {
@@ -644,6 +677,11 @@ func (a *systemApplier) armFloor(ctx context.Context) error {
// With no floor armed it reads neither the registry nor any version and
// allows, with one log line: main's behaviour.
//
+// This is the EARLY check, made before anything is staged so a refused
+// build is never copied in or run. It can go stale by the time the build is
+// put in place, so the step that puts it there runs under guardActivation,
+// which applies the same rule again with its inputs held still.
+//
// How the hold half relates to systemparticipation.go (region HA H14): that
// guard defers any executable change, up or down, while this box takes
// part in a claim operation (.hostexec), and only under CARLOS_REGION_HA,
@@ -654,53 +692,122 @@ func (a *systemApplier) armFloor(ctx context.Context) error {
// (this check before staging, H14's marker just before the journal) and
// either refusal stands. Neither reads or relaxes the other.
func (a *systemApplier) activationAllowed(ctx context.Context, version string) error {
+ return a.activationRule(version, func() string { return a.onDiskBuild(ctx) }, nil)
+}
+
+// activationRefused is a refusal by the activation rule, told apart from a
+// failure of the step it guards: the install abandons on one and rolls
+// back on the other.
+type activationRefused struct{ error }
+
+func (e activationRefused) Unwrap() error { return e.error }
+
+func refuse(format string, args ...any) error {
+ return activationRefused{fmt.Errorf(format, args...)}
+}
+
+// guardActivation runs swap, the step that makes version this box's
+// executable, with the rule's inputs held still across it (fix round 1):
+// the floor files under the floor lock, which every floor writer and every
+// judgement of carlos.prev also takes, and, when the rule needs them, the
+// held rows under the registry's write lock (WithHoldsFrozen: a SetHold
+// waits). running is the release on disk, read by the caller before any
+// 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 {
+ ran := false
+ err := withFloorLock(a.dataDir, false, func(*movesDir) error {
+ ran = true
+ return a.activationRule(version, func() string { return running }, swap)
+ })
+ if !ran {
+ return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
+ }
+ return err
+}
+
+// activationRule is the rule itself. swap, when not nil, runs only once the
+// rule allows, inside whatever the rule had to hold to decide: guardActivation
+// passes the step, and the early check (activationAllowed) passes nil.
+func (a *systemApplier) activationRule(version string, running func() string, swap func() error) error {
+ early := swap == nil
+ if early {
+ swap = func() error { return nil }
+ }
floor, err := readLocalFloor(a.dataDir)
if err != nil {
- return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err)
+ return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
}
if floor == nil {
+ if !early {
+ return swap()
+ }
a.ops.logf("system apply: no moves protocol floor is armed on this box; %q is not checked against one", version)
- return nil
+ return swap()
}
if version == "" {
- return fmt.Errorf("moves protocol floor: carlos.prev records no version, so it is below %s", floor.MinRelease)
+ return refuse("moves protocol floor: carlos.prev records no version, so it is below %s", floor.MinRelease)
}
if ok, _ := moves.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
- return fmt.Errorf("moves protocol floor: %s is below %s", version, floor.MinRelease)
+ return refuse("moves protocol floor: %s is below %s", version, floor.MinRelease)
+ }
+ run := running()
+ if version == run {
+ return swap()
+ }
+ if ok, _ := moves.ReleaseAtOrAbove(version, run); ok {
+ return swap() // not a downgrade: the held rows do not matter
+ }
+ // A downgrade, or a running release that cannot be compared: allowed
+ // only while no row is held, and the rows stay as they were read until
+ // swap has run.
+ called := false
+ err = a.freezeHolds(func(held []registry.Route) error {
+ called = true
+ if len(held) == 0 {
+ return swap()
+ }
+ if run == "" {
+ return refuse("a participant in a held operation does not downgrade, and the running release is unknown")
+ }
+ return refuse("a participant in a held operation does not downgrade: %s is below the running %s", version, run)
+ })
+ if !called {
+ return refuse("a participant in a held operation does not downgrade, and the registry cannot say whether this box is one: %v", err)
}
- rows, err := a.holdRows()
+ return err
+}
+
+// floorAllowsForward is the floor half alone, for recovery's forward path:
+// the gated build is already the one on disk, so only the floor can refuse
+// it. Unarmed: allowed.
+func (a *systemApplier) floorAllowsForward(version string) error {
+ floor, err := readLocalFloor(a.dataDir)
if err != nil {
- return fmt.Errorf("a participant in a held operation does not downgrade, and the registry cannot say whether this box is one: %v", err)
- }
- if len(rows) == 0 {
- return nil
+ return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err)
}
- running := a.onDiskBuild(ctx)
- if version == running {
+ if floor == nil {
return nil
}
- if running == "" {
- return errors.New("a participant in a held operation does not downgrade, and the running release is unknown")
- }
- if ok, _ := moves.ReleaseAtOrAbove(version, running); !ok {
- return fmt.Errorf("a participant in a held operation does not downgrade: %s is below the running %s", version, running)
+ if ok, _ := moves.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
+ return fmt.Errorf("moves protocol floor: %s is below %s", version, floor.MinRelease)
}
return nil
}
-// holdRows opens the registry (openRegistry, as the layout guard does) and
-// lists the rows an operation holds.
-func (a *systemApplier) holdRows() ([]registry.Route, error) {
+// freezeHolds opens the registry (openRegistry, as the layout guard does)
+// and runs fn with the held rows frozen (registry.WithHoldsFrozen).
+func (a *systemApplier) freezeHolds(fn func(held []registry.Route) error) error {
open := a.openRegistry
if open == nil {
open = func() (*registry.Registry, error) { return openSystemRegistry(a.dataDir) }
}
reg, err := open()
if err != nil {
- return nil, err
+ return err
}
defer reg.Close()
- return reg.HoldRows()
+ return reg.WithHoldsFrozen(fn)
}
func (a *systemApplier) prevBuild(ctx context.Context) string {
@@ -1118,7 +1225,8 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo
// sidecar is below any armed floor). A refusal keeps the journal: the
// box stays on the build it is on, and the caller reports it.
prevVersion, _ := readLine(prevVersionPath(a.optDir))
- if err := a.activationAllowed(ctx, prevVersion); err != nil {
+ running := a.onDiskBuild(ctx)
+ if err := a.activationRule(prevVersion, func() string { return running }, nil); err != nil {
return bootstrapPlan{}, err
}
// The same quiesce the install did: a root pass that is mid-flight
@@ -1126,7 +1234,20 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo
if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil {
a.ops.logf("system apply: quiesce: %v (continuing)", err)
}
- if err := copyFileDurable(prev, systemBinaryPath(a.optDir), 0o755); err != nil {
+ // 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.
+ restore := systemBinaryPath(a.optDir) + ".restore"
+ if err := copyFileDurable(prev, restore, 0o755); err != nil {
+ return bootstrapPlan{}, err
+ }
+ if err := a.guardActivation(prevVersion, running, func() error {
+ if err := os.Rename(restore, systemBinaryPath(a.optDir)); err != nil {
+ return err
+ }
+ return syncDir(a.optDir)
+ }); err != nil {
+ os.Remove(restore)
return bootstrapPlan{}, err
}
// The unit set to put back is whatever the binary being restored ships,
@@ -1242,6 +1363,12 @@ func (a *systemApplier) recoverIfNeeded(ctx context.Context) (bool, error) {
if j.Phase == systemPhaseGated {
if got := a.onDiskBuild(ctx); got != j.Target {
a.ops.logf("system apply: recovery: %s passed its gate but the binary on disk reports %q; rolling back instead", j.Target, got)
+ } else if err := a.floorAllowsForward(j.Target); err != nil {
+ // A floor raised since the gate (moves spec §11; fix round 1): the
+ // build is not finished forward. Back to carlos.prev, which
+ // restoreFiles judges in turn; if that is refused too, the
+ // journal stays and the box stays as it is.
+ a.ops.logf("system apply: recovery: %s passed its gate but %v; rolling back instead", j.Target, err)
} else {
plan, _, err := a.stagePlan(ctx, systemBinaryPath(a.optDir), filepath.Join(a.stagingDir(), "new"))
if err != nil {
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go
index f27a8cf2d..146dbf62c 100644
--- a/internal/host/systemapply_test.go
+++ b/internal/host/systemapply_test.go
@@ -1852,3 +1852,161 @@ func TestFloorIsDormantWithoutTheObject(t *testing.T) {
t.Fatalf("on disk %q", f.onDisk())
}
}
+
+// duringStaging runs change when the apply runs the staged candidate's
+// `version`, which is the middle of staging: after the early activation
+// check, before the install puts anything in place.
+func (f *applyFixture) duringStaging(change func()) {
+ staged := filepath.Join(f.a.stagingDir(), "carlos")
+ run := f.a.ops.run
+ once := false
+ f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
+ if name == staged && !once {
+ once = true
+ change()
+ }
+ return run(ctx, d, name, args...)
+ }
+}
+
+// Fix round 1, finding 1: the early check can go stale while the candidate
+// is staged. The install checks again at the rename, under the floor lock
+// and with the held rows frozen, and a refusal there leaves the box as it
+// was: the binary untouched, no journal, and the quiesced passes re-armed.
+func TestActivationIsCheckedAgainAtTheRename(t *testing.T) {
+ for _, tc := range []struct {
+ name, running, target, want string
+ change func(f *applyFixture)
+ }{
+ {"a floor raised during staging", "v0.31.0", "v0.32.0", "moves protocol floor: v0.32.0 is below v0.33.0",
+ func(f *applyFixture) { armLocalFloor(t, f.a.dataDir, "v0.33.0") }},
+ {"a hold set during staging", "v0.32.0", "v0.31.0", "a participant in a held operation does not downgrade",
+ func(f *applyFixture) { f.holdRow("app.example.com") }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f, _ := floorFixture(t, tc.running, tc.target)
+ putFloor(t, f.store, "v0.30.0")
+ f.duringStaging(func() { tc.change(f) })
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, tc.want) {
+ t.Fatalf("result = %+v", r)
+ }
+ if f.onDisk() != tc.running {
+ t.Fatalf("on disk %q, want %s", f.onDisk(), tc.running)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatalf("journal left open: %+v", j)
+ }
+ joined := strings.Join(f.calls, "\n")
+ if !strings.Contains(joined, "systemctl start carlos-adopt.timer") {
+ t.Fatalf("the quiesced passes were not re-armed:\n%s", joined)
+ }
+ if strings.Contains(joined, "systemctl restart carlos-edge") {
+ t.Fatalf("a refused install restarted the edge:\n%s", joined)
+ }
+ })
+ }
+}
+
+// Fix round 1, finding 2: a gated journal is finished forward only when its
+// target is at or above the floor armed now. Below it, recovery goes back
+// to carlos.prev, which restoreFiles judges in turn.
+func TestForwardRecoveryChecksTheFloor(t *testing.T) {
+ for _, tc := range []struct {
+ name, prev, want string
+ ok bool
+ }{
+ {"carlos.prev below the floor too", "v0.30.0", "v0.31.0", false},
+ {"carlos.prev at the floor", "v0.32.0", "v0.32.0", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f, _ := floorFixture(t, "v0.30.0", "v0.31.0")
+ f.brokenBox("v0.31.0", tc.prev, tc.prev)
+ f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: tc.prev, StartedAt: f.now})
+ armLocalFloor(t, f.a.dataDir, "v0.32.0")
+ 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("a below-floor target was finished forward: %+v", ap)
+ }
+ j, _ := readSystemJournal(f.a.optDir)
+ if f.onDisk() != tc.want {
+ t.Fatalf("on disk %q, want %s", f.onDisk(), tc.want)
+ }
+ if tc.ok && (err != nil || j != nil) {
+ t.Fatalf("err %v, journal %+v", err, j)
+ }
+ if !tc.ok && (err == nil || !strings.Contains(err.Error(), "moves protocol floor") || j == nil) {
+ t.Fatalf("err %v, journal %+v", err, j)
+ }
+ })
+ }
+}
+
+// restoreFiles puts carlos.prev in place under the floor lock: a floor
+// writer holding it holds the restore off.
+func TestRestoreWaitsForTheFloorLock(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")
+ held, free := make(chan struct{}), make(chan struct{})
+ go withFloorLock(f.a.dataDir, false, func(*movesDir) error {
+ close(held)
+ <-free
+ return nil
+ })
+ <-held
+ done := make(chan error, 1)
+ go func() { _, err := f.a.restoreFiles(context.Background(), &j); done <- err }()
+ time.Sleep(300 * time.Millisecond)
+ // The bytes, not f.onDisk: the fake ops are the restore's while it runs.
+ if b, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !strings.Contains(string(b), "v0.32.0") {
+ close(free)
+ t.Fatalf("restored while the floor lock was held: %q", b)
+ }
+ close(free)
+ if err := <-done; err != nil || f.onDisk() != "v0.31.0" {
+ t.Fatalf("after the lock: err %v, on disk %q", err, f.onDisk())
+ }
+}
+
+// The install's rename waits for the floor lock: a floor writer that takes
+// it after the journal (at the quiesce) holds the binary where it is.
+func TestInstallRenameWaitsForTheFloorLock(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ putFloor(t, f.store, "v0.30.0")
+ var duringLock []byte
+ held := make(chan struct{})
+ freed := make(chan struct{})
+ systemctl := f.a.ops.systemctl
+ once := false
+ f.a.ops.systemctl = func(args ...string) (string, error) {
+ if args[0] == "stop" && !once {
+ once = true
+ go func() {
+ defer close(freed)
+ withFloorLock(f.a.dataDir, false, func(*movesDir) error {
+ close(held)
+ time.Sleep(time.Second)
+ duringLock, _ = os.ReadFile(systemBinaryPath(f.a.optDir))
+ return nil
+ })
+ }()
+ <-held
+ }
+ return systemctl(args...)
+ }
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ <-freed
+ if !strings.Contains(string(duringLock), "carlos v0.31.0") {
+ t.Fatalf("the binary was replaced while the floor lock was held: %q", duringLock)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" {
+ t.Fatalf("result %+v, on disk %q", r, f.onDisk())
+ }
+}
diff --git a/internal/registry/hold_test.go b/internal/registry/hold_test.go
index e5b4569d9..ddce56e60 100644
--- a/internal/registry/hold_test.go
+++ b/internal/registry/hold_test.go
@@ -1,8 +1,10 @@
package registry
import (
+ "errors"
"path/filepath"
"testing"
+ "time"
)
const (
@@ -366,3 +368,47 @@ func TestFingerprintsUnchangedForUnheldRows(t *testing.T) {
t.Fatalf("SnapshotFingerprint = %s, want %s", got, wantS)
}
}
+
+// WithHoldsFrozen is root's activation guard (moves Task 13): while fn runs,
+// no other handle can write the registry, so a SetHold from the agent waits
+// and the held rows fn saw are still the held rows when it returns.
+func TestWithHoldsFrozenHoldsOffASetHold(t *testing.T) {
+ r := holdFixture(t, "h")
+ other, err := Open(r.Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Close()
+ done := make(chan error, 1)
+ err = r.WithHoldsFrozen(func(held []Route) error {
+ if len(held) != 0 {
+ t.Errorf("held = %+v", held)
+ }
+ go func() { done <- other.SetHold("h", "moving", holdOpA) }()
+ select {
+ case err := <-done:
+ t.Errorf("SetHold finished while the holds were frozen: %v", err)
+ done <- err // for the read below
+ case <-time.After(300 * time.Millisecond):
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := <-done; err != nil {
+ t.Fatalf("SetHold after the freeze: %v", err)
+ }
+ var seen []Route
+ if err := r.WithHoldsFrozen(func(held []Route) error { seen = held; return nil }); err != nil {
+ t.Fatal(err)
+ }
+ if len(seen) != 1 || seen[0].Host != "h" || seen[0].HoldOp != holdOpA {
+ t.Fatalf("held after SetHold = %+v", seen)
+ }
+ // fn's error is the call's, and nothing fn did not do is written.
+ sentinel := errors.New("refused")
+ if err := r.WithHoldsFrozen(func([]Route) error { return sentinel }); !errors.Is(err, sentinel) {
+ t.Fatalf("err = %v", err)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index d14547170..ecdc61823 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -10,6 +10,7 @@
package registry
import (
+ "context"
"crypto/sha256"
"database/sql"
"errors"
@@ -1132,6 +1133,47 @@ func (r *Registry) HoldRows() ([]Route, error) {
return out, rows.Err()
}
+// WithHoldsFrozen runs fn with the rows an operation holds, while no other
+// connection can write the registry: it takes SQLite's write lock first
+// (BEGIN IMMEDIATE), so a SetHold or ReleaseHold elsewhere waits on its
+// busy_timeout until fn returns, and the rows fn was given are still the
+// held rows when it does. It is root's activation guard (moves Task 13): the
+// check that no row is held and the step that puts an older executable in
+// place happen with the answer held still. fn must be quick (a rename);
+// writers give up after busy_timeout. Nothing is written: the transaction is
+// rolled back. fn's error is returned.
+func (r *Registry) WithHoldsFrozen(fn func(held []Route) error) error {
+ ctx := context.Background()
+ conn, err := r.sql.Conn(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
+ return err
+ }
+ defer conn.ExecContext(ctx, "ROLLBACK")
+ rows, err := conn.QueryContext(ctx, `SELECT `+routeCols+` FROM routes WHERE hold_op != '' ORDER BY host`)
+ if err != nil {
+ return err
+ }
+ var held []Route
+ for rows.Next() {
+ t, err := scanRoute(rows.Scan)
+ if err != nil {
+ rows.Close()
+ return err
+ }
+ held = append(held, t)
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return err
+ }
+ rows.Close()
+ return fn(held)
+}
+
// SetAccount stamps a route's app→account linkage as a narrow UPDATE — the
// reconcile pass re-stamps this every tick from the bucket manifests, and a
// read-modify-write Upsert racing a deploy's SetVersions (or the activator's