host: the self-update relay, apply and recover units, and a JSON arming plan
bootstrap -root <dir> -plan-json prints this binary's enabledUnits and inert list, so an apply arms what the NEW binary's bootstrap would arm. Both oneshots exec carlos.prev when a journal is open.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
6 files changed,
+232
−10
internal/host/bootstrap.go+54 −9internal/host/host_test.go+73 −1internal/host/probes.go+14 −0internal/host/units/carlos-system-recover.service+36 −0internal/host/units/carlos-system-update.path+13 −0internal/host/units/carlos-system-update.service+42 −0
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index a34b66e08..7834b2553 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -13,8 +13,10 @@ import ( |
| "github.com/carlosframework/platform/internal/cliflag" |
| "embed" |
| + "encoding/json" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "os" |
| "os/exec" |
| @@ -60,16 +62,40 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" |
| // enabledUnits are what a live bootstrap turns on: the always-on front door, |
| -// the hourly adoption timer, and the three path units that let the sandboxed |
| +// the hourly adoption timer, and the path units that let the sandboxed |
| // agent ask a root pass for the halves it cannot do itself: carlos-adopt.path |
| // (config fast convergence, issue #44), carlos-stage.path (release staging |
| -// — the agent sees a promotion in seconds but cannot write /opt) and |
| +// — the agent sees a promotion in seconds but cannot write /opt), |
| // carlos-restart.path (a member's `carlos restart`, issue #113 — the agent |
| -// bounces its own exec children but cannot cycle a unit). The litestream + restore-verify units install |
| +// bounces its own exec children but cannot cycle a unit) and |
| +// carlos-system-update.path (the platform self-update relay, spec §4 — the |
| +// agent can fetch and verify a candidate but cannot write /opt or /etc). |
| +// carlos-system-recover.service is enabled (not armed by a path — it also |
| +// runs Before=carlos-edge.service on every boot, and on the update oneshot's |
| +// own OnFailure=) so recovery from an interrupted self-update never depends |
| +// on a marker file surviving a crash. The litestream + restore-verify units install |
| // everywhere but stay inert until an operator stages /data/litestream.env and |
| // generates /etc/litestream.yml (`carlos ops litestream`) — a box with no |
| // replication bucket is unaffected. |
| -var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path"} |
| +var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path", |
| + "carlos-system-update.path", "carlos-system-recover.service"} |
| + |
| +// inertUnits are installed everywhere and deliberately never armed by |
| +// bootstrap or by a self-update apply: restore-verify until an operator |
| +// stages litestream, hibernate-verify by long-standing choice. |
| +var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verify.timer"} |
| + |
| +// bootstrapPlan is what `bootstrap -root <dir> -plan-json` prints: the |
| +// arming list of THIS binary, so a self-update apply arms what the new |
| +// binary's bootstrap would arm and never a list an older binary remembers. |
| +type bootstrapPlan struct { |
| + Enabled []string `json:"enabled"` |
| + Inert []string `json:"inert"` |
| +} |
| + |
| +func currentBootstrapPlan() bootstrapPlan { |
| + return bootstrapPlan{Enabled: append([]string(nil), enabledUnits...), Inert: append([]string(nil), inertUnits...)} |
| +} |
| // systemctlPlan is the exact systemctl sequence a live bootstrap runs — and |
| // the same sequence a staging run (-root <dir>) PRINTS instead of running, so |
| @@ -116,17 +142,21 @@ func RunBootstrap(args []string) { |
| fs := flag.NewFlagSet("bootstrap", flag.ExitOnError) |
| root := fs.String("root", "/", "install root; a staging dir writes files only (no user creation, no systemctl)") |
| offcloud := fs.Bool("offcloud", false, "off-cloud box: require a staged 0600 credentials file instead of an EC2 instance profile") |
| + planJSON := fs.Bool("plan-json", false, "with -root: print the arming plan as JSON on stdout instead of the human summary") |
| var consumers repeatableFlag |
| fs.Var(&consumers, "socket-consumer", "host-app user to enroll in the "+socketsGroup+" group (repeatable); members may dial every instance socket on the box") |
| fs.Usage = cliflag.Usage(fs, "") |
| _ = fs.Parse(args) |
| + if *planJSON && filepath.Clean(*root) == "/" { |
| + log.Fatal("bootstrap: -plan-json is for a staging root") |
| + } |
| if *offcloud { |
| if err := bootstrapOffcloud(*root, consumers); err != nil { |
| log.Fatalf("bootstrap: %v", err) |
| } |
| return |
| } |
| - if err := bootstrap(*root, consumers); err != nil { |
| + if err := bootstrapWith(*root, consumers, *planJSON); err != nil { |
| log.Fatalf("bootstrap: %v", err) |
| } |
| } |
| @@ -179,7 +209,19 @@ func bootstrapOffcloud(root string, consumers []string) error { |
| // A non-"/" root is the staging/test path: files only, with the privileged |
| // steps printed instead of run. |
| func bootstrap(root string, consumers []string) error { |
| + return bootstrapWith(root, consumers, false) |
| +} |
| + |
| +// bootstrapWith is bootstrap's body, plus the -plan-json staging path: when |
| +// planJSON is set (staging root only — RunBootstrap refuses it with -root /), |
| +// every "wrote"/"created" line below is redirected to stderr so the JSON |
| +// plan currentBootstrapPlan() prints is alone on stdout. |
| +func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| live := filepath.Clean(root) == "/" |
| + out := io.Writer(os.Stdout) |
| + if planJSON { |
| + out = os.Stderr |
| + } |
| if live { |
| // Validate consumer users BEFORE any mutation (the offcloud |
| // precondition's discipline): a typo in -socket-consumer must leave |
| @@ -270,7 +312,7 @@ func bootstrap(root string, consumers []string) error { |
| if err := f.Close(); err != nil { |
| return fmt.Errorf("create %s: %v", regPath, err) |
| } |
| - fmt.Printf(" created %s\n", regPath) |
| + fmt.Fprintf(out, " created %s\n", regPath) |
| } |
| names, err := unitNames() |
| @@ -286,7 +328,7 @@ func bootstrap(root string, consumers []string) error { |
| if err := os.WriteFile(path, content, 0o644); err != nil { |
| return fmt.Errorf("write %s: %v", path, err) |
| } |
| - fmt.Printf(" wrote %s\n", path) |
| + fmt.Fprintf(out, " wrote %s\n", path) |
| } |
| // On a live root the privileged plan runs BEFORE the tmpfiles conf is |
| @@ -316,15 +358,18 @@ func bootstrap(root string, consumers []string) error { |
| if err := os.WriteFile(sysusersPath, []byte("g "+socketsGroup+" -\n"), 0o644); err != nil { |
| return fmt.Errorf("write %s: %v", sysusersPath, err) |
| } |
| - fmt.Printf(" wrote %s\n", sysusersPath) |
| + fmt.Fprintf(out, " wrote %s\n", sysusersPath) |
| tmpfilesPath := filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf") |
| if err := os.WriteFile(tmpfilesPath, []byte(tmpfilesConf), 0o644); err != nil { |
| return fmt.Errorf("write %s: %v", tmpfilesPath, err) |
| } |
| - fmt.Printf(" wrote %s\n", tmpfilesPath) |
| + fmt.Fprintf(out, " wrote %s\n", tmpfilesPath) |
| if !live { |
| + if planJSON { |
| + return json.NewEncoder(os.Stdout).Encode(currentBootstrapPlan()) |
| + } |
| fmt.Printf("bootstrap: staged under %s — on a real box (--root /) it would also:\n", root) |
| // Printed, never run: a staging root is how a LIVE box refreshes its |
| // unit files (pinfra's update-platform-binary.sh stages into |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 77a958504..d954032d5 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -43,6 +43,9 @@ var expectedUnits = []string{ |
| "carlos-restore-verify.timer", |
| "carlos-stage.path", |
| "carlos-stage.service", |
| + "carlos-system-recover.service", |
| + "carlos-system-update.path", |
| + "carlos-system-update.service", |
| } |
| // requireUnixSockets skips a test when the sandbox forbids unix-socket |
| @@ -1127,7 +1130,7 @@ func TestParseLoad1(t *testing.T) { |
| // confident false — so a typo here would report every box in the fleet as |
| // broken, and a renamed unit would report a live box as dead. |
| func TestProbedUnitsAreTheOnesBootstrapArms(t *testing.T) { |
| - for _, unit := range []string{adoptTimerUnit, configPathUnit} { |
| + for _, unit := range []string{adoptTimerUnit, configPathUnit, systemUpdatePathUnit} { |
| if !slices.Contains(enabledUnits, unit) { |
| t.Errorf("%s: probed but not in enabledUnits — bootstrap never arms it", unit) |
| } |
| @@ -1137,6 +1140,75 @@ func TestProbedUnitsAreTheOnesBootstrapArms(t *testing.T) { |
| } |
| } |
| +func TestBootstrapPlanJSONNamesWhatALiveRunArms(t *testing.T) { |
| + p := currentBootstrapPlan() |
| + want := map[string]bool{"carlos-edge.service": true, "carlos-adopt.timer": true, "carlos-adopt.path": true, |
| + "carlos-stage.path": true, "carlos-restart.path": true, "carlos-system-update.path": true, "carlos-system-recover.service": true} |
| + got := map[string]bool{} |
| + for _, u := range p.Enabled { |
| + got[u] = true |
| + } |
| + for u := range want { |
| + if !got[u] { |
| + t.Errorf("plan lacks %s", u) |
| + } |
| + } |
| + if got["carlos-system-update.service"] { |
| + t.Error("the path-triggered oneshot must not be enabled: it is started by its .path") |
| + } |
| + inert := map[string]bool{} |
| + for _, u := range p.Inert { |
| + inert[u] = true |
| + } |
| + if !inert["carlos-restore-verify.timer"] || !inert["carlos-hibernate-verify.timer"] { |
| + t.Errorf("inert = %v", p.Inert) |
| + } |
| + // And systemctlPlan never starts a service, whatever is enabled. |
| + for _, args := range systemctlPlan(p.Enabled) { |
| + if args[0] == "start" { |
| + for _, u := range args[1:] { |
| + if strings.HasSuffix(u, ".service") { |
| + t.Errorf("plan starts a service: %v", args) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestSystemUnitsAreShapedLikeTheStageRelay(t *testing.T) { |
| + read := func(name string) string { |
| + b, err := unitsFS.ReadFile("units/" + name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| + } |
| + p := read("carlos-system-update.path") |
| + for _, want := range []string{"PathChanged=/data/system-update-requested", "Unit=carlos-system-update.service", "WantedBy=multi-user.target"} { |
| + if !strings.Contains(p, want) { |
| + t.Errorf("path unit lacks %q", want) |
| + } |
| + } |
| + 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"} { |
| + if !strings.Contains(u, want) { |
| + t.Errorf("update unit lacks %q", want) |
| + } |
| + } |
| + for _, never := range []string{"ProtectSystem", "User="} { |
| + if strings.Contains(u, never) { |
| + t.Errorf("update unit must run as root with /opt writable; found %q", never) |
| + } |
| + } |
| + 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"} { |
| + if !strings.Contains(r, want) { |
| + t.Errorf("recover unit lacks %q", want) |
| + } |
| + } |
| +} |
| + |
| // TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the |
| // monitoring spec commits to: operator-grade needles live under metrics/ and |
| // alerts/, and nothing the public status publisher writes may ever be |
diff --git a/internal/host/probes.go b/internal/host/probes.go| index 481f376ee..c17681403 100644 |
| --- a/internal/host/probes.go |
| +++ b/internal/host/probes.go |
| @@ -83,6 +83,20 @@ func restartPathActive() *bool { |
| return parseIsActive(string(out)) |
| } |
| +// systemUpdatePathUnit is the unit whose liveness IS "an agent's fetched |
| +// platform candidate reaches a root apply" (spec §4): the path unit watching |
| +// /data/system-update-requested. Installed and armed by bootstrap alongside |
| +// the other path units (see enabledUnits). |
| +const systemUpdatePathUnit = "carlos-system-update.path" |
| + |
| +// systemUpdatePathActive asks the same question restartPathActive asks, about |
| +// the self-update relay: an unarmed path here means an agent that fetched and |
| +// verified a new build touches its marker and nothing ever picks it up. |
| +func systemUpdatePathActive() *bool { |
| + out, _ := exec.Command("systemctl", "is-active", systemUpdatePathUnit).Output() |
| + return parseIsActive(string(out)) |
| +} |
| + |
| // parseIsActive maps systemd's is-active vocabulary onto the tri-state the |
| // status page publishes. "unknown" — systemd's answer when it has no such unit |
| // loaded — is a genuine false: a box with no adoption timer adopts nothing. |
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service| new file mode 100644 |
| index 000000000..8e74c0e20 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-system-recover.service |
| @@ -0,0 +1,36 @@ |
| +# Recovery of an interrupted platform self-update (spec §6, "every run |
| +# begins with recovery"). Runs once per boot, BEFORE the edge, and on the |
| +# update oneshot's failure. It restores files only — carlos.prev over |
| +# carlos, the previous unit set — and never restarts or gates the edge |
| +# 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. |
| +[Unit] |
| +Description=carlos platform self-update recovery |
| +Before=carlos-edge.service |
| +DefaultDependencies=yes |
| + |
| +[Service] |
| +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' |
| + |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +TasksMax=64 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-system-update.path b/internal/host/units/carlos-system-update.path| new file mode 100644 |
| index 000000000..ed24c2d8b |
| --- /dev/null |
| +++ b/internal/host/units/carlos-system-update.path |
| @@ -0,0 +1,13 @@ |
| +# The relay from the sandboxed agent to a root apply of a new platform |
| +# binary (spec §4). The agent (User=carlos, ProtectSystem=strict) can fetch |
| +# and verify the artifact but cannot write /opt or /etc; it touches this |
| +# marker and root does the rest. Same shape as carlos-stage.path. |
| +[Unit] |
| +Description=CARLOS platform self-update on agent request |
| + |
| +[Path] |
| +PathChanged=/data/system-update-requested |
| +Unit=carlos-system-update.service |
| + |
| +[Install] |
| +WantedBy=multi-user.target |
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service| new file mode 100644 |
| index 000000000..a2271f826 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-system-update.service |
| @@ -0,0 +1,42 @@ |
| +# Root apply of a platform self-update (spec §6): re-verify the pointer, |
| +# copy the candidate into a root-owned dir, journal, swap the binary and |
| +# units, restart the edge, gate it, roll back to carlos.prev on failure. |
| +# |
| +# ExecStart goes through one line of shell so that when a journal is open |
| +# (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. |
| +# |
| +# 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 |
| +# bursts. TimeoutStartSec bounds a hung apply; OnFailure= makes a killed or |
| +# timed-out apply recover without waiting for a touch. |
| +[Unit] |
| +Description=carlos platform self-update (root apply) |
| +After=network-online.target |
| +Wants=network-online.target |
| +StartLimitIntervalSec=0 |
| +OnFailure=carlos-system-recover.service |
| + |
| +[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' |
| + |
| +# --- hardening (the carlos-stage.service set) --- |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=256M |
| +TasksMax=64 |