carlos / platform Public

moves: the protocol floor armed on disk and enforced at executable selection; edges file publishes moves_protocol, the armed floor and prev_release (§11)

Root's apply writes the published floor to <DataDir>/.moves before staging
(symlink-safe, durable); the agent refreshes it hourly. decidePrev writes
carlos.prev.version and judgePrev writes or removes carlos.prev.allowed
before the journal, under a lock the root adopt pass's judge shares.
activationAllowed refuses a below-floor install, rollback or recovery, and
any downgrade while a hold_op row exists, once a floor is armed. The two
system units pick carlos.prev only when the verdict file equals the floor
line (cmp -s; no % or $). With no floor published, every step is
best-effort and the box behaves as main. The console publishes
control/moves-console.json at start, switch on only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3a64ac1a5ca173897c9e0f0111721bca7fc52df8 parent 924d313
18 files changed, +1652 −11
  • cmd/console/main.go +3 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +7 −1
  • internal/console/movesconsole_test.go +69 −0
  • internal/console/server.go +26 −0
  • internal/host/agent.go +9 −0
  • internal/host/edgesfile.go +23 −0
  • internal/host/edgesfile_test.go +71 −4
  • internal/host/host_test.go +92 −2
  • internal/host/movesfloor.go +377 −0
  • internal/host/movesfloor_linux.go +101 −0
  • internal/host/movesfloor_other.go +12 −0
  • internal/host/movesfloor_test.go +253 −0
  • internal/host/systemapply.go +193 −2
  • internal/host/systemapply_test.go +329 −0
  • internal/host/units/carlos-system-recover.service +13 −1
  • internal/host/units/carlos-system-update.service +11 −1
  • internal/moves/floor.go +38 −0
  • internal/moves/floor_test.go +25 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 4f1ad4eff..8a9612c41 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -377,6 +377,9 @@ func serve() {
// here rather than in NewServer so constructing a Server — which every
// console test does — never starts a goroutine or sends mail.
go srv.StartAlertWatcher(context.Background())
+ // The moves console record (moves Task 13): only with the switch on,
+ // best-effort, logged by the method itself.
+ go srv.PublishMovesConsole(context.Background())
// The senders migration's tail (tenant-email senders design §5): once a
// release has passed with every console writing senders/app.json, an
// operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 496fd6a82..1e1e2f8f1 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -49,7 +49,7 @@ Columns: id, file, text, status.
| MV-N7 | internal/console/apiinstancemove.go | this move was started by an older console and has no id. abort it and request it again | pending |
| MV-N8 | internal/console/apiinstancemove.go | the fleet's edges files could not all be read. try again | pending |
| MV-N9 | internal/host/movespass.go | a staging lane for this move id already exists | pending |
-| MV-N10 | internal/host/systemapply.go | the moves floor is unknown and this box holds an operation | pending |
+| MV-N10 | internal/host/systemapply.go | moves protocol floor: <version> is below <min_release> | pending |
| MV-N11 | internal/console/apiinstancemove.go | declare-lost is not enabled in this deployment yet | pending |
| MV-N12 | internal/provision/provision.go | no journaled move in flight | pending |
| MV-N13 | internal/provision/provision.go | the record's move is <id> for incarnation <incarnation> | pending |
@@ -126,6 +126,12 @@ Columns: id, file, text, status.
| MV-N84 | internal/moves/readiness.go | the target's readiness report is dated ahead of this clock | pending |
| MV-N85 | internal/moves/readiness.go | the source's readiness report is dated ahead of this clock | pending |
| MV-N86 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> is a hard link | pending |
+| MV-N87 | internal/host/systemapply.go | moves protocol floor: carlos.prev records no version, so it is below <min_release> | pending |
+| MV-N88 | internal/host/systemapply.go | moves protocol floor: the floor on this box cannot be read: <error> | pending |
+| MV-N89 | internal/host/systemapply.go | a participant in a held operation does not downgrade: <version> is below the running <release> | pending |
+| MV-N90 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the registry cannot say whether this box is one: <error> | pending |
+| MV-N91 | internal/host/systemapply.go | a participant in a held operation does not downgrade, and the running release is unknown | pending |
+| MV-N92 | internal/host/systemapply.go | moves protocol floor: could not write it on this box: <error> | pending |
## Plan 2
diff --git a/internal/console/movesconsole_test.go b/internal/console/movesconsole_test.go
new file mode 100644
index 000000000..176dae529
--- /dev/null
+++ b/internal/console/movesconsole_test.go
@@ -0,0 +1,69 @@
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// D13: the console publishes control/moves-console.json only with the
+// moves switch on, and a second start replaces the first start's record.
+func TestConsoleRecordNeedsTheSwitch(t *testing.T) {
+ ctx := context.Background()
+ priv, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ pubB64 := base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes())
+ store := release.NewDirStore(t.TempDir())
+
+ off := &Server{cfg: Config{Releases: store, SecretsPub: pubB64}}
+ if err := off.PublishMovesConsole(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := store.Get(ctx, moves.ConsoleKey); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("switch off, yet %s: %v", moves.ConsoleKey, err)
+ }
+
+ on := &Server{cfg: Config{Releases: store, SecretsPub: pubB64, MovesEnabled: true}}
+ for start := 0; start < 2; start++ {
+ if err := on.PublishMovesConsole(ctx); err != nil {
+ t.Fatalf("start %d: %v", start, err)
+ }
+ }
+ body, _, err := store.Get(ctx, moves.ConsoleKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rec moves.ConsoleRecord
+ if err := json.Unmarshal(body, &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.V != 1 || rec.Protocol != moves.ProtocolVersion || rec.SecretsKeyFP != appconfig.KeyFingerprint(priv.PublicKey()) || rec.At.IsZero() {
+ t.Fatalf("record = %+v", rec)
+ }
+
+ // No secrets key: the record says so by carrying no fingerprint.
+ bare := &Server{cfg: Config{Releases: store, MovesEnabled: true}}
+ if err := bare.PublishMovesConsole(ctx); err != nil {
+ t.Fatal(err)
+ }
+ body, _, _ = store.Get(ctx, moves.ConsoleKey)
+ if bytes.Contains(body, []byte("secrets_key_fp")) {
+ t.Fatalf("no key, yet %s", body)
+ }
+
+ // A bucketless console has nowhere to publish and does not fail.
+ if err := (&Server{cfg: Config{MovesEnabled: true}}).PublishMovesConsole(ctx); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index cfd653a75..b152e2f1e 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -32,6 +32,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/boxid"
"github.com/carlosframework/platform/internal/dnsrec"
@@ -41,6 +42,7 @@ import (
"github.com/carlosframework/platform/internal/geo"
"github.com/carlosframework/platform/internal/infraview"
"github.com/carlosframework/platform/internal/mail"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/publicrelease"
@@ -1424,6 +1426,30 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
return s, nil
}
+// PublishMovesConsole writes control/moves-console.json: the moves
+// protocol this console speaks and the fingerprint of its
+// CARLOS_SECRETS_PUBKEY (empty when unset), so a box can check the console
+// it is moving for (moves Task 13). Only with the moves switch on (index
+// D13: off publishes nothing). Called once per start, by the console's
+// main, never by NewServer, so constructing a Server in a test writes
+// nothing. A later start replaces the record. Best-effort: a failure is
+// logged and returned, and the console serves regardless.
+func (s *Server) PublishMovesConsole(ctx context.Context) error {
+ if !s.cfg.MovesEnabled || s.cfg.Releases == nil {
+ return nil
+ }
+ fp, _ := appconfig.PublicFingerprint(s.cfg.SecretsPub)
+ body, err := json.Marshal(moves.ConsoleRecord{V: 1, Protocol: moves.ProtocolVersion, SecretsKeyFP: fp, At: time.Now().UTC()})
+ if err == nil {
+ err = moves.PutReplace(ctx, s.cfg.Releases, moves.ConsoleKey, body)
+ }
+ if err != nil {
+ log.Printf("console: could not publish %s: %v", moves.ConsoleKey, err)
+ return err
+ }
+ return nil
+}
+
// ServeHTTP checks the host-based redirect BEFORE the mux on purpose: it's
// purely host-based, touches no session/account state, so it sits in front
// of the console's security gates without weakening them (the gates
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 1b70e777e..3f8a1f34a 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -3641,6 +3641,15 @@ func runAdopt(args []string, buildClaims ClaimFactory) {
}
log.Fatalf("adopt: open registry %s: %v", *regPath, err)
}
+ // The moves protocol floor (moves spec §11): re-judge carlos.prev
+ // against the armed floor and the held rows on every root pass, in every
+ // mode, so a raised floor or a new hold reaches the units' choice within
+ // the hour, or within seconds of a SetHold (its caller touches
+ // stage-requested). Here, before the fatal gates below, so a pass that
+ // dies later still judged. Never fatal; one stat on a box with no floor.
+ if err := rootJudgePrev(reg); err != nil {
+ log.Printf("adopt: moves protocol floor: %v", err)
+ }
// Step 0 (discussion #111): evict the staged versions nothing on this box
// still names, BEFORE anything that can fail because the disk is full.
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index a569d7553..337e146fd 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -285,6 +285,24 @@ type edgesFile struct {
// headroom change alone never forces a write (see edgesFileWrite).
Headroom *edgeHeadroom `json:"headroom,omitempty"`
+ // The moves protocol floor (moves spec §11; withMovesFields). The move
+ // door admits a move only when every file of both regions shows the
+ // published floor armed here and carlos.prev judged against it.
+ //
+ // MovesProtocol is the protocol this build speaks. NOT omitempty: absent
+ // is a writer that predates moves, which the door reads as below any
+ // floor.
+ MovesProtocol int `json:"moves_protocol"`
+ // MovesFloorMinRelease is the min_release of the floor armed on this box
+ // (<DataDir>/.moves/floor.min-release); absent: none armed.
+ MovesFloorMinRelease string `json:"moves_floor_min_release,omitempty"`
+ // PrevRelease is carlos.prev's release (carlos.prev.version); "none"
+ // when there is no carlos.prev; absent when it is unknown.
+ PrevRelease string `json:"prev_release,omitempty"`
+ // PrevAllowedFor is the verdict (carlos.prev.allowed): the min_release
+ // carlos.prev was judged at or above. Absent: no verdict.
+ PrevAllowedFor string `json:"prev_allowed_for,omitempty"`
+
Generation string `json:"generation"`
Hosts map[string]edgeHost `json:"hosts"`
}
@@ -752,6 +770,10 @@ func edgesFileLoop(reg *registry.Registry) {
Capabilities: edgesFileCapabilities(os.Getenv),
}
dataDir := provisionTickConfig(os.Getenv).DataDir
+ optDir := release.OptDir()
+ // The local floor files this file publishes are refreshed hourly by
+ // their own loop, on this loop's store handle (moves spec §11).
+ go movesFloorLoop(ctx, store, dataDir)
// Region HA reads each relocatable host's claim (spec 1 §5.4); off, the
// file is exactly what it always was.
var claims *edgesClaims
@@ -792,6 +814,7 @@ func edgesFileLoop(reg *registry.Registry) {
eip = readPublicIP()
}
ident.Headroom = headroomFor(ident.Capabilities, dataDir, reg)
+ ident = withMovesFields(ident, dataDir, optDir)
err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims)
if err != nil {
// Throttled: a bucket that refuses every write (an IAM Deny, an
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
index 46c3ed26a..1faa27ac3 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -316,7 +317,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) {
t.Fatalf("generation %q is not RFC3339: %v", probe.Generation, err)
}
- want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` +
probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}`
if string(got) != want {
t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want)
@@ -358,7 +359,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) {
t.Fatal(err)
}
gen := genOf(t, got)
- want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` +
gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}`
if string(got) != want {
t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want)
@@ -377,7 +378,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) {
t.Fatal(err)
}
gen := genOf(t, got)
- want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` +
gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}`
if string(got) != want {
t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want)
@@ -930,7 +931,7 @@ func TestEdgesFileRawWireBodyPlacementField(t *testing.T) {
if err := json.Unmarshal(got, &probe); err != nil {
t.Fatal(err)
}
- want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"generation":"` +
+ want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` +
probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}`
if string(got) != want {
t.Fatalf("raw wire body (placement set) =\n%s\nwant\n%s", got, want)
@@ -1480,3 +1481,69 @@ func TestEdgesFileStampIsOmittedWhenZero(t *testing.T) {
t.Fatalf("stamp missing: %s", b)
}
}
+
+// The move door (Task 4) admits a move only when every edges file of both
+// regions shows the box's armed floor and its verdict on carlos.prev, so
+// the writer publishes them from the box's own files at every write.
+func TestEdgesFilePublishesMovesProtocolFloorAndPrev(t *testing.T) {
+ type published struct {
+ MovesProtocol *int `json:"moves_protocol"`
+ Floor *string `json:"moves_floor_min_release"`
+ Prev *string `json:"prev_release"`
+ Allowed *string `json:"prev_allowed_for"`
+ }
+ publish := func(t *testing.T, dd, opt string) (published, string) {
+ t.Helper()
+ s, reg := release.NewDirStore(t.TempDir()), edgesTestRegistry(t)
+ seedRoute(t, reg, "app.example.com")
+ var last []byte
+ ident := withMovesFields(edgesFile{Box: "quiet-harbour"}, dd, opt)
+ if err := edgesFilePass(context.Background(), s, reg, ident, "203.0.113.10", &last); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := s.Get(context.Background(), "control/edges/quiet-harbour.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p published
+ if err := json.Unmarshal(body, &p); err != nil {
+ t.Fatal(err)
+ }
+ return p, string(body)
+ }
+
+ // Nothing armed, no carlos.prev.
+ dd, opt := t.TempDir(), t.TempDir()
+ p, body := publish(t, dd, opt)
+ if p.MovesProtocol == nil || *p.MovesProtocol != moves.ProtocolVersion || !strings.Contains(body, `"moves_protocol":1`) {
+ t.Fatalf("moves_protocol missing: %s", body)
+ }
+ if p.Floor != nil || p.Allowed != nil || p.Prev == nil || *p.Prev != "none" {
+ t.Fatalf("unarmed box with no carlos.prev published %s", body)
+ }
+
+ // A carlos.prev with no sidecar: its release is unknown, so absent.
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if p, body = publish(t, dd, opt); p.Prev != nil {
+ t.Fatalf("carlos.prev without a sidecar published %s", body)
+ }
+
+ // Armed, judged: every value as the box's files hold it, one line each.
+ if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.30.0"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := writePrevVersion(opt, "v0.31.0"); err != nil {
+ t.Fatal(err)
+ }
+ writeFileDurable(filepath.Join(opt, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644)
+ p, body = publish(t, dd, opt)
+ if p.Floor == nil || *p.Floor != "v0.30.0" || p.Prev == nil || *p.Prev != "v0.31.0" || p.Allowed == nil || *p.Allowed != "v0.30.0" {
+ t.Fatalf("armed box published %s", body)
+ }
+
+ // No verdict: absent, which the door reads as below the floor.
+ os.Remove(filepath.Join(opt, moves.PrevAllowedFile))
+ if p, body = publish(t, dd, opt); p.Allowed != nil {
+ t.Fatalf("no verdict, yet published %s", body)
+ }
+}
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 77d9f305d..4bd257c09 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -10,6 +10,7 @@ import (
"log"
"net"
"os"
+ "os/exec"
"path/filepath"
"reflect"
"slices"
@@ -19,6 +20,7 @@ import (
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/metrics"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -1311,7 +1313,8 @@ 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"} {
+ "EnvironmentFile=-/etc/carlos/host.env", "system apply", "carlos.prev", "MemoryMax=256M",
+ "floor.min-release", "carlos.prev.allowed", "cmp -s"} {
if !strings.Contains(u, want) {
t.Errorf("update unit lacks %q", want)
}
@@ -1322,7 +1325,8 @@ 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"} {
+ 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"} {
if !strings.Contains(r, want) {
t.Errorf("recover unit lacks %q", want)
}
@@ -1756,3 +1760,89 @@ func TestPlanCarriesArmedList(t *testing.T) {
t.Errorf("plan.Armed = %v, systemctlPlan would start %v", got, want)
}
}
+
+// TestSystemUnitsSelectPrevByTheFloor runs each system unit's ExecStart
+// shell (moves spec §11, round-6 finding 4) against a fake /opt/carlos and
+// /data, with stub binaries that record which one ran. Without a floor file
+// the selection is main's exactly; with one, carlos.prev runs only when its
+// verdict names the floor on disk, byte for byte.
+func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
+ 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
+ }
+ }
+ // systemd expands %-specifiers and $VARIABLES in ExecStart before
+ // the shell ever sees the line (systemd.service(5)).
+ if strings.ContainsAny(line, "%$") {
+ t.Fatalf("ExecStart carries a %% or a $, which systemd expands: %s", line)
+ }
+ for _, path := range []string{"/data/" + moves.FloorMinReleaseFile, "/opt/carlos/" + moves.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
+ journal bool
+ floor, verdict string // "" means no file
+ noPrev bool
+ want string
+ }{
+ {"no journal", false, "", "", false, "carlos"},
+ {"no floor", true, "", "", false, "carlos.prev"},
+ {"no floor, no prev", true, "", "", true, "carlos"},
+ {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev"},
+ {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos"},
+ {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos"},
+ {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ root := t.TempDir()
+ opt, data := filepath.Join(root, "opt"), filepath.Join(root, "data")
+ ran := filepath.Join(root, "ran")
+ os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
+ os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
+ stub := func(name string) {
+ os.WriteFile(filepath.Join(opt, name), []byte("#!/bin/sh\necho "+name+" > "+ran+"\n"), 0o755)
+ }
+ stub("carlos")
+ if !tc.noPrev {
+ stub("carlos.prev")
+ }
+ if tc.journal {
+ os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
+ }
+ if tc.floor != "" {
+ os.WriteFile(filepath.Join(data, moves.FloorMinReleaseFile), []byte(tc.floor), 0o644)
+ }
+ if tc.verdict != "" {
+ os.WriteFile(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.verdict), 0o644)
+ }
+ script := strings.ReplaceAll(body, "/data/", data+"/")
+ script = strings.ReplaceAll(script, "/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)
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/internal/host/movesfloor.go b/internal/host/movesfloor.go
new file mode 100644
index 000000000..c168a96c5
--- /dev/null
+++ b/internal/host/movesfloor.go
@@ -0,0 +1,377 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The moves protocol floor on a box (moves spec §11). Once an operator
+// publishes control/moves-protocol.json, no box may activate an executable
+// below its min_release: not by update, not by rollback, not by recovery,
+// not by picking carlos.prev at boot. The floor is the one Phase 1 element
+// that is not behind CARLOS_MOVES_PHASE1 (index D13): publishing the object
+// arms it, and turning the switch off later does not disarm it. A
+// deployment that never published it has none of the files below, and
+// every path here then does what main does.
+//
+// The files, and who writes them:
+//
+// - <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.
+// - <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.
+// - <optDir>/carlos.prev.allowed, the verdict: the min_release carlos.prev
+// was judged at or above, written only while no registry row is held by
+// an operation (a .prev is a downgrade by definition). judgePrev is the
+// one judge, run by root at every apply and every adopt pass.
+//
+// The two system units choose carlos.prev for a recovery only when the
+// floor line is absent, or equal byte for byte to the verdict. The version
+// comparison happens once, here in Go (moves.ReleaseAtOrAbove, which fails
+// closed); the shell only compares two files.
+//
+// What this is not: a defence against a compromised agent. The agent owns
+// /data and can remove the floor files there. The floor protects the
+// protocol against old builds and honest mistakes, which is what §11 asks.
+
+const movesFloorMaxBytes = 4 << 10
+
+// movesFloorRefreshEvery is the agent's refresh cadence (moves spec §11:
+// a raised floor reaches every box within the hour).
+const movesFloorRefreshEvery = time.Hour
+
+func prevVersionPath(optDir string) string { return filepath.Join(optDir, moves.PrevVersionFile) }
+func prevAllowedPath(optDir string) string { return filepath.Join(optDir, moves.PrevAllowedFile) }
+
+// validFloorRelease is a min_release the floor can be armed at: a release
+// moves.ReleaseAtOrAbove can compare, so nothing that would fail closed on
+// every comparison is ever written to disk.
+func validFloorRelease(v string) bool {
+ ok, err := moves.ReleaseAtOrAbove(v, v)
+ return ok && err == nil
+}
+
+// readLocalFloor reads <dataDir>/.moves/floor.json without following a
+// symlink anywhere below dataDir (root reads it, and the agent owns the
+// tree). (nil, nil) means no floor is armed on this box. A file that is
+// there but cannot be read, or does not hold a usable floor, is an error:
+// the callers treat that as armed and unknown, never as absent.
+func readLocalFloor(dataDir string) (*moves.Floor, error) {
+ f, err := openBeneath(dataDir, filepath.Dir(moves.FloorFile), filepath.Base(moves.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 moves.Floor
+ if err := json.NewDecoder(io.LimitReader(f, movesFloorMaxBytes)).Decode(&fl); err != nil {
+ return nil, fmt.Errorf("%s: %w", f.Name(), err)
+ }
+ if !validFloorRelease(fl.MinRelease) {
+ return nil, fmt.Errorf("%s: min_release %q is not a release", f.Name(), fl.MinRelease)
+ }
+ 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.
+func writeLocalFloor(dataDir string, f moves.Floor) error {
+ if !validFloorRelease(f.MinRelease) {
+ return fmt.Errorf("min_release %q is not a release", f.MinRelease)
+ }
+ 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 {
+ return err
+ }
+ return writeBeneathDurable(dataDir, dir, filepath.Base(moves.FloorMinReleaseFile), []byte(f.MinRelease+"\n"), 0o644)
+}
+
+// errFloorWrite marks a floor the store holds that this box could not
+// write down: the one syncLocalFloor failure that leaves the box enforcing
+// less than what is published.
+var errFloorWrite = errors.New("could not write it on this box")
+
+// syncLocalFloor is the rule root's apply and the agent's refresh share:
+// 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);
+// - 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.
+func syncLocalFloor(ctx context.Context, s release.Store, dataDir string) (*moves.Floor, error) {
+ local, lerr := readLocalFloor(dataDir)
+ if s == nil {
+ 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:
+ return local, errors.Join(rerr, lerr)
+ case remote == nil:
+ return local, lerr
+ case !validFloorRelease(remote.MinRelease):
+ 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
+ }
+ }
+ if err := writeLocalFloor(dataDir, *remote); err != nil {
+ local, _ = readLocalFloor(dataDir)
+ return local, fmt.Errorf("%w: %v", errFloorWrite, err)
+ }
+ return remote, nil
+}
+
+// readBeneathLine reads a one-line file under root without following a
+// symlink below root, trimmed. false means absent, unreadable or empty.
+func readBeneathLine(root, rel string) (string, bool) {
+ f, err := openBeneath(root, strings.Split(filepath.ToSlash(rel), "/")...)
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+ if st, err := f.Stat(); err != nil || !st.Mode().IsRegular() {
+ return "", false
+ }
+ return readLineFrom(f)
+}
+
+// readLine reads a one-line file the reader trusts the path of (root's own
+// /opt/carlos, or the agent's own /data), trimmed. false means absent,
+// unreadable or empty.
+func readLine(path string) (string, bool) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", false
+ }
+ defer f.Close()
+ return readLineFrom(f)
+}
+
+func readLineFrom(r io.Reader) (string, bool) {
+ b, err := io.ReadAll(io.LimitReader(r, 256))
+ if err != nil {
+ return "", false
+ }
+ s := strings.TrimSpace(string(b))
+ return s, s != ""
+}
+
+// writePrevVersion records carlos.prev's release beside it, durably.
+func writePrevVersion(optDir, version string) error {
+ if !validSystemVersion(version) {
+ return fmt.Errorf("carlos.prev: %q is not a version", version)
+ }
+ return writeFileDurable(prevVersionPath(optDir), []byte(version+"\n"), 0o644)
+}
+
+// removeDurable removes path and, when it was there, fsyncs its directory.
+// A path that is already gone is removed.
+func removeDurable(path string) error {
+ err := os.Remove(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ return syncDir(filepath.Dir(path))
+}
+
+// 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) {
+ if reg == nil {
+ return nil, errors.New("no registry")
+ }
+ return reg.HoldRows()
+ }, 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.
+//
+// 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 {
+ 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))
+ }
+ return cause
+ }
+ 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)
+ }
+ 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)
+ }
+ return writeFileDurable(prevAllowedPath(optDir), []byte(floor.MinRelease+"\n"), 0o644)
+}
+
+// prevLockFile serialises the two writers of carlos.prev's sidecar and
+// verdict: decidePrev, which replaces carlos.prev under the apply lock, and
+// the root adopt pass, which re-judges it under no apply lock at all.
+// Without it an adopt pass that read the old sidecar could write its
+// verdict after decidePrev had replaced carlos.prev and removed it.
+const prevLockFile = "prev.lock"
+
+const (
+ prevLockWait = 2 * time.Minute
+ prevLockPoll = 100 * time.Millisecond
+)
+
+// withPrevLock runs fn holding prevLockFile, waiting up to prevLockWait.
+// Holders are short (a binary copy at most), so a lock that is not free by
+// then is reported, not waited out.
+func withPrevLock(optDir string, fn func() error) error {
+ dir := systemRootDir(optDir)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ lock, err := os.OpenFile(filepath.Join(dir, prevLockFile), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ deadline := time.Now().Add(prevLockWait)
+ for {
+ err := flockExclusiveNonblock(lock)
+ if err == nil {
+ break
+ }
+ if errors.Is(err, errSystemUnsupported) || time.Now().After(deadline) {
+ return fmt.Errorf("carlos.prev lock: %w", err)
+ }
+ time.Sleep(prevLockPoll)
+ }
+ 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).
+func rootJudgePrev(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
+ // stat is all an unarmed box pays.
+ if _, err := os.Stat(prevAllowedPath(optDir)); errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ }
+ return withPrevLock(optDir, func() error {
+ return judgePrevWith(optDir, dd, func() ([]registry.Route, error) { return reg.HoldRows() }, func() string {
+ out, err := productionSysOps().run(context.Background(), systemVersionWait, systemPrevPath(optDir), "version")
+ if err != nil {
+ return ""
+ }
+ return carlosVersionOf(out)
+ })
+ })
+}
+
+// movesFloorRefresh is one pass of the agent's refresh: syncLocalFloor on
+// the agent's own store handle, logged when it fails.
+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)
+ }
+}
+
+// movesFloorLoop refreshes the local floor files at start and every hour.
+// Started by edgesFileLoop, whose file publishes what it writes.
+func movesFloorLoop(ctx context.Context, s release.Store, dataDir string) {
+ movesFloorRefresh(ctx, s, dataDir, log.Printf)
+ tick := time.NewTicker(movesFloorRefreshEvery)
+ defer tick.Stop()
+ for range tick.C {
+ movesFloorRefresh(ctx, s, dataDir, log.Printf)
+ }
+}
+
+// withMovesFields sets the edges file's four moves fields from this box's
+// own files, read at each write: the protocol this build speaks, the armed
+// floor's min_release (absent: not armed), carlos.prev's release ("none":
+// there is no carlos.prev; absent: unknown) and the verdict (absent: no
+// 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))
+ f.PrevRelease = ""
+ if _, err := os.Stat(systemPrevPath(optDir)); errors.Is(err, os.ErrNotExist) {
+ f.PrevRelease = "none"
+ } else if err == nil {
+ f.PrevRelease, _ = readLine(prevVersionPath(optDir))
+ }
+ f.PrevAllowedFor, _ = readLine(prevAllowedPath(optDir))
+ return f
+}
diff --git a/internal/host/movesfloor_linux.go b/internal/host/movesfloor_linux.go
new file mode 100644
index 000000000..fb059f382
--- /dev/null
+++ b/internal/host/movesfloor_linux.go
@@ -0,0 +1,101 @@
+//go:build linux
+
+package host
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strconv"
+ "syscall"
+)
+
+// 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.
+//
+// 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 {
+ 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)
+ }
+ 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)
+ }
+ if dfd, err = syscall.Openat(rfd, dir, dirFlags, 0); err == nil {
+ fchownToService(dfd)
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("open %s: %w", filepath.Join(root, dir), err)
+ }
+ defer syscall.Close(dfd)
+
+ 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()))
+ if err != nil {
+ return fmt.Errorf("create %s: %w", filepath.Join(root, dir, tmp), err)
+ }
+ f := os.NewFile(uintptr(fd), filepath.Join(root, dir, tmp))
+ werr := func() error {
+ if _, err := f.Write(data); err != nil {
+ return err
+ }
+ if err := f.Chmod(mode.Perm()); err != nil {
+ return err
+ }
+ return f.Sync()
+ }()
+ if cerr := f.Close(); werr == nil {
+ werr = cerr
+ }
+ if werr == nil {
+ werr = syscall.Renameat(dfd, tmp, dfd, name)
+ }
+ if werr != nil {
+ syscall.Unlinkat(dfd, tmp)
+ return fmt.Errorf("write %s: %w", filepath.Join(root, dir, name), werr)
+ }
+ if err := syscall.Fsync(dfd); err != nil {
+ return fmt.Errorf("fsync %s: %w", filepath.Join(root, dir), err)
+ }
+ return nil
+}
+
+// fchownToService hands a directory root just created to the service user,
+// best-effort: a process that is not root, or a box with no such user (a
+// test), keeps the owner it has.
+func fchownToService(fd int) {
+ if os.Geteuid() != 0 {
+ return
+ }
+ u, err := user.Lookup(serviceUser)
+ if err != nil {
+ return
+ }
+ uid, uerr := strconv.Atoi(u.Uid)
+ gid, gerr := strconv.Atoi(u.Gid)
+ if uerr != nil || gerr != nil {
+ return
+ }
+ syscall.Fchown(fd, uid, gid)
+}
diff --git a/internal/host/movesfloor_other.go b/internal/host/movesfloor_other.go
new file mode 100644
index 000000000..6aca8f058
--- /dev/null
+++ b/internal/host/movesfloor_other.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+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 {
+ return errSystemUnsupported
+}
diff --git a/internal/host/movesfloor_test.go b/internal/host/movesfloor_test.go
new file mode 100644
index 000000000..5d5970cae
--- /dev/null
+++ b/internal/host/movesfloor_test.go
@@ -0,0 +1,253 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// putFloor publishes the operator's floor object in s.
+func putFloor(t *testing.T, s release.Store, minRelease string) {
+ t.Helper()
+ body, _ := json.Marshal(moves.Floor{Min: moves.ProtocolVersion, MinRelease: minRelease})
+ if _, err := s.Put(context.Background(), moves.FloorKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// armLocalFloor writes the box's two local floor files, as an apply would.
+func armLocalFloor(t *testing.T, dataDir, minRelease string) {
+ t.Helper()
+ if err := writeLocalFloor(dataDir, moves.Floor{Min: moves.ProtocolVersion, MinRelease: minRelease}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func noHolds() ([]registry.Route, error) { return nil, nil }
+
+func oneHold() ([]registry.Route, error) {
+ return []registry.Route{{Host: "app.example.com", Status: "moving", HoldOp: "op1"}}, nil
+}
+
+func TestLocalFloorFilesRoundTrip(t *testing.T) {
+ dd := t.TempDir()
+ f, err := readLocalFloor(dd)
+ if err != nil || f != nil {
+ t.Fatalf("no files read as %+v, %v", f, err)
+ }
+ armLocalFloor(t, dd, "v0.30.0")
+ f, err = readLocalFloor(dd)
+ if err != nil || f == nil || f.MinRelease != "v0.30.0" || f.Min != moves.ProtocolVersion {
+ t.Fatalf("armed floor read as %+v, %v", f, err)
+ }
+ line, err := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile))
+ if err != nil || string(line) != "v0.30.0\n" {
+ t.Fatalf("floor.min-release = %q, %v", line, err)
+ }
+ // A floor whose min_release is not a release is never written: the
+ // units would compare garbage, and the door could never match it.
+ if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.31.0\nv0.1.0"}); err == nil {
+ t.Fatal("a two-line min_release was written")
+ }
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("a refused write disturbed the armed floor: %+v", f)
+ }
+}
+
+// The local floor lives in a directory the sandboxed agent owns, and root
+// reads and writes it. Neither may follow a symlink planted there.
+func TestLocalFloorRefusesASymlink(t *testing.T) {
+ dd, elsewhere := t.TempDir(), t.TempDir()
+ if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeLocalFloor(dd, moves.Floor{Min: 1, MinRelease: "v0.30.0"}); 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, "floor.json"), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644)
+ if f, err := readLocalFloor(dd); err == nil {
+ t.Fatalf("read through a symlinked .moves: %+v", f)
+ }
+}
+
+func TestSyncLocalFloorKeepsWhatIsArmed(t *testing.T) {
+ ctx := context.Background()
+
+ // Absent everywhere: no floor, no files.
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ if f, err := syncLocalFloor(ctx, s, dd); err != nil || f != nil {
+ t.Fatalf("absent everywhere = %+v, %v", f, err)
+ }
+ if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("an unarmed deployment grew a .moves directory")
+ }
+
+ // Published: both local files written.
+ putFloor(t, s, "v0.30.0")
+ if f, err := syncLocalFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("published = %+v, %v", f, err)
+ }
+ if got, _ := readLocalFloor(dd); got == nil || got.MinRelease != "v0.30.0" {
+ t.Fatalf("local floor = %+v", got)
+ }
+
+ // The store unreadable: the local floor stands, and the error is said.
+ if f, err := syncLocalFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("store error = %+v, %v", f, err)
+ }
+
+ // The object gone (it is create-only, so an operator removed it): once
+ // armed, the floor stays armed.
+ if err := s.Delete(ctx, moves.FloorKey); err != nil {
+ t.Fatal(err)
+ }
+ if f, err := syncLocalFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("object removed = %+v, %v", f, err)
+ }
+
+ // Raised: the local files follow.
+ putFloor(t, s, "v0.31.0")
+ if f, _ := syncLocalFloor(ctx, s, dd); f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("raised = %+v", f)
+ }
+ if b, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile)); string(b) != "v0.31.0\n" {
+ t.Fatalf("floor.min-release after a raise = %q", b)
+ }
+
+ // A malformed min_release is never armed over a good one.
+ s.Delete(ctx, moves.FloorKey)
+ putFloor(t, s, "v0.32.0-rc.1")
+ if f, err := syncLocalFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.31.0" {
+ t.Fatalf("malformed = %+v, %v", f, err)
+ }
+}
+
+func TestJudgePrevWritesTheVerdictOnlyAtOrAboveTheFloorWithoutAHold(t *testing.T) {
+ setup := func(t *testing.T, floor, prev string) (opt, dd string) {
+ opt, dd = t.TempDir(), t.TempDir()
+ os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
+ if prev != "" {
+ if err := writePrevVersion(opt, prev); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if floor != "" {
+ armLocalFloor(t, dd, floor)
+ }
+ return opt, dd
+ }
+ verdict := func(opt string) (string, bool) {
+ b, err := os.ReadFile(filepath.Join(opt, moves.PrevAllowedFile))
+ return string(b), err == nil
+ }
+
+ t.Run("at the floor", func(t *testing.T) {
+ opt, dd := setup(t, "v0.30.0", "v0.30.0")
+ if err := judgePrevWith(opt, dd, noHolds, nil); err != nil {
+ t.Fatal(err)
+ }
+ v, ok := verdict(opt)
+ line, _ := os.ReadFile(filepath.Join(dd, moves.FloorMinReleaseFile))
+ // Byte-identical, because the units compare them with cmp.
+ if !ok || !bytes.Equal([]byte(v), line) {
+ t.Fatalf("verdict %q (%v), floor line %q", v, ok, line)
+ }
+ })
+ for _, tc := range []struct {
+ name, floor, prev string
+ holds func() ([]registry.Route, error)
+ }{
+ {"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") }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ opt, dd := setup(t, tc.floor, tc.prev)
+ // A verdict from an earlier judgement is removed, not left.
+ writeFileDurable(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.floor+"\n"), 0o644)
+ judgePrevWith(opt, dd, tc.holds, nil)
+ if v, ok := verdict(opt); ok {
+ t.Fatalf("verdict %q left for %s", v, tc.name)
+ }
+ })
+ }
+ t.Run("no carlos.prev", func(t *testing.T) {
+ opt, dd := setup(t, "v0.30.0", "v0.31.0")
+ os.Remove(systemPrevPath(opt))
+ writeFileDurable(filepath.Join(opt, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644)
+ if err := judgePrevWith(opt, dd, noHolds, nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := verdict(opt); ok {
+ t.Fatal("verdict left with no carlos.prev")
+ }
+ })
+ t.Run("the sidecar is learned when missing", func(t *testing.T) {
+ opt, dd := setup(t, "v0.30.0", "")
+ asked := 0
+ if err := judgePrevWith(opt, dd, noHolds, func() string { asked++; return "v0.30.1" }); err != nil {
+ t.Fatal(err)
+ }
+ if b, _ := os.ReadFile(filepath.Join(opt, moves.PrevVersionFile)); string(b) != "v0.30.1\n" || asked != 1 {
+ t.Fatalf("sidecar %q after %d asks", b, asked)
+ }
+ if _, ok := verdict(opt); !ok {
+ t.Fatal("no verdict for a learned version at or above the floor")
+ }
+ })
+}
+
+// No floor armed: judgePrev consults neither the registry nor carlos.prev,
+// and leaves no verdict (the units ignore it then anyway).
+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 }
+ ran := false
+ if err := judgePrevWith(opt, dd, holds, func() string { ran = true; return "v0.29.0" }); err != nil {
+ t.Fatal(err)
+ }
+ if consulted || ran {
+ t.Fatalf("unarmed judge consulted the registry (%v) or ran carlos.prev (%v)", consulted, ran)
+ }
+ for _, name := range []string{moves.PrevAllowedFile, moves.PrevVersionFile} {
+ if _, err := os.Stat(filepath.Join(opt, name)); err == nil {
+ t.Errorf("unarmed judge wrote %s", name)
+ }
+ }
+}
+
+// The agent's hourly refresh is syncLocalFloor on the agent's own store
+// handle; the edges file then publishes what it wrote.
+func TestMovesFloorRefreshArmsFromTheStore(t *testing.T) {
+ dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
+ var said []string
+ logf := func(format string, args ...any) { said = append(said, format) }
+ movesFloorRefresh(context.Background(), s, dd, logf)
+ if f, _ := readLocalFloor(dd); f != nil {
+ t.Fatalf("no object, yet armed %+v", f)
+ }
+ putFloor(t, s, "v0.30.0")
+ movesFloorRefresh(context.Background(), s, dd, logf)
+ if f, _ := readLocalFloor(dd); f == nil || f.MinRelease != "v0.30.0" {
+ t.Fatalf("refresh did not arm: %+v", f)
+ }
+ movesFloorRefresh(context.Background(), unreadableStore{s}, dd, logf)
+ if len(said) == 0 {
+ t.Fatal("a failed refresh said nothing")
+ }
+}
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index fa34ad671..91c6d606a 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -23,6 +23,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/cliflag"
"github.com/carlosframework/platform/internal/layout"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -221,6 +222,9 @@ type systemApplier struct {
// refuses an update while this box takes part in a claim operation
// (region HA spec 1 §5.10; systemparticipation.go).
regionHA bool
+ // store is the deploy store verifyPointer opened, kept for armFloor.
+ // Nil on --recover, which reads no store.
+ store release.Store
}
func (a *systemApplier) rootDir() string { return systemRootDir(a.optDir) }
@@ -303,6 +307,12 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error {
if err != nil {
return a.record(req, err)
}
+ // The moves protocol floor (moves spec §11), written to disk from the
+ // store before anything is staged, so every check below and every later
+ // recovery enforces what is published.
+ if err := a.armFloor(ctx); err != nil {
+ return a.record(req, err)
+ }
if req.Version != p.Current.Version || !req.PromotedAt.Equal(p.PromotedAt) {
return a.record(req, fmt.Errorf("%w: request names %s@%s, pointer is %s@%s", errSystemRefused,
req.Version, req.PromotedAt.Format(time.RFC3339), p.Current.Version, p.PromotedAt.Format(time.RFC3339)))
@@ -322,6 +332,12 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error {
if req.NoOp || p.Current.Version == a.onDiskBuild(ctx) {
return a.writeResult(req, systemOutcomeNoted, "")
}
+ // The install's activation check, before the candidate is copied in or
+ // run at all: a build below the floor, or a downgrade while a row is
+ // held, is refused with the box exactly as it was.
+ if err := a.activationAllowed(ctx, p.Current.Version); err != nil {
+ return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
+ }
staged, err := a.stageCandidate(ctx, p, req)
if err != nil {
return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
@@ -513,14 +529,180 @@ func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) {
disk := a.onDiskBuild(ctx)
known := (applied != nil && applied.Version == disk && disk != "") || (applied == nil && prevErr != nil)
if !known {
- return prevErr == nil, nil
+ return prevErr == nil, a.notePrev(ctx, false, "")
}
- if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil {
+ if err := a.notePrev(ctx, true, disk); err != nil {
return false, err
}
return true, nil
}
+// notePrev is decidePrev's half of the moves floor (moves spec §11), under
+// the lock the adopt pass's judge takes too. With replace, carlos.prev is
+// refreshed from the on-disk binary (version is what that binary reported):
+// the sidecar and the verdict of the carlos.prev being replaced are removed
+// first, so a crash mid-copy leaves a carlos.prev that nothing vouches for
+// (refused while a floor is armed), never one vouched for by its
+// predecessor's files. Then the sidecar is written and judgePrev runs, all
+// before the journal.
+//
+// The copy's own failure is decidePrev's error as it always was. The floor
+// steps are best-effort while no floor is armed (logged, never fatal: an
+// unarmed deployment must update exactly as main does), and hard once one
+// is (floorStep).
+func (a *systemApplier) notePrev(ctx context.Context, replace bool, version string) error {
+ var copyErr error
+ ran := false
+ steps := func() error {
+ ran = true
+ if replace {
+ if err := a.floorStep(errors.Join(removeDurable(prevAllowedPath(a.optDir)), removeDurable(prevVersionPath(a.optDir)))); err != nil {
+ return err
+ }
+ if copyErr = copyFileDurable(systemBinaryPath(a.optDir), systemPrevPath(a.optDir), 0o755); copyErr != nil {
+ return nil
+ }
+ if version != "" {
+ if err := a.floorStep(writePrevVersion(a.optDir, version)); err != nil {
+ return err
+ }
+ }
+ }
+ return a.floorStep(judgePrevWith(a.optDir, a.dataDir, a.holdRows, func() string { return a.prevBuild(ctx) }))
+ }
+ err := withPrevLock(a.optDir, steps)
+ if !ran {
+ // The lock itself failed. Armed, that is the apply's error; with no
+ // floor the steps run anyway, because the backup is main's step.
+ if err = a.floorStep(err); err != nil {
+ return err
+ }
+ err = steps()
+ }
+ if copyErr != nil {
+ return copyErr
+ }
+ return err
+}
+
+// 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).
+func (a *systemApplier) floorArmed() bool {
+ f, err := readLocalFloor(a.dataDir)
+ return err != nil || f != nil
+}
+
+// floorStep is the dormancy rule for the floor's own steps (moves Task 13,
+// ruling F15): while no floor is armed a failure is a log line and the
+// apply goes on as main's would; once one is, it is the apply's error.
+func (a *systemApplier) floorStep(err error) error {
+ if err == nil {
+ return nil
+ }
+ if a.floorArmed() {
+ return err
+ }
+ a.ops.logf("system apply: %v (no moves protocol floor is armed; continuing)", err)
+ return nil
+}
+
+// armFloor writes the published floor to this box's local floor files
+// (syncLocalFloor), first thing once the store is open. What is published
+// and could not be written down defers the apply: the box would otherwise
+// 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.
+func (a *systemApplier) armFloor(ctx context.Context) error {
+ f, err := syncLocalFloor(ctx, a.store, a.dataDir)
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, errFloorWrite) {
+ return fmt.Errorf("%w: moves protocol floor: %v", errSystemDeferred, err)
+ }
+ onDisk := "none"
+ if f != nil {
+ onDisk = f.MinRelease
+ }
+ a.ops.logf("system apply: moves protocol floor: %v (the floor on this box stays %s)", err, onDisk)
+ return nil
+}
+
+// activationAllowed is the applier's own rule for every path that puts an
+// executable in place (moves spec §11): the install (for the candidate),
+// and restoreFiles (for carlos.prev, by its sidecar), which both rollback
+// and recovery go through. Two halves, both only once a floor is armed:
+//
+// - the floor: version below the armed min_release is refused. The floor
+// is read from the local file alone, never the store: boot recovery
+// runs before the network.
+// - the hold: while any registry row is held by an operation, a version
+// below the release running now is refused (§11: a participant in a
+// held operation does not downgrade).
+//
+// With no floor armed it reads neither the registry nor any version and
+// allows, with one log line: main's behaviour.
+//
+// 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,
+// and only on the install path. This one refuses only a downgrade, while a
+// moves hold_op row exists, once a floor is armed, and on every activating
+// path, rollback and recovery included. They overlap on a downgrade
+// install during a move that also runs a claim operation; there both run
+// (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 {
+ floor, err := readLocalFloor(a.dataDir)
+ if err != nil {
+ return fmt.Errorf("moves protocol floor: the floor on this box cannot be read: %v", err)
+ }
+ if floor == nil {
+ a.ops.logf("system apply: no moves protocol floor is armed on this box; %q is not checked against one", version)
+ return nil
+ }
+ if version == "" {
+ return fmt.Errorf("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)
+ }
+ rows, err := a.holdRows()
+ 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
+ }
+ running := a.onDiskBuild(ctx)
+ if version == running {
+ 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)
+ }
+ 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) {
+ open := a.openRegistry
+ if open == nil {
+ open = func() (*registry.Registry, error) { return openSystemRegistry(a.dataDir) }
+ }
+ reg, err := open()
+ if err != nil {
+ return nil, err
+ }
+ defer reg.Close()
+ return reg.HoldRows()
+}
+
func (a *systemApplier) prevBuild(ctx context.Context) string {
out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version")
if err != nil {
@@ -931,6 +1113,14 @@ func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (boo
if _, err := os.Stat(prev); err != nil {
return bootstrapPlan{}, fmt.Errorf("no carlos.prev: %w", err)
}
+ // The moves floor and the hold rule (moves spec §11), before anything
+ // on the box changes, for the version carlos.prev's sidecar records (no
+ // 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 {
+ return bootstrapPlan{}, err
+ }
// The same quiesce the install did: a root pass that is mid-flight
// against the failed build must not run on across the swap back.
if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil {
@@ -1151,6 +1341,7 @@ func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPoint
if err != nil {
return nil, fmt.Errorf("%w: %v", errSystemDeferred, err)
}
+ a.store = store
p, _, err := release.GetSystemPointer(ctx, store, pub, release.SystemComponentRouter, a.channel)
switch {
case err == nil:
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go
index 4327daa2d..f27a8cf2d 100644
--- a/internal/host/systemapply_test.go
+++ b/internal/host/systemapply_test.go
@@ -1,12 +1,14 @@
package host
import (
+ "bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"slices"
@@ -14,6 +16,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -1523,3 +1526,329 @@ func TestLayoutReversalIncompleteNamesEveryRowInTheWay(t *testing.T) {
t.Fatalf("a flat box reported %q", got)
}
}
+
+// ---- the moves protocol floor (moves spec §11, Task 13) ----
+
+// floorFixture is applyHappy with release numbers the floor can compare: a
+// box running `running` (applied.json names it, so the install refreshes
+// carlos.prev from it) and a signed candidate at target. The candidate
+// ships the layout's mount unit, so the layout guard never opens the
+// registry and every registry open in these tests is the floor's own.
+func floorFixture(t *testing.T, running, target string) (*applyFixture, systemRequest) {
+ t.Helper()
+ f := newApplyFixture(t, "stable", running)
+ req := f.promote(target, []byte("#!/bin/sh\necho carlos "+target+"\n"), false)
+ bin, staged, prev := systemBinaryPath(f.a.optDir), filepath.Join(f.a.stagingDir(), "carlos"), systemPrevPath(f.a.optDir)
+ f.answer["version:"+staged] = "carlos " + target + "\n"
+ f.answer["plan:"+staged] = f.answer["plan:"+bin]
+ f.answer["units:"+staged] = f.answer["units:"+bin] + " " + layoutMountUnit
+ f.answer["plan:"+prev] = f.answer["plan:"+bin]
+ f.answer["units:"+prev] = f.answer["units:"+bin]
+ // Every binary answers `version` from its own bytes from here on.
+ delete(f.answer, "version:"+bin)
+ if err := writeJSONDurable(f.a.file("applied.json"), systemApplied{Version: running, Channel: "stable", At: f.now}, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return f, req
+}
+
+// holdRow seeds a row and holds it under a move, as the agent's SetHold does.
+func (f *applyFixture) holdRow(host string) {
+ f.t.Helper()
+ f.seedRoutes(flatRow(f.a.dataDir, host))
+ reg, err := registry.Open(filepath.Join(f.a.dataDir, "registry.db"))
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ defer reg.Close()
+ if err := reg.SetHold(host, "moving", "op-1"); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+// onDisk is what the box's carlos binary says it is.
+func (f *applyFixture) onDisk() string { return f.a.onDiskBuild(context.Background()) }
+
+// brokenBox puts a failed install on disk (running), a carlos.prev at prev
+// with its sidecar (none when sidecar is ""), and the journal an install
+// leaves open after its restart.
+func (f *applyFixture) brokenBox(running, prev, sidecar string) systemJournal {
+ f.t.Helper()
+ os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+running+"\n"), 0o755)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+prev+"\n"), 0o755)
+ if sidecar != "" {
+ if err := writePrevVersion(f.a.optDir, sidecar); err != nil {
+ f.t.Fatal(err)
+ }
+ }
+ j := systemJournal{Phase: systemPhaseRestarted, Target: running, PrevBuild: prev, StartedAt: f.now}
+ if err := f.a.journal(j); err != nil {
+ f.t.Fatal(err)
+ }
+ return j
+}
+
+func TestSystemApplyArmsTheFloorBeforeStaging(t *testing.T) {
+ f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
+ putFloor(t, f.store, "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 below-floor candidate was staged and run: %s %v", name, args)
+ }
+ return run(ctx, d, name, args...)
+ }
+ before, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if lf, err := readLocalFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" {
+ t.Fatalf("local floor = %+v, %v", lf, err)
+ }
+ if b, _ := os.ReadFile(filepath.Join(f.a.dataDir, moves.FloorMinReleaseFile)); string(b) != "v0.30.0\n" {
+ t.Fatalf("floor.min-release = %q", b)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "moves protocol floor: v0.29.0 is below v0.30.0") {
+ t.Fatalf("result = %+v", r)
+ }
+ if after, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !bytes.Equal(before, after) {
+ t.Fatal("the binary changed")
+ }
+ if _, err := os.Stat(systemPrevPath(f.a.optDir)); err == nil {
+ t.Fatal("a refused install refreshed carlos.prev")
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatal("a refused install opened a journal")
+ }
+}
+
+// Boot recovery runs before the network: the floor it enforces is the one
+// on disk, and the version it judges is carlos.prev's sidecar.
+func TestBootRecoveryUsesTheArmedFloorOffline(t *testing.T) {
+ for _, tc := range []struct {
+ name, sidecar string
+ restored bool
+ }{
+ {"below the floor", "v0.29.0", false},
+ {"at the 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
+ }
+ armLocalFloor(t, f.a.dataDir, "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(), "moves 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)
+ }
+ })
+ }
+}
+
+// decidePrev learns carlos.prev's version and judges it before the journal
+// is written. The proof of "before": region HA defers this install at the
+// step just ahead of the journal, and the two files are already there.
+func TestDecidePrevWritesTheSidecarAndVerdictBeforeTheJournal(t *testing.T) {
+ for _, tc := range []struct {
+ name, running string
+ held, verdict bool
+ }{
+ {"at or above the floor", "v0.31.0", false, true},
+ {"below the floor", "v0.29.0", false, false},
+ {"a held row", "v0.31.0", true, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f, _ := floorFixture(t, tc.running, "v0.32.0")
+ putFloor(t, f.store, "v0.30.0")
+ if tc.held {
+ f.holdRow("app.example.com")
+ }
+ // A verdict from an earlier judgement must not outlive this one.
+ writeFileDurable(filepath.Join(f.a.optDir, moves.PrevAllowedFile), []byte("v0.30.0\n"), 0o644)
+ f.a.regionHA = true
+ os.MkdirAll(filepath.Join(f.a.dataDir, hostexecDir, "op-9"), 0o755)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeDeferred {
+ t.Fatalf("result = %+v", r)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatal("journal written")
+ }
+ if b, _ := os.ReadFile(filepath.Join(f.a.optDir, moves.PrevVersionFile)); string(b) != tc.running+"\n" {
+ t.Fatalf("sidecar = %q, want %s", b, tc.running)
+ }
+ b, err := os.ReadFile(filepath.Join(f.a.optDir, moves.PrevAllowedFile))
+ if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") {
+ t.Fatalf("verdict = %q, %v; want present %v", b, err, tc.verdict)
+ }
+ })
+ }
+}
+
+// Replacing carlos.prev withdraws its predecessor's sidecar and verdict
+// first, so a copy that dies part way leaves a carlos.prev nothing vouches
+// for, never the new bytes under the old verdict.
+func TestDecidePrevForgetsTheOldVerdictBeforeTheCopy(t *testing.T) {
+ f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
+ putFloor(t, f.store, "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(filepath.Join(f.a.optDir, moves.PrevAllowedFile), []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)
+ }
+ for _, name := range []string{moves.PrevAllowedFile, moves.PrevVersionFile} {
+ if _, err := os.Stat(filepath.Join(f.a.optDir, name)); err == nil {
+ t.Errorf("%s survived a failed replacement of carlos.prev", name)
+ }
+ }
+}
+
+// Moves spec §11 (round-6 finding 5): while a row is held, no activating
+// path takes the box below the release it runs, whatever the floor allows.
+func TestHeldParticipantRefusesEveryDowngrade(t *testing.T) {
+ const downgrade = "a participant in a held operation does not downgrade"
+ for _, held := range []bool{true, false} {
+ t.Run(fmt.Sprintf("held=%v", held), func(t *testing.T) {
+ // install
+ f, _ := floorFixture(t, "v0.32.0", "v0.31.0")
+ putFloor(t, f.store, "v0.30.0")
+ if held {
+ f.holdRow("app.example.com")
+ }
+ f.a.Run(context.Background(), false)
+ r := f.result()
+ if held && (r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, downgrade) || f.onDisk() != "v0.32.0") {
+ t.Fatalf("held install: result %+v, on disk %q", r, f.onDisk())
+ }
+ if !held && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.31.0") {
+ t.Fatalf("unheld install: result %+v, on disk %q", r, f.onDisk())
+ }
+
+ // rollback
+ f, req := floorFixture(t, "v0.32.0", "v0.33.0")
+ armLocalFloor(t, f.a.dataDir, "v0.30.0")
+ if held {
+ f.holdRow("app.example.com")
+ }
+ j := f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
+ f.a.rollback(context.Background(), req, "gate: test", &j)
+ r = f.result()
+ if held && (r == nil || r.Outcome != systemOutcomeRollbackFailed || !strings.Contains(r.Reason, downgrade) || f.onDisk() != "v0.32.0") {
+ t.Fatalf("held rollback: result %+v, on disk %q", r, f.onDisk())
+ }
+ if !held && (r == nil || r.Outcome != systemOutcomeRolledBack || f.onDisk() != "v0.31.0") {
+ t.Fatalf("unheld rollback: result %+v, on disk %q", r, f.onDisk())
+ }
+
+ // restoreFiles, which recovery calls without rollback
+ f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
+ armLocalFloor(t, f.a.dataDir, "v0.30.0")
+ if held {
+ f.holdRow("app.example.com")
+ }
+ j = f.brokenBox("v0.32.0", "v0.31.0", "v0.31.0")
+ _, err := f.a.restoreFiles(context.Background(), &j)
+ if held && (err == nil || !strings.Contains(err.Error(), downgrade) || f.onDisk() != "v0.32.0") {
+ t.Fatalf("held restore: err %v, on disk %q", err, f.onDisk())
+ }
+ if !held && (err != nil || f.onDisk() != "v0.31.0") {
+ t.Fatalf("unheld restore: err %v, on disk %q", err, f.onDisk())
+ }
+ })
+ }
+}
+
+// D13: the floor is armed by publishing its object, not by the switch. A
+// deployment that never published it behaves as main, held rows and all,
+// and never opens the registry for the floor. Once armed, turning the
+// switch off does not disarm it.
+func TestFloorIsDormantWithoutTheObject(t *testing.T) {
+ t.Setenv(moves.SwitchEnv, "")
+ counting := func(f *applyFixture) *int {
+ n := 0
+ open := f.a.openRegistry
+ f.a.openRegistry = func() (*registry.Registry, error) { n++; return open() }
+ return &n
+ }
+
+ f, _ := floorFixture(t, "v0.32.0", "v0.31.0")
+ f.holdRow("app.example.com")
+ opened := counting(f)
+ 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.31.0" {
+ t.Fatalf("install: result %+v, on disk %q", r, f.onDisk())
+ }
+ if *opened != 0 {
+ t.Fatalf("an unarmed install opened the registry %d times", *opened)
+ }
+ if _, err := os.Stat(filepath.Join(f.a.dataDir, ".moves")); err == nil {
+ t.Fatal("an unarmed install wrote floor files")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.optDir, moves.PrevAllowedFile)); err == nil {
+ t.Fatal("an unarmed install wrote a verdict")
+ }
+
+ f, req := floorFixture(t, "v0.32.0", "v0.33.0")
+ f.holdRow("app.example.com")
+ opened = counting(f)
+ j := f.brokenBox("v0.32.0", "v0.31.0", "")
+ f.a.rollback(context.Background(), req, "gate: test", &j)
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack || f.onDisk() != "v0.31.0" {
+ t.Fatalf("rollback: result %+v, on disk %q", r, f.onDisk())
+ }
+ if *opened != 0 {
+ t.Fatalf("an unarmed rollback opened the registry %d times", *opened)
+ }
+
+ f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
+ f.holdRow("app.example.com")
+ opened = counting(f)
+ j = f.brokenBox("v0.32.0", "v0.31.0", "")
+ if _, err := f.a.restoreFiles(context.Background(), &j); err != nil || f.onDisk() != "v0.31.0" {
+ t.Fatalf("restore: err %v, on disk %q", err, f.onDisk())
+ }
+ if *opened != 0 {
+ t.Fatalf("an unarmed restore opened the registry %d times", *opened)
+ }
+
+ // Armed on disk, the switch still off: a below-floor carlos.prev stays put.
+ f, _ = floorFixture(t, "v0.32.0", "v0.33.0")
+ armLocalFloor(t, f.a.dataDir, "v0.30.0")
+ j = f.brokenBox("v0.32.0", "v0.29.0", "v0.29.0")
+ if _, err := f.a.restoreFiles(context.Background(), &j); err == nil || !strings.Contains(err.Error(), "moves protocol floor: v0.29.0 is below v0.30.0") {
+ t.Fatalf("armed restore with the switch off: err %v", err)
+ }
+ if f.onDisk() != "v0.32.0" {
+ t.Fatalf("on disk %q", f.onDisk())
+ }
+}
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service
index 8e74c0e20..3da2b581b 100644
--- a/internal/host/units/carlos-system-recover.service
+++ b/internal/host/units/carlos-system-recover.service
@@ -5,6 +5,18 @@
# from inside a unit the edge is ordered after: at boot the edge's own
# start IS the restart. At runtime (edge already started this boot) it
# issues a non-blocking restart and exits.
+#
+# When a journal is open and carlos.prev exists, the recovery runs from
+# carlos.prev.
+# The moves protocol floor (moves spec §11) narrows that choice: once a
+# floor is armed (/data/.moves/floor.min-release is there and not empty),
+# carlos.prev runs only when its verdict file (/opt/carlos/carlos.prev.allowed,
+# written by root after comparing versions in Go) names that same floor,
+# byte for byte.
+# Otherwise the current binary runs, and its own check refuses to restore a
+# 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.
[Unit]
Description=carlos platform self-update recovery
Before=carlos-edge.service
@@ -14,7 +26,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 ]; 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; }; 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 a2271f826..9bc8d25f9 100644
--- a/internal/host/units/carlos-system-update.service
+++ b/internal/host/units/carlos-system-update.service
@@ -6,6 +6,16 @@
# (a previous apply was cut off) and carlos.prev exists, the RECOVERY runs
# from carlos.prev rather than from the candidate that did not work.
#
+# The moves protocol floor (moves spec §11) narrows that choice: once a
+# floor is armed (/data/.moves/floor.min-release is there and not empty),
+# carlos.prev runs only when its verdict file (/opt/carlos/carlos.prev.allowed,
+# written by root after comparing versions in Go) names that same floor,
+# byte for byte.
+# Otherwise the current binary runs, and its own check refuses to restore a
+# 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.
+#
# 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
@@ -22,7 +32,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 ]; 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; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
# --- hardening (the carlos-stage.service set) ---
ProtectHome=yes
diff --git a/internal/moves/floor.go b/internal/moves/floor.go
index 8963ce4a5..0704ea932 100644
--- a/internal/moves/floor.go
+++ b/internal/moves/floor.go
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/release"
)
@@ -18,6 +19,43 @@ const ProtocolVersion = 1
// release bucket.
const FloorKey = "control/moves-protocol.json"
+// ConsoleKey is where a console with the moves switch on publishes the
+// protocol it speaks and the fingerprint of its secrets public key, once at
+// each start (replacing the last start's record).
+const ConsoleKey = "control/moves-console.json"
+
+// ConsoleRecord is the body at ConsoleKey.
+type ConsoleRecord struct {
+ V int `json:"v"`
+ Protocol int `json:"protocol"`
+ SecretsKeyFP string `json:"secrets_key_fp,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// The box's local copy of the floor and its verdict on carlos.prev (spec
+// §11). The units that pick the executable at recovery read two of these
+// by fixed path (internal/host/units/carlos-system-*.service), so they are
+// constants and never configuration.
+const (
+ // FloorFile is the floor as JSON, relative to the box's DataDir
+ // (<DataDir>/.moves/floor.json). Root's apply and the agent write it;
+ // root's activation check reads it and nothing else, so it works offline
+ // at boot.
+ FloorFile = ".moves/floor.json"
+ // FloorMinReleaseFile is the floor's min_release alone, one line,
+ // relative to the box's DataDir (<DataDir>/.moves/floor.min-release).
+ // The units compare it byte for byte with PrevAllowedFile.
+ FloorMinReleaseFile = ".moves/floor.min-release"
+ // PrevVersionFile is carlos.prev's version, one line, beside carlos.prev
+ // in the box's opt dir (/opt/carlos/carlos.prev.version).
+ PrevVersionFile = "carlos.prev.version"
+ // PrevAllowedFile is the verdict on carlos.prev, beside it in the opt
+ // dir: the min_release it was judged at or above, one line, in the same
+ // bytes as FloorMinReleaseFile. Absent means carlos.prev may not run
+ // while a floor is armed.
+ PrevAllowedFile = "carlos.prev.allowed"
+)
+
// Floor is the minimum protocol version, and the first release that speaks
// it, that the door requires of a host before a move may involve it.
type Floor struct {
diff --git a/internal/moves/floor_test.go b/internal/moves/floor_test.go
index f008a522c..cc056b6e1 100644
--- a/internal/moves/floor_test.go
+++ b/internal/moves/floor_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/release"
)
@@ -60,3 +61,27 @@ func TestReadFloorGarbageIsAnError(t *testing.T) {
t.Fatalf("garbage floor read as %+v, %v", f, err)
}
}
+
+// The console record's wire names are what Task 11's T2 check and any
+// operator reading the bucket see; the four file names are what the host
+// writes and the two system units hard-code (host_test.go pins the units
+// against these same constants).
+func TestConsoleRecordAndFloorFileNames(t *testing.T) {
+ at := time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
+ b, err := json.Marshal(ConsoleRecord{V: 1, Protocol: ProtocolVersion, SecretsKeyFP: "fp", At: at})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := `{"v":1,"protocol":1,"secrets_key_fp":"fp","at":"2026-10-07T12:00:00Z"}`; string(b) != want {
+ t.Fatalf("console record = %s, want %s", b, want)
+ }
+ if ConsoleKey != "control/moves-console.json" {
+ t.Fatalf("ConsoleKey = %q", ConsoleKey)
+ }
+ if FloorFile != ".moves/floor.json" || FloorMinReleaseFile != ".moves/floor.min-release" {
+ t.Fatalf("floor files = %q, %q", FloorFile, FloorMinReleaseFile)
+ }
+ if PrevVersionFile != "carlos.prev.version" || PrevAllowedFile != "carlos.prev.allowed" {
+ t.Fatalf("prev files = %q, %q", PrevVersionFile, PrevAllowedFile)
+ }
+}