carlos / platform Public

host: the seeds floor in the system units and the activation rule (Plan 3 Task 3)

Both system units gain a second clause: once
/data/.moves/seeds-floor.min-release is there and not empty, carlos.prev
runs only when /opt/carlos/carlos.prev.seeds-allowed names that floor,
byte for byte. No seeds floor file: the choice is the moves clause's alone.

seedsActivationRefusal (SD-N1) is checked first by activationRule,
activationGuard (under floor.lock) and floorAllowsForward. armFloor arms
the seeds floor first (armSeedsFloor); notePrev withdraws the seeds
verdict before replacing carlos.prev and judges it after; floorArmed
counts an armed seeds floor. SD-N1 wired.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f22c60822b5c1a7ee344294036c4cb4969944a45 parent cb828fb
7 files changed, +457 −10
  • approved_seeded_copy_test.go +3 −2
  • internal/host/host_test.go +2 −2
  • internal/host/seedsfloor.go +38 −0
  • internal/host/seedsfloor_test.go +381 −0
  • internal/host/systemapply.go +21 −4
  • internal/host/units/carlos-system-recover.service +6 −1
  • internal/host/units/carlos-system-update.service +6 −1

CI cancelled — superseded by a newer push — run details

diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go
index 5646e3ee1..a4bc3a41b 100644
--- a/approved_seeded_copy_test.go
+++ b/approved_seeded_copy_test.go
@@ -31,8 +31,9 @@ var seededCopyIDs = func() []string {
return ids
}()
-// seededWiredIDs grows as the plans wire rows. Plan 1 wires none.
-var seededWiredIDs = []string{}
+// seededWiredIDs grows as the plans wire rows. Plan 1 wires none; Plan 3
+// Task 3 wires SD-N1.
+var seededWiredIDs = []string{"SD-N1"}
func mustParseSeededRecord(t *testing.T) []copyRow {
t.Helper()
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 07d5bc59e..489d9b7ea 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -1315,7 +1315,7 @@ func TestSystemUnitsAreShapedLikeTheStageRelay(t *testing.T) {
u := read("carlos-system-update.service")
for _, want := range []string{"Type=oneshot", "StartLimitIntervalSec=0", "TimeoutStartSec=15min", "OnFailure=carlos-system-recover.service",
"EnvironmentFile=-/etc/carlos/host.env", "system apply", "carlos.prev", "MemoryMax=256M",
- "floor.min-release", "carlos.prev.allowed", "cmp -s"} {
+ "floor.min-release", "carlos.prev.allowed", "cmp -s", "seeds-floor.min-release", "carlos.prev.seeds-allowed"} {
if !strings.Contains(u, want) {
t.Errorf("update unit lacks %q", want)
}
@@ -1327,7 +1327,7 @@ func TestSystemUnitsAreShapedLikeTheStageRelay(t *testing.T) {
}
r := read("carlos-system-recover.service")
for _, want := range []string{"Type=oneshot", "Before=carlos-edge.service", "WantedBy=multi-user.target", "TimeoutStartSec=10min", "system apply --recover", "carlos.prev",
- "floor.min-release", "carlos.prev.allowed", "cmp -s"} {
+ "floor.min-release", "carlos.prev.allowed", "cmp -s", "seeds-floor.min-release", "carlos.prev.seeds-allowed"} {
if !strings.Contains(r, want) {
t.Errorf("recover unit lacks %q", want)
}
diff --git a/internal/host/seedsfloor.go b/internal/host/seedsfloor.go
index e45c77afc..00edaac8f 100644
--- a/internal/host/seedsfloor.go
+++ b/internal/host/seedsfloor.go
@@ -169,6 +169,44 @@ func judgeSeedsPrev(optDir, dataDir string) error {
return err
}
+// seedsActivationRefusal is the seeds half of the activation rule: version
+// below the armed seeds floor is refused (SD-N1). The floor is read from
+// the local file alone (boot recovery runs before the network). Unarmed:
+// nil. A version of "" (a carlos.prev that records none) is below any
+// floor: seeds.ReleaseAtOrAbove("", ...) is false.
+func seedsActivationRefusal(version, dataDir string) error {
+ floor, err := readLocalSeedsFloor(dataDir)
+ if err != nil {
+ return refuse("seeds protocol floor: the floor on this box cannot be read: %v", err)
+ }
+ if floor == nil {
+ return nil
+ }
+ if ok, _ := seeds.ReleaseAtOrAbove(version, floor.MinRelease); !ok {
+ return refuse("seeds protocol floor: %s is below %s", version, floor.MinRelease)
+ }
+ return nil
+}
+
+// armSeedsFloor is armFloor's seeds half, with the same rule: a published
+// floor this box could not write down defers the apply; anything else is a
+// log line.
+func (a *systemApplier) armSeedsFloor(ctx context.Context) error {
+ f, err := syncLocalSeedsFloor(ctx, a.store, a.dataDir)
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, errFloorWrite) {
+ return fmt.Errorf("%w: seeds protocol floor: %v", errSystemDeferred, err)
+ }
+ onDisk := "none"
+ if f != nil {
+ onDisk = f.MinRelease
+ }
+ a.ops.logf("system apply: seeds protocol floor: %v (the seeds floor on this box stays %s)", err, onDisk)
+ return nil
+}
+
// 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 {
diff --git a/internal/host/seedsfloor_test.go b/internal/host/seedsfloor_test.go
index 3aceda729..85ce69beb 100644
--- a/internal/host/seedsfloor_test.go
+++ b/internal/host/seedsfloor_test.go
@@ -3,11 +3,17 @@ package host
import (
"bytes"
"context"
+ "crypto/ed25519"
"encoding/json"
"errors"
+ "fmt"
"os"
+ "os/exec"
"path/filepath"
+ "runtime"
+ "strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/release"
@@ -266,3 +272,378 @@ func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) {
t.Fatalf("no moves floor published, yet armed %+v", f)
}
}
+
+// TestSystemUnitsSelectPrevByTheSeedsFloor runs each system unit's ExecStart
+// shell against a fake /opt/carlos and /data, as
+// TestSystemUnitsSelectPrevByTheFloor does for the moves clause. No seeds
+// floor file: the choice is exactly what the moves clause alone makes.
+// With one, carlos.prev runs only when its seeds verdict names it, byte for
+// byte, and only when the moves clause allows it too.
+func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("no /bin/sh")
+ }
+ const empty = "\x00empty" // a file that is there with nothing in it
+ for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} {
+ t.Run(unit, func(t *testing.T) {
+ b, err := unitsFS.ReadFile("units/" + unit)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var line string
+ for _, l := range strings.Split(string(b), "\n") {
+ if strings.HasPrefix(l, "ExecStart=") {
+ line = l
+ }
+ }
+ if strings.ContainsAny(line, "%$") {
+ t.Fatalf("ExecStart carries a %% or a $: %s", line)
+ }
+ for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} {
+ if !strings.Contains(line, path) {
+ t.Fatalf("ExecStart does not read %s: %s", path, line)
+ }
+ }
+ body, ok := strings.CutPrefix(line, "ExecStart=/bin/sh -c '")
+ body, ok2 := strings.CutSuffix(body, "'")
+ if !ok || !ok2 || strings.Contains(body, "'") {
+ t.Fatalf("ExecStart is not one quoted sh -c body: %s", line)
+ }
+ for _, tc := range []struct {
+ name string
+ noJournal bool
+ movesFloor, movesOK string
+ seedsFloor, seedsOK string
+ want string
+ }{
+ {"no seeds floor", false, "", "", "", "", "carlos.prev"},
+ {"no seeds floor, no journal", true, "", "", "", "", "carlos"},
+ {"an empty seeds floor file", false, "", "", empty, "", "carlos.prev"},
+ {"seeds floor and a verdict naming it", false, "", "", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
+ {"seeds floor and no verdict", false, "", "", "v0.27.0\n", "", "carlos"},
+ {"seeds floor and an empty verdict", false, "", "", "v0.27.0\n", empty, "carlos"},
+ {"a seeds verdict for an older floor", false, "", "", "v0.27.0\n", "v0.26.0\n", "carlos"},
+ {"a seeds verdict, no journal", true, "", "", "v0.27.0\n", "v0.27.0\n", "carlos"},
+ {"both floors, both verdicts", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "carlos.prev"},
+ {"both floors, only the moves verdict", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "carlos"},
+ {"both floors, only the seeds verdict", false, "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "carlos"},
+ {"the moves verdict in the seeds verdict's place", false, "", "v0.27.0\n", "v0.27.0\n", "", "carlos"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran")
+ os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
+ os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
+ for _, name := range []string{"carlos", "carlos.prev"} {
+ os.WriteFile(filepath.Join(opt, name), []byte("#!/bin/sh\necho "+name+" > "+ran+"\n"), 0o755)
+ }
+ if !tc.noJournal {
+ os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
+ }
+ put := func(path, body string) {
+ switch body {
+ case "":
+ case empty:
+ os.WriteFile(path, nil, 0o644)
+ default:
+ os.WriteFile(path, []byte(body), 0o644)
+ }
+ }
+ put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor)
+ put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK)
+ put(filepath.Join(data, seeds.FloorMinReleaseFile), tc.seedsFloor)
+ put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK)
+ script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/")
+ if out, err := exec.Command("/bin/sh", "-c", script).CombinedOutput(); err != nil {
+ t.Fatalf("%v: %s", err, out)
+ }
+ got, _ := os.ReadFile(ran)
+ if strings.TrimSpace(string(got)) != tc.want {
+ t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestSeedsActivationRefusal(t *testing.T) {
+ dd := t.TempDir()
+ if err := seedsActivationRefusal("v0.20.0", dd); err != nil {
+ t.Fatalf("no seeds floor: %v", err)
+ }
+ os.MkdirAll(filepath.Join(dd, ".moves"), 0o755)
+ os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644)
+ if err := seedsActivationRefusal("v0.27.0", dd); err != nil {
+ t.Fatalf("at the floor: %v", err)
+ }
+ err := seedsActivationRefusal("v0.26.9", dd)
+ var refused activationRefused
+ if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" {
+ t.Fatalf("below the floor: %v", err)
+ }
+ // A carlos.prev with no recorded version is below any floor.
+ if err := seedsActivationRefusal("", dd); !errors.As(err, &refused) {
+ t.Fatalf("no version: %v, want a refusal", err)
+ }
+ os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644)
+ if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) {
+ t.Fatalf("an unreadable seeds floor: %v, want a refusal", err)
+ }
+}
+
+// Task 2's review: with the seeds floor armed and a carlos.prev that has no
+// version sidecar, the seeds verdict is withdrawn (and not written), so the
+// units' cmp fails and the current binary runs.
+func TestJudgeSeedsPrevDeniesACarlosPrevWithNoVersion(t *testing.T) {
+ opt, dd := t.TempDir(), t.TempDir()
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
+ if err := judgeSeedsPrev(opt, dd); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a seeds verdict for a carlos.prev that records no version")
+ }
+}
+
+// The apply arms the seeds floor from the store before it stages anything,
+// and a candidate below it is refused with SD-N1, never staged or run.
+func TestSystemApplyArmsTheSeedsFloorBeforeStaging(t *testing.T) {
+ f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
+ putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
+ staged := filepath.Join(f.a.stagingDir(), "carlos")
+ run := f.a.ops.run
+ f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
+ if name == staged {
+ t.Errorf("a candidate below the seeds floor was staged and run: %s %v", name, args)
+ }
+ return run(ctx, d, name, args...)
+ }
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" {
+ t.Fatalf("local seeds floor = %+v, %v", lf, err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.29.0 is below v0.30.0") {
+ t.Fatalf("result = %+v", r)
+ }
+ if f.onDisk() != "v0.28.0" {
+ t.Fatalf("on disk %q", f.onDisk())
+ }
+ if f, err := readLocalFloor(f.a.dataDir); f != nil || err != nil {
+ t.Fatalf("the moves floor read as %+v, %v", f, err)
+ }
+}
+
+// A seeds floor this box could not write down defers the apply; a store
+// that cannot be read is a log line and the apply goes on.
+func TestArmSeedsFloor(t *testing.T) {
+ f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
+ putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
+ f.a.store = f.store
+ // .moves is a file: floor.lock cannot be made beside it.
+ os.WriteFile(filepath.Join(f.a.dataDir, ".moves"), nil, 0o644)
+ if err := f.a.armSeedsFloor(context.Background()); !errors.Is(err, errSystemDeferred) || !strings.Contains(err.Error(), "seeds protocol floor") {
+ t.Fatalf("unwritable: %v, want a deferral", err)
+ }
+ os.Remove(filepath.Join(f.a.dataDir, ".moves"))
+ var said []string
+ f.a.ops.logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
+ f.a.store = unreadableStore{f.store}
+ if err := f.a.armSeedsFloor(context.Background()); err != nil {
+ t.Fatalf("unreadable store: %v", err)
+ }
+ if len(said) != 1 || !strings.Contains(said[0], "the seeds floor on this box stays none") {
+ t.Fatalf("logged %q", said)
+ }
+ f.a.store = f.store
+ if err := f.a.armSeedsFloor(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.27.0" {
+ t.Fatalf("local seeds floor = %+v, %v", lf, err)
+ }
+}
+
+// The install's backup of the running build is judged against the seeds
+// floor as well, and a replacement withdraws the old seeds verdict first.
+func TestInstallJudgesCarlosPrevByTheSeedsFloor(t *testing.T) {
+ for _, tc := range []struct {
+ name, running string
+ verdict bool
+ }{
+ {"at or above the seeds floor", "v0.31.0", true},
+ {"below the seeds floor", "v0.29.0", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f, _ := floorFixture(t, tc.running, "v0.32.0")
+ putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
+ // A verdict from an earlier judgement must not outlive this one.
+ writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" {
+ t.Fatalf("result = %+v, on disk %q", r, f.onDisk())
+ }
+ if line, ok := readLine(prevVersionPath(f.a.optDir)); !ok || line != tc.running {
+ t.Fatalf("sidecar = %q, %v", line, ok)
+ }
+ b, err := os.ReadFile(seedsPrevAllowedPath(f.a.optDir))
+ if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") {
+ t.Fatalf("seeds verdict = %q, %v; want present %v", b, err, tc.verdict)
+ }
+ if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a moves verdict with no moves floor")
+ }
+ })
+ }
+}
+
+// Boot recovery runs before the network: the seeds floor it enforces is
+// the one on disk, and the version it judges is carlos.prev's sidecar.
+func TestBootRecoveryUsesTheArmedSeedsFloorOffline(t *testing.T) {
+ for _, tc := range []struct {
+ name, sidecar string
+ restored bool
+ }{
+ {"below the seeds floor", "v0.29.0", false},
+ {"at the seeds floor", "v0.30.0", true},
+ {"no sidecar", "", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
+ return unreadableStore{f.store}, f.pub, nil
+ }
+ armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
+ prev := tc.sidecar
+ if prev == "" {
+ prev = "v0.30.0"
+ }
+ f.brokenBox("v0.31.0", prev, tc.sidecar)
+ f.edgeStartedThisBoot = false
+ err := f.a.Run(context.Background(), true)
+ j, _ := readSystemJournal(f.a.optDir)
+ if tc.restored {
+ if err != nil || f.onDisk() != "v0.30.0" || j != nil {
+ t.Fatalf("err %v, on disk %q, journal %+v", err, f.onDisk(), j)
+ }
+ return
+ }
+ if err == nil || !strings.Contains(err.Error(), "seeds protocol floor") {
+ t.Fatalf("recovery err = %v", err)
+ }
+ if f.onDisk() != "v0.31.0" || j == nil {
+ t.Fatalf("a refused recovery changed the box: on disk %q, journal %+v", f.onDisk(), j)
+ }
+ })
+ }
+}
+
+// A gated journal is finished forward only when its target is at or above
+// the seeds floor armed now; below it, recovery goes back to carlos.prev.
+func TestForwardRecoveryChecksTheSeedsFloor(t *testing.T) {
+ for _, tc := range []struct {
+ name, prev, want string
+ ok bool
+ }{
+ {"carlos.prev below the seeds floor too", "v0.30.0", "v0.31.0", false},
+ {"carlos.prev at the seeds 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})
+ armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "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 target below the seeds floor 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(), "seeds protocol floor") || j == nil) {
+ t.Fatalf("err %v, journal %+v", err, j)
+ }
+ // Refused before the forward path stages a plan from the binary
+ // on disk, not only at the finish's guard.
+ for _, c := range f.calls {
+ if strings.HasPrefix(c, "run carlos bootstrap") {
+ t.Fatalf("the forward path staged a plan for a target below the seeds floor: %s", c)
+ }
+ }
+ })
+ }
+}
+
+// Replacing carlos.prev withdraws the old seeds verdict before the copy, so
+// a copy that dies part way leaves no seeds verdict on the new bytes.
+func TestReplacingCarlosPrevForgetsTheOldSeedsVerdictFirst(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755)
+ writePrevVersion(f.a.optDir, "v0.30.0")
+ writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644)
+ // The copy's temp file cannot be created.
+ os.Mkdir(systemPrevPath(f.a.optDir)+".tmp", 0o755)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused {
+ t.Fatalf("result = %+v", r)
+ }
+ if _, err := os.Stat(seedsPrevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("the seeds verdict survived a failed replacement of carlos.prev")
+ }
+}
+
+// An armed seeds floor arms the floor's own steps (floorStep): a failure in
+// them is the apply's error, as with the moves floor. With no floor of
+// either kind the same failure is a log line and the install goes on.
+func TestAnArmedSeedsFloorMakesFloorStepsFatal(t *testing.T) {
+ for _, armed := range []bool{true, false} {
+ t.Run(fmt.Sprintf("armed=%v", armed), func(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ if armed {
+ putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
+ }
+ // carlos.prev's lock cannot be opened.
+ os.MkdirAll(filepath.Join(systemRootDir(f.a.optDir), prevLockFile), 0o755)
+ f.a.Run(context.Background(), false)
+ r := f.result()
+ if armed && (r == nil || r.Outcome == systemOutcomeApplied || f.onDisk() != "v0.31.0") {
+ t.Fatalf("armed: result %+v, on disk %q", r, f.onDisk())
+ }
+ if !armed && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0") {
+ t.Fatalf("unarmed: result %+v, on disk %q", r, f.onDisk())
+ }
+ })
+ }
+}
+
+// The seeds floor raised while the candidate is staged: the rename's guard
+// refuses it.
+func TestSeedsFloorIsCheckedAgainAtTheRename(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ f.duringStaging(func() { armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.33.0"}) })
+ 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, "seeds protocol floor: v0.32.0 is below v0.33.0") {
+ t.Fatalf("result = %+v", r)
+ }
+ if f.onDisk() != "v0.31.0" {
+ t.Fatalf("on disk %q", f.onDisk())
+ }
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index 09330955d..6dddd9301 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -773,7 +773,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
steps := func() error {
ran = true
if replace {
- if err := a.floorStep(errors.Join(removeDurable(prevAllowedPath(a.optDir)), removeDurable(prevVersionPath(a.optDir)))); err != nil {
+ if err := a.floorStep(errors.Join(removeDurable(prevAllowedPath(a.optDir)), removeDurable(seedsPrevAllowedPath(a.optDir)), removeDurable(prevVersionPath(a.optDir)))); err != nil {
return err
}
if copyErr = copyFileDurable(systemBinaryPath(a.optDir), systemPrevPath(a.optDir), 0o755); copyErr != nil {
@@ -785,7 +785,7 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
}
}
}
- return a.floorStep(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }))
+ return a.floorStep(errors.Join(judgePrevWith(a.optDir, a.dataDir, a.freezeHolds, func() string { return a.prevBuild(ctx) }), judgeSeedsPrev(a.optDir, a.dataDir)))
}
err := withPrevLock(a.optDir, steps)
if !ran {
@@ -804,10 +804,11 @@ func (a *systemApplier) notePrev(ctx context.Context, replace bool, version stri
// floorArmed reports whether a moves protocol floor is armed on this box:
// a local floor file that reads, or one that is there and cannot be read
-// (armed, value unknown).
+// (armed, value unknown). An armed seeds floor (seedsfloor.go) counts too,
+// since the floor's steps write and judge both.
func (a *systemApplier) floorArmed() bool {
f, err := readLocalFloor(a.dataDir)
- return err != nil || f != nil
+ return err != nil || f != nil || seedsFloorArmed(a.dataDir)
}
// floorStep is the dormancy rule for the floor's own steps (moves Task 13,
@@ -830,7 +831,11 @@ func (a *systemApplier) floorStep(err error) error {
// enforce less than the floor. Anything else (the store not answering, no
// object, an unusable one) keeps what is on disk and is one log line, so a
// deployment that never published a floor updates exactly as main does.
+// The seeds floor is armed first, by the same rule (armSeedsFloor).
func (a *systemApplier) armFloor(ctx context.Context) error {
+ if err := a.armSeedsFloor(ctx); err != nil {
+ return err
+ }
f, err := syncLocalFloor(ctx, a.store, a.dataDir)
if err == nil {
return nil
@@ -861,6 +866,9 @@ 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.
//
+// The seeds floor (seedsActivationRefusal) is checked first, on every one
+// of these paths and the same way as the floor half; it has no hold half.
+//
// 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 activationGuard,
@@ -940,6 +948,9 @@ func (a *systemApplier) activationGuard(target, running string, fn func() error)
locked := false
err := withFloorLock(a.dataDir, true, func(*movesDir) error {
locked = true
+ if err := seedsActivationRefusal(target, a.dataDir); err != nil {
+ return err
+ }
floor, err := readLocalFloor(a.dataDir)
if err != nil {
return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
@@ -1008,6 +1019,9 @@ func holdRefusal(version, running string, held []registry.Route) error {
// guards nothing by itself; the step it precedes runs under
// activationGuard.
func (a *systemApplier) activationRule(version string, running func() string) error {
+ if err := seedsActivationRefusal(version, a.dataDir); err != nil {
+ return err
+ }
floor, err := readLocalFloor(a.dataDir)
if err != nil {
return refuse("moves protocol floor: the floor on this box cannot be read: %v", err)
@@ -1039,6 +1053,9 @@ func (a *systemApplier) activationRule(version string, running func() string) er
// 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 {
+ if err := seedsActivationRefusal(version, a.dataDir); err != nil {
+ return err
+ }
floor, err := readLocalFloor(a.dataDir)
if err != nil {
return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err)
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service
index 3da2b581b..134bf6e83 100644
--- a/internal/host/units/carlos-system-recover.service
+++ b/internal/host/units/carlos-system-recover.service
@@ -17,6 +17,11 @@
# carlos.prev below the floor. No floor file: the choice is exactly as
# before. The line carries no percent sign and no dollar sign, both of which
# systemd expands before the shell sees them.
+#
+# The seeds floor (seeded-instances spec §3.3) adds the same test for its
+# own files: once /data/.moves/seeds-floor.min-release is there and not
+# empty, carlos.prev runs only when /opt/carlos/carlos.prev.seeds-allowed
+# names that same floor, byte for byte.
[Unit]
Description=carlos platform self-update recovery
Before=carlos-edge.service
@@ -26,7 +31,7 @@ DefaultDependencies=yes
Type=oneshot
TimeoutStartSec=10min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || cmp -s /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
ProtectHome=yes
PrivateTmp=yes
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service
index 9bc8d25f9..8cd343b94 100644
--- a/internal/host/units/carlos-system-update.service
+++ b/internal/host/units/carlos-system-update.service
@@ -16,6 +16,11 @@
# before. The line carries no percent sign and no dollar sign, both of which
# systemd expands before the shell sees them.
#
+# The seeds floor (seeded-instances spec §3.3) adds the same test for its
+# own files: once /data/.moves/seeds-floor.min-release is there and not
+# empty, carlos.prev runs only when /opt/carlos/carlos.prev.seeds-allowed
+# names that same floor, byte for byte.
+#
# Deliberately no filesystem-sandboxing directive and no dedicated run
# account: it must write /opt/carlos and /etc/systemd/system, which only
# root can do. StartLimitIntervalSec=0 because the path unit can fire in
@@ -32,7 +37,7 @@ OnFailure=carlos-system-recover.service
Type=oneshot
TimeoutStartSec=15min
EnvironmentFile=-/etc/carlos/host.env
-ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; } && { ! [ -s /data/.moves/seeds-floor.min-release ] || cmp -s /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
# --- hardening (the carlos-stage.service set) ---
ProtectHome=yes