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>
5 files changed,
+598
−5
internal/host/movesfloor.go+14 −5internal/host/movesfloor_linux.go+7 −0internal/host/seedsfloor.go+189 −0internal/host/seedsfloor_linux_test.go+120 −0internal/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) |
| + } |
| +} |