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>
18 files changed,
+1652
−11
cmd/console/main.go+3 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+7 −1internal/console/movesconsole_test.go+69 −0internal/console/server.go+26 −0internal/host/agent.go+9 −0internal/host/edgesfile.go+23 −0internal/host/edgesfile_test.go+71 −4internal/host/host_test.go+92 −2internal/host/movesfloor.go+377 −0internal/host/movesfloor_linux.go+101 −0internal/host/movesfloor_other.go+12 −0internal/host/movesfloor_test.go+253 −0internal/host/systemapply.go+193 −2internal/host/systemapply_test.go+329 −0internal/host/units/carlos-system-recover.service+13 −1internal/host/units/carlos-system-update.service+11 −1internal/moves/floor.go+38 −0internal/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) |
| + } |
| +} |