carlos / platform Public

host: the box's local seeds floor and its verdict on carlos.prev (Plan 3 Task 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cb828fbadaf204d953b6216bb381b83ae9e53634 parent 56bbe42
5 files changed, +598 −5
  • internal/host/movesfloor.go +14 −5
  • internal/host/movesfloor_linux.go +7 −0
  • internal/host/seedsfloor.go +189 −0
  • internal/host/seedsfloor_linux_test.go +120 −0
  • internal/host/seedsfloor_test.go +268 −0

CI passed — run details

diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
index 8203cabbe..5b1692218 100644
--- a/internal/host/movesfloor.go
+++ b/internal/host/movesfloor.go
@@ -440,11 +440,18 @@ func withPrevLock(optDir string, fn func() error) error {
return fn()
}
-// rootJudgePrev is the root adopt pass's judgement (every mode): the same
-// judge decidePrev runs, under the same lock, learning carlos.prev's
-// version by running it when the sidecar is missing (a box whose
-// carlos.prev was installed by a build before the floor existed).
+// rootJudgePrev is the root adopt pass's judgement (every mode): the moves
+// half, then the seeds half (seedsfloor.go). Each runs whatever the other
+// returns.
func rootJudgePrev(reg *registry.Registry) error {
+ return errors.Join(rootJudgeMovesPrev(reg), rootJudgeSeedsPrev())
+}
+
+// rootJudgeMovesPrev is the moves half: the same judge decidePrev runs,
+// under the same lock, learning carlos.prev's version by running it when
+// the sidecar is missing (a box whose carlos.prev was installed by a build
+// before the floor existed).
+func rootJudgeMovesPrev(reg *registry.Registry) error {
optDir, dd := release.OptDir(), dataDir()
if _, err := os.Stat(filepath.Join(dd, moves.FloorFile)); errors.Is(err, os.ErrNotExist) {
// No floor armed: nothing to judge and no verdict to keep. The one
@@ -465,11 +472,13 @@ func rootJudgePrev(reg *registry.Registry) error {
}
// movesFloorRefresh is one pass of the agent's refresh: syncLocalFloor on
-// the agent's own store handle, logged when it fails.
+// the agent's own store handle, logged when it fails, then the seeds
+// floor's (seedsFloorRefresh) on the same handle.
func movesFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) {
if _, err := syncLocalFloor(ctx, s, dataDir); err != nil {
logf("agent: moves protocol floor refresh: %v", err)
}
+ seedsFloorRefresh(ctx, s, dataDir, logf)
}
// movesFloorLoop refreshes the local floor files at start and every hour.
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go
index 8561740f0..d430ba48c 100644
--- a/internal/host/movesfloor_linux.go
+++ b/internal/host/movesfloor_linux.go
@@ -124,10 +124,17 @@ func (d *movesDir) lockNamed(name string, wait time.Duration, busy error) error
}
}
+// writeHook, when set, is called with each file name WriteDurable is about
+// to write: a test seam for the order the floor files are written in.
+var writeHook func(name string)
+
// 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 {
+ if writeHook != nil {
+ writeHook(name)
+ }
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return err
diff --git a/internal/host/seedsfloor.go b/internal/host/seedsfloor.go
new file mode 100644
index 000000000..e45c77afc
--- /dev/null
+++ b/internal/host/seedsfloor.go
@@ -0,0 +1,189 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// The seeds protocol floor on a box (seeded-instances spec §3.3, codex 16):
+// the moves floor's mechanism (movesfloor.go), beside it. Its two local
+// files live in the same .moves directory and are written under the same
+// floor.lock; its verdict on carlos.prev sits beside the moves verdict; the
+// two system units carry a second clause for it; the activation rule
+// checks it as well. A deployment that never publishes it has none of the
+// files, and every path here then does what it did before.
+
+func seedsPrevAllowedPath(optDir string) string { return filepath.Join(optDir, seeds.PrevAllowedFile) }
+
+// readLocalSeedsFloor reads <dataDir>/.moves/seeds-floor.json without
+// following a symlink below dataDir. (nil, nil): not armed. A file that is
+// there but unusable is an error, which callers treat as armed and unknown.
+func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) {
+ f, err := openBeneath(dataDir, filepath.Dir(seeds.FloorFile), filepath.Base(seeds.FloorFile))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+ if !st.Mode().IsRegular() || st.Size() > movesFloorMaxBytes {
+ return nil, fmt.Errorf("%s is not a regular file under %d bytes", f.Name(), movesFloorMaxBytes)
+ }
+ var fl seeds.Floor
+ if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil {
+ return nil, fmt.Errorf("%s: %w", f.Name(), err)
+ }
+ if ok, err := seeds.ReleaseAtOrAbove(fl.MinRelease, fl.MinRelease); !ok || err != nil {
+ return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease)
+ }
+ return &fl, nil
+}
+
+// seedsFloorArmed: a local seeds floor that reads, or one that is there and
+// cannot be read.
+func seedsFloorArmed(dataDir string) bool {
+ f, err := readLocalSeedsFloor(dataDir)
+ return err != nil || f != nil
+}
+
+// writeSeedsFloorFiles writes the min-release line FIRST and the JSON LAST,
+// for writeFloorFiles' reason: after a crash between them the line can only
+// be ahead of the JSON, so the units never pick a carlos.prev the Go checks
+// would refuse.
+func writeSeedsFloorFiles(d *movesDir, f seeds.Floor) error {
+ body, err := json.Marshal(f)
+ if err != nil {
+ return err
+ }
+ if err := d.WriteDurable(filepath.Base(seeds.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644); err != nil {
+ return err
+ }
+ return d.WriteDurable(filepath.Base(seeds.FloorFile), append(body, '\n'), 0o644)
+}
+
+// syncLocalSeedsFloor is syncLocalFloor's rule for the seeds floor: a
+// published, usable floor that is not lower is written under the floor
+// lock; absent leaves what is on disk; a lower one is refused; an
+// unreadable store leaves what is on disk and returns the error.
+func syncLocalSeedsFloor(ctx context.Context, s release.Store, dataDir string) (*seeds.Floor, error) {
+ if s == nil {
+ local, lerr := readLocalSeedsFloor(dataDir)
+ return local, errors.Join(errors.New("no deployment store to read the seeds floor from"), lerr)
+ }
+ remote, rerr := seeds.ReadFloor(ctx, s)
+ switch {
+ case rerr != nil:
+ local, lerr := readLocalSeedsFloor(dataDir)
+ return local, errors.Join(rerr, lerr)
+ case remote == nil:
+ return readLocalSeedsFloor(dataDir)
+ }
+ if ok, err := seeds.ReleaseAtOrAbove(remote.MinRelease, remote.MinRelease); !ok || err != nil {
+ local, lerr := readLocalSeedsFloor(dataDir)
+ return local, errors.Join(fmt.Errorf("the published seeds min_release %q is not a release; the seeds floor on this box stays as it was", remote.MinRelease), lerr)
+ }
+ var out *seeds.Floor
+ ran := false
+ err := withFloorLock(dataDir, true, func(d *movesDir) error {
+ ran = true
+ local, lerr := readLocalSeedsFloor(dataDir)
+ if lerr == nil && local != nil {
+ if remote.Min < local.Min {
+ out = local
+ return fmt.Errorf("the published seeds floor's protocol minimum %d is below this box's %d", remote.Min, local.Min)
+ }
+ if ok, _ := seeds.ReleaseAtOrAbove(remote.MinRelease, local.MinRelease); !ok {
+ out = local
+ return fmt.Errorf("the published seeds min_release %q is below this box's %s", remote.MinRelease, local.MinRelease)
+ }
+ if *local == *remote {
+ if line, ok := readBeneathLine(dataDir, seeds.FloorMinReleaseFile); ok && line == remote.MinRelease {
+ out = local
+ return nil
+ }
+ }
+ }
+ if err := writeSeedsFloorFiles(d, *remote); err != nil {
+ out, _ = readLocalSeedsFloor(dataDir)
+ return fmt.Errorf("%w: %v", errFloorWrite, err)
+ }
+ out = remote
+ return nil
+ })
+ if !ran {
+ out, _ = readLocalSeedsFloor(dataDir)
+ return out, fmt.Errorf("%w: %v", errFloorWrite, err)
+ }
+ return out, err
+}
+
+// judgeSeedsPrev writes the seeds verdict when carlos.prev is at or above
+// the armed seeds floor, and removes it otherwise (no carlos.prev, no
+// floor, no version, below). Unlike the moves verdict it does not read the
+// registry: a seed has no hold rows.
+func judgeSeedsPrev(optDir, dataDir string) error {
+ deny := func(cause error) error {
+ if err := removeDurable(seedsPrevAllowedPath(optDir)); err != nil {
+ return errors.Join(cause, fmt.Errorf("remove the seeds verdict on carlos.prev: %w", err))
+ }
+ return cause
+ }
+ if _, err := os.Stat(systemPrevPath(optDir)); err != nil {
+ return deny(nil)
+ }
+ ran := false
+ err := withFloorLock(dataDir, false, func(*movesDir) error {
+ ran = true
+ floor, err := readLocalSeedsFloor(dataDir)
+ if err != nil {
+ return deny(fmt.Errorf("seeds 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, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !at {
+ return deny(nil)
+ }
+ return writeFileDurable(seedsPrevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
+ })
+ if !ran {
+ return deny(fmt.Errorf("seeds protocol floor: the floor on this box cannot be read: %v", err))
+ }
+ return err
+}
+
+// rootJudgeSeedsPrev is the root adopt pass's call: nothing to do on a box
+// that has neither a seeds floor nor a seeds verdict.
+func rootJudgeSeedsPrev() error {
+ optDir, dd := release.OptDir(), dataDir()
+ if !seedsFloorArmed(dd) {
+ if _, err := os.Stat(seedsPrevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ }
+ return withPrevLock(optDir, func() error { return judgeSeedsPrev(optDir, dd) })
+}
+
+// seedsFloorRefresh is the agent's hourly refresh of the seeds floor.
+func seedsFloorRefresh(ctx context.Context, s release.Store, dataDir string, logf func(string, ...any)) {
+ if _, err := syncLocalSeedsFloor(ctx, s, dataDir); err != nil {
+ logf("agent: seeds protocol floor refresh: %v", err)
+ }
+}
diff --git a/internal/host/seedsfloor_linux_test.go b/internal/host/seedsfloor_linux_test.go
new file mode 100644
index 000000000..a1ba5a22d
--- /dev/null
+++ b/internal/host/seedsfloor_linux_test.go
@@ -0,0 +1,120 @@
+//go:build linux
+
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+func publishSeedsFloor(t *testing.T, s release.Store, rel string) {
+ t.Helper()
+ if err := seeds.PublishFloor(context.Background(), s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSyncLocalSeedsFloorArmsAndOnlyRises(t *testing.T) {
+ ctx := context.Background()
+ dd := t.TempDir()
+ s := release.NewDirStore(t.TempDir())
+ if f, err := syncLocalSeedsFloor(ctx, s, dd); f != nil || err != nil {
+ t.Fatalf("nothing published: %+v, %v", f, err)
+ }
+ if seedsFloorArmed(dd) {
+ t.Fatal("armed with nothing published")
+ }
+ publishSeedsFloor(t, s, "v0.27.0")
+ f, err := syncLocalSeedsFloor(ctx, s, dd)
+ if err != nil || f == nil || f.MinRelease != "v0.27.0" || !seedsFloorArmed(dd) {
+ t.Fatalf("after publish: %+v, %v", f, err)
+ }
+ if line, ok := readBeneathLine(dd, seeds.FloorMinReleaseFile); !ok || line != "v0.27.0" {
+ t.Fatalf("min-release file = %q, %v", line, ok)
+ }
+ // Once armed, armed: an object that disappears leaves the files.
+ if err := s.Delete(ctx, seeds.FloorKey); err != nil {
+ t.Fatal(err)
+ }
+ if f, _ := syncLocalSeedsFloor(ctx, s, dd); f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("after the object went: %+v", f)
+ }
+}
+
+func TestSeedsFloorFilesWriteOrder(t *testing.T) {
+ dd := t.TempDir()
+ // A crash after the min-release file and before the JSON: the Go
+ // readers see no floor, and the units' line is AHEAD of every verdict.
+ if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, seeds.FloorMinReleaseFile), []byte("v0.27.0\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if f, err := readLocalSeedsFloor(dd); f != nil || err != nil {
+ t.Fatalf("readers trust only the JSON: %+v, %v", f, err)
+ }
+ // And the writer writes the line first, the JSON last.
+ var order []string
+ writeHook = func(name string) { order = append(order, name) }
+ defer func() { writeHook = nil }()
+ if err := withFloorLock(dd, true, func(d *movesDir) error {
+ return writeSeedsFloorFiles(d, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if len(order) != 2 || order[0] != filepath.Base(seeds.FloorMinReleaseFile) || order[1] != filepath.Base(seeds.FloorFile) {
+ t.Fatalf("write order = %q", order)
+ }
+}
+
+func TestJudgeSeedsPrev(t *testing.T) {
+ opt, dd := t.TempDir(), t.TempDir()
+ verdict := seedsPrevAllowedPath(opt)
+ // No carlos.prev: no verdict.
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(verdict); !os.IsNotExist(err) {
+ t.Fatal("a verdict with no carlos.prev")
+ }
+ if err := os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := writePrevVersion(opt, "v0.27.2"); err != nil {
+ t.Fatal(err)
+ }
+ // No seeds floor: no verdict (the units do not read one then).
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(verdict); !os.IsNotExist(err) {
+ t.Fatal("a verdict with no seeds floor")
+ }
+ s := release.NewDirStore(t.TempDir())
+ publishSeedsFloor(t, s, "v0.27.0")
+ if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil {
+ t.Fatal(err)
+ }
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if line, ok := readLine(verdict); !ok || line != "v0.27.0" {
+ t.Fatalf("verdict = %q, %v; want the floor's min_release", line, ok)
+ }
+ // carlos.prev below the floor: the verdict goes.
+ if err := writePrevVersion(opt, "v0.26.9"); err != nil {
+ t.Fatal(err)
+ }
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(verdict); !os.IsNotExist(err) {
+ t.Fatal("a verdict for a carlos.prev below the floor")
+ }
+}
diff --git a/internal/host/seedsfloor_test.go b/internal/host/seedsfloor_test.go
new file mode 100644
index 000000000..3aceda729
--- /dev/null
+++ b/internal/host/seedsfloor_test.go
@@ -0,0 +1,268 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/seeds"
+)
+
+// putSeedsFloor replaces the seeds floor object in s directly, with no
+// rising check, as an operator's hand or a broken publisher could.
+func putSeedsFloor(t *testing.T, s release.Store, f seeds.Floor) {
+ t.Helper()
+ body, _ := json.Marshal(f)
+ if err := s.Delete(context.Background(), seeds.FloorKey); err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), seeds.FloorKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// armLocalSeedsFloor writes the box's two local seeds floor files, as a
+// refresh would.
+func armLocalSeedsFloor(t *testing.T, dataDir string, f seeds.Floor) {
+ t.Helper()
+ if err := withFloorLock(dataDir, true, func(d *movesDir) error { return writeSeedsFloorFiles(d, f) }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// A deployment that never publishes the seeds floor: no refresh, judgement
+// or adopt pass leaves a file behind, so the units and the update path see
+// exactly what they saw before the seeds floor existed.
+func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) {
+ ctx := context.Background()
+ opt, dd := t.TempDir(), t.TempDir()
+ t.Setenv("CARLOS_OPT_DIR", opt)
+ t.Setenv("CARLOS_DATA_DIR", dd)
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if err := writePrevVersion(opt, "v0.27.2"); err != nil {
+ t.Fatal(err)
+ }
+ s := release.NewDirStore(t.TempDir())
+ var said []string
+ seedsFloorRefresh(ctx, s, dd, func(format string, args ...any) { said = append(said, format) })
+ if len(said) != 0 {
+ t.Fatalf("an unpublished floor was logged: %q", said)
+ }
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if err := rootJudgePrev(nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("an unarmed deployment grew a .moves directory")
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("an unarmed deployment has a seeds verdict")
+ }
+ if seedsFloorArmed(dd) {
+ t.Fatal("armed with nothing published")
+ }
+}
+
+// The seeds floor and the moves floor share a directory and a lock, and
+// nothing else: arming one arms neither the other's files nor its verdict.
+func TestSeedsFloorLeavesTheMovesFloorAlone(t *testing.T) {
+ ctx := context.Background()
+ opt, dd := t.TempDir(), t.TempDir()
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if err := writePrevVersion(opt, "v0.27.2"); err != nil {
+ t.Fatal(err)
+ }
+ s := release.NewDirStore(t.TempDir())
+ putSeedsFloor(t, s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: "v0.27.0"})
+ if _, err := syncLocalSeedsFloor(ctx, s, dd); err != nil {
+ t.Fatal(err)
+ }
+ if f, err := readLocalFloor(dd); f != nil || err != nil {
+ t.Fatalf("the moves floor read as %+v, %v", f, err)
+ }
+ for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} {
+ if _, err := os.Stat(filepath.Join(dd, name)); !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("arming the seeds floor wrote %s", name)
+ }
+ }
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(prevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("the seeds judgement wrote the moves verdict")
+ }
+ // Byte-identical to the floor line, because the units compare them
+ // with cmp.
+ v, verr := os.ReadFile(seedsPrevAllowedPath(opt))
+ line, lerr := os.ReadFile(filepath.Join(dd, seeds.FloorMinReleaseFile))
+ if verr != nil || lerr != nil || !bytes.Equal(v, line) {
+ t.Fatalf("verdict %q (%v), floor line %q (%v)", v, verr, line, lerr)
+ }
+}
+
+// rootJudgePrev runs the seeds half on a box with no moves floor: a box can
+// arm the seeds floor first.
+func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) {
+ opt, dd := t.TempDir(), t.TempDir()
+ t.Setenv("CARLOS_OPT_DIR", opt)
+ t.Setenv("CARLOS_DATA_DIR", dd)
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if err := writePrevVersion(opt, "v0.27.2"); err != nil {
+ t.Fatal(err)
+ }
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ if err := rootJudgePrev(nil); err != nil {
+ t.Fatal(err)
+ }
+ if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" {
+ t.Fatalf("verdict = %q, %v", line, ok)
+ }
+ // The floor rises past carlos.prev: the next pass takes the verdict away.
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
+ if err := rootJudgePrev(nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a verdict left for a carlos.prev below the raised floor")
+ }
+ // The floor files gone by an operator's hand: a stale verdict still goes.
+ writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
+ os.Remove(filepath.Join(dd, seeds.FloorFile))
+ os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile))
+ if err := rootJudgePrev(nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a verdict left with no seeds floor")
+ }
+}
+
+// The seeds floor only rises on a box, like the moves floor.
+func TestSyncLocalSeedsFloorRefusesARegression(t *testing.T) {
+ ctx := context.Background()
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: 2, MinRelease: "v0.28.0"})
+ for _, tc := range []struct {
+ name string
+ remote seeds.Floor
+ }{
+ {"a lower min_release", seeds.Floor{Min: 2, MinRelease: "v0.27.0"}},
+ {"a lower protocol minimum", seeds.Floor{Min: 1, MinRelease: "v0.28.0"}},
+ } {
+ putSeedsFloor(t, s, tc.remote)
+ if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || *f != (seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) {
+ t.Fatalf("%s = %+v, %v", tc.name, f, err)
+ }
+ if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.28.0" {
+ t.Fatalf("%s: min-release line = %q", tc.name, line)
+ }
+ }
+ // Raised: both files follow.
+ putSeedsFloor(t, s, seeds.Floor{Min: 2, MinRelease: "v0.29.0"})
+ if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.29.0" {
+ t.Fatalf("raised = %+v, %v", f, err)
+ }
+ if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.29.0" {
+ t.Fatalf("min-release line after a raise = %q", line)
+ }
+}
+
+// A store that cannot be read, or holds a min_release that is not a
+// release, leaves the armed floor as it is and says why.
+func TestSyncLocalSeedsFloorKeepsWhatIsArmed(t *testing.T) {
+ ctx := context.Background()
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ if f, err := syncLocalSeedsFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("store error = %+v, %v", f, err)
+ }
+ if f, err := syncLocalSeedsFloor(ctx, nil, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("no store = %+v, %v", f, err)
+ }
+ for _, bad := range []string{"v0.28.0-rc.1", "v0.28.0\nv0.1.0", ""} {
+ putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: bad})
+ if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("published %q = %+v, %v", bad, f, err)
+ }
+ }
+ var said []string
+ seedsFloorRefresh(ctx, unreadableStore{s}, dd, func(format string, args ...any) { said = append(said, format) })
+ if len(said) == 0 {
+ t.Fatal("a failed refresh said nothing")
+ }
+}
+
+// A seeds floor file that is there but unusable counts as armed and
+// unknown: the judge takes the verdict away and says why, and a usable
+// published floor replaces it.
+func TestAnUnreadableSeedsFloorIsArmed(t *testing.T) {
+ ctx := context.Background()
+ opt, dd := t.TempDir(), t.TempDir()
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if err := writePrevVersion(opt, "v0.27.2"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte("{not json"), 0o644)
+ if !seedsFloorArmed(dd) {
+ t.Fatal("an unreadable floor counted as not armed")
+ }
+ writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
+ if err := judgeSeedsPrev(opt, dd); err == nil {
+ t.Fatal("an unreadable floor was judged without a word")
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a verdict left beside an unreadable floor")
+ }
+ s := release.NewDirStore(t.TempDir())
+ putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("published over an unreadable floor = %+v, %v", f, err)
+ }
+}
+
+// The local seeds floor lives in a directory the sandboxed agent owns, and
+// root reads and writes it. Neither may follow a symlink planted there.
+func TestLocalSeedsFloorRefusesASymlink(t *testing.T) {
+ ctx := context.Background()
+ dd, elsewhere := t.TempDir(), t.TempDir()
+ if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil {
+ t.Fatal(err)
+ }
+ s := release.NewDirStore(t.TempDir())
+ putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ if _, err := syncLocalSeedsFloor(ctx, s, dd); err == nil {
+ t.Fatal("wrote through a symlinked .moves")
+ }
+ if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
+ t.Fatalf("the symlink's target was written: %v", entries)
+ }
+ os.WriteFile(filepath.Join(elsewhere, filepath.Base(seeds.FloorFile)), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644)
+ if f, err := readLocalSeedsFloor(dd); err == nil {
+ t.Fatalf("read through a symlinked .moves: %+v", f)
+ }
+}
+
+// The agent's hourly moves floor refresh refreshes the seeds floor too, on
+// the same store handle, whether or not a moves floor is published.
+func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) {
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ movesFloorRefresh(context.Background(), s, dd, func(string, ...any) {})
+ if f, err := readLocalSeedsFloor(dd); err != nil || f == nil || f.MinRelease != "v0.27.0" {
+ t.Fatalf("seeds floor after the refresh = %+v, %v", f, err)
+ }
+ if f, _ := readLocalFloor(dd); f != nil {
+ t.Fatalf("no moves floor published, yet armed %+v", f)
+ }
+}