relay: the unit, bootstrap arming and the adopt restart (spec §3)
carlos-relay.service is installed everywhere and, unlike every other service, STARTED by bootstrap. That is safe because the unit carries ConditionPathExists=/etc/carlos/relay-masters: on a box the operator has not armed with a region master the start is a no-op systemd records as a failed condition, so the fleet-wide arming costs nothing and an armed box does not wait for a reboot to listen. The unit is the edge's hardening cut down — no CAP_NET_BIND_SERVICE (3478 is unprivileged), ReadWritePaths=/data/relay /run/carlos, MemoryMax=256M, MemoryHigh=160M and OOMScoreAdjust=1000, because the relay shares the box with the edge and must lose first. A platform update restarts it beside the edge, with try-restart rather than restart and the error logged rather than returned: an unarmed box's inactive unit must not fail an update that is otherwise fine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 files changed,
+127
−13
internal/host/bootstrap.go+28 −9internal/host/host_test.go+31 −4internal/host/systemapply.go+18 −0internal/host/units/carlos-relay.service+50 −0
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 7834b2553..e7e907956 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -76,8 +76,16 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| // 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", |
| +// replication bucket is unaffected. carlos-relay.service (relay spec §3) is |
| +// enabled AND started fleet-wide, unlike the edge: its |
| +// ConditionPathExists=/etc/carlos/relay-masters makes the start a no-op on |
| +// every box the operator has not armed with a region master. |
| +// |
| +// relayUnitName is spelled once because two places must agree on it: this |
| +// list and the systemctl plan's start set. |
| +const relayUnitName = "carlos-relay.service" |
| + |
| +var enabledUnits = []string{"carlos-edge.service", relayUnitName, "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 |
| @@ -113,12 +121,19 @@ func currentBootstrapPlan() bootstrapPlan { |
| // already-running timer or path unit is a no-op, which keeps bootstrap |
| // idempotent. |
| // |
| -// Services are deliberately NOT started. carlos-edge needs its binary at |
| -// /opt/carlos/carlos and /etc/carlos/host.env staged first — neither of which |
| -// bootstrap can do for the operator, which is why it ends by telling them to. |
| -// Starting timers is safe in a way starting services is not: a timer that |
| -// fires before the binary lands just fails one adoption pass and tries again |
| -// on the next tick. |
| +// Services are deliberately NOT started, with one exception. carlos-edge |
| +// needs its binary at /opt/carlos/carlos and /etc/carlos/host.env staged |
| +// first — neither of which bootstrap can do for the operator, which is why |
| +// it ends by telling them to. Starting timers is safe in a way starting |
| +// services is not: a timer that fires before the binary lands just fails one |
| +// adoption pass and tries again on the next tick. |
| +// |
| +// carlos-relay.service is the exception (relay spec §3). It carries |
| +// ConditionPathExists=/etc/carlos/relay-masters, so on a box the operator |
| +// has not armed with a region master the start is a no-op systemd records as |
| +// a failed condition, not a failure — which is what makes starting it safe |
| +// fleet-wide. On a box that IS armed, starting it here is what saves a |
| +// reboot. |
| func systemctlPlan(units []string) [][]string { |
| plan := [][]string{ |
| {"daemon-reload"}, |
| @@ -126,7 +141,7 @@ func systemctlPlan(units []string) [][]string { |
| } |
| var armed []string |
| for _, u := range units { |
| - if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") { |
| + if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || u == relayUnitName { |
| armed = append(armed, u) |
| } |
| } |
| @@ -244,6 +259,10 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| dirs := []dir{ |
| {dataDir, 0o750}, |
| {filepath.Join(dataDir, "acme"), 0o750}, |
| + // The relay's one writable state dir (carlos-relay.service's |
| + // ReadWritePaths). Created on every box, armed or not, so the unit |
| + // never fails for want of a directory the moment a master lands. |
| + {filepath.Join(dataDir, "relay"), 0o750}, |
| // Setgid: sockets bound inside inherit the dir's group — the |
| // consumers group, once a live run (or the tmpfiles line) sets it. |
| // See socketsGroup/tmpfilesConf. |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index d954032d5..2fd411efa 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -37,6 +37,7 @@ var expectedUnits = []string{ |
| "carlos-hibernate-verify.service", |
| "carlos-hibernate-verify.timer", |
| "carlos-litestream.service", |
| + "carlos-relay.service", |
| "carlos-restart.path", |
| "carlos-restart.service", |
| "carlos-restore-verify.service", |
| @@ -83,6 +84,27 @@ func TestUnitsEmbedComplete(t *testing.T) { |
| } |
| } |
| +// TestBootstrapStartsRelayUnit pins the one service bootstrap starts and the |
| +// one it must not. The relay is safe to start fleet-wide because its |
| +// ConditionPathExists gates it on a masters file; the edge is not, and a |
| +// change that quietly promoted every service into the start line would arm |
| +// half-staged boxes. |
| +func TestBootstrapStartsRelayUnit(t *testing.T) { |
| + plan := systemctlPlan(enabledUnits) |
| + var started []string |
| + for _, step := range plan { |
| + if step[0] == "start" { |
| + started = step[1:] |
| + } |
| + } |
| + if !slices.Contains(started, "carlos-relay.service") { |
| + t.Fatalf("relay not started by plan: %v", plan) |
| + } |
| + if slices.Contains(started, "carlos-edge.service") { |
| + t.Fatalf("edge must stay start-by-hand: %v", plan) |
| + } |
| +} |
| + |
| func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { |
| root := t.TempDir() |
| if err := bootstrap(root, nil); err != nil { |
| @@ -889,7 +911,10 @@ func TestIsCanaryRoute(t *testing.T) { |
| // deploy quietly needed a hand-run `systemctl start carlos-adopt.service`. |
| // Every timer bootstrap enables must therefore also be started, and no |
| // SERVICE may be (carlos-edge needs its binary and host.env staged first — |
| -// bootstrap's own closing line tells the operator to start it). |
| +// bootstrap's own closing line tells the operator to start it). The one |
| +// exemption is carlos-relay.service, whose ConditionPathExists makes the |
| +// start a no-op on any box the operator has not armed with a region master |
| +// (relay spec §3); TestBootstrapStartsRelayUnit pins that it IS started. |
| // |
| // PATH units are the same trap with the same fix: an enabled-but-unstarted |
| // carlos-adopt.path watches nothing until the next reboot, so the agent's |
| @@ -920,7 +945,7 @@ func TestSystemctlPlanArmsTimers(t *testing.T) { |
| if (strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path")) && !started[u] { |
| t.Errorf("%s: enabled but never started — that is exactly the bug (enable only symlinks; the unit arms at the next boot)", u) |
| } |
| - if strings.HasSuffix(u, ".service") && started[u] { |
| + if strings.HasSuffix(u, ".service") && u != relayUnitName && started[u] { |
| t.Errorf("%s: bootstrap must not start a service — the binary and /etc/carlos/host.env may not be staged yet", u) |
| } |
| } |
| @@ -1163,11 +1188,13 @@ func TestBootstrapPlanJSONNamesWhatALiveRunArms(t *testing.T) { |
| 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. |
| + // And systemctlPlan starts no service but the relay, whatever is |
| + // enabled — the relay is safe because its ConditionPathExists gates it |
| + // on a masters file the operator stages (relay spec §3). |
| for _, args := range systemctlPlan(p.Enabled) { |
| if args[0] == "start" { |
| for _, u := range args[1:] { |
| - if strings.HasSuffix(u, ".service") { |
| + if strings.HasSuffix(u, ".service") && u != relayUnitName { |
| t.Errorf("plan starts a service: %v", args) |
| } |
| } |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index f37f4f537..2cb0605ba 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -31,6 +31,7 @@ import ( |
| const ( |
| systemEdgeUnit = "carlos-edge" |
| + systemRelayUnit = "carlos-relay" |
| systemLockFile = "lock" |
| systemMaxRequest = 4 << 10 |
| systemGateWait = 20 * time.Second |
| @@ -382,6 +383,7 @@ func (a *systemApplier) install(ctx context.Context, req systemRequest, p *relea |
| if err := a.restartEdge(); err != nil { |
| return fail("restart", err) |
| } |
| + a.restartRelay() |
| j.Phase = systemPhaseRestarted |
| if err := a.journal(j); err != nil { |
| return fail("journal", err) |
| @@ -571,6 +573,21 @@ func (a *systemApplier) restartEdge() error { |
| return err |
| } |
| +// restartRelay mirrors restartEdge for the relay (relay spec §3), with one |
| +// difference that decides its shape: the relay is optional. A box with no |
| +// /etc/carlos/relay-masters has the unit installed but inactive, because its |
| +// ConditionPathExists failed, and `try-restart` on it is a no-op that may |
| +// still report an error. That is not a failure of the update — the edge, the |
| +// binary and the gate are all fine — so the error is logged and swallowed, |
| +// and try-restart (not restart) is what keeps an unarmed box from being |
| +// handed a relay it never asked for. |
| +func (a *systemApplier) restartRelay() { |
| + a.ops.systemctl("reset-failed", systemRelayUnit) |
| + if _, err := a.ops.systemctl("try-restart", systemRelayUnit); err != nil { |
| + a.ops.logf("system apply: relay try-restart: %v", err) |
| + } |
| +} |
| + |
| // gate: within gateWait the edge is active, answers on loopback, and the |
| // installed binary reports the target; then a soak across which NRestarts |
| // stays 0 — step 9's reset-failed zeroed it, so anything above 0 is a |
| @@ -692,6 +709,7 @@ func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason |
| if err := a.restartEdge(); err != nil { |
| return failed("restart", err) // journal kept: the edge is still to come back |
| } |
| + a.restartRelay() |
| if err := a.gate(ctx, j.PrevBuild); err != nil { |
| err := failed("previous build failed its gate too", err) |
| a.removeJournal() |
diff --git a/internal/host/units/carlos-relay.service b/internal/host/units/carlos-relay.service| new file mode 100644 |
| index 000000000..dc03507b6 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-relay.service |
| @@ -0,0 +1,50 @@ |
| +# The platform STUN/TURN relay (media roadmap stage 4a). Armed only on a |
| +# box that carries /etc/carlos/relay-masters; ConditionPathExists keeps a |
| +# box without a master from ever listening. Shares the box with the edge and |
| +# must lose first: bounded memory, PSI-driven shedding in the process, and |
| +# the highest OOM adjustment there is. |
| +[Unit] |
| +Description=carlos relay (STUN and TURN) |
| +After=network-online.target |
| +Wants=network-online.target |
| +ConditionPathExists=/etc/carlos/relay-masters |
| + |
| +[Service] |
| +EnvironmentFile=-/etc/carlos/host.env |
| +ExecStart=/opt/carlos/carlos relay serve |
| +Restart=always |
| +RestartSec=1 |
| + |
| +User=carlos |
| +Group=carlos |
| +NoNewPrivileges=yes |
| +ProtectSystem=strict |
| +ProtectHome=yes |
| +ReadWritePaths=/data/relay /run/carlos |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +RestrictNamespaces=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +LockPersonality=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallFilter=@system-service |
| +SystemCallErrorNumber=EPERM |
| +SystemCallArchitectures=native |
| +MemoryHigh=160M |
| +MemoryMax=256M |
| +OOMScoreAdjust=1000 |
| +TasksMax=512 |
| +Nice=5 |
| +CPUWeight=50 |
| + |
| +[Install] |
| +WantedBy=multi-user.target |