units and verbs: the instance templates, the probe, the control socket and service, and their docs (plan 2, task 6)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
13 files changed,
+656
−8
docs/site/cli.md+33 −0internal/host/bootstrap.go+14 −1internal/host/host_test.go+6 −0internal/host/systemunits.go+17 −5internal/host/systemunits_test.go+4 −2internal/host/units/carlos-instance-control.service+32 −0internal/host/units/carlos-instance-control.socket+18 −0internal/host/units/carlos-instance-probe.service+71 −0internal/host/units/carlos-instance-seed@.service+99 −0internal/host/units/carlos-instance-sidecar@.service+99 −0internal/host/units/carlos-instance@.service+99 −0internal/host/units_isolation_test.go+154 −0main.go+10 −0
CI passed — run details
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 393fe19d9..0963b364d 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1288,6 +1288,39 @@ build the process is not serving. |
| root-side unit to run for it, since the agent can request a pass but cannot |
| write to `/etc` or restart units itself. |
| +### carlos instance-control |
| + |
| +The root half of isolated exec. `carlos-instance-control.socket` activates |
| +it when the agent connects; it starts, stops and sweeps the |
| +`carlos-instance*@` units for the agent, sets each host's memory tier on |
| +its slice before a start, and runs the isolation probe at start and every |
| +five minutes. It accepts a connection only from the edge unit's own main |
| +process. Not for operators: there is nothing to type. |
| + |
| +### carlos instance-exec |
| + |
| +The first thing an isolated instance unit runs. It reads the launch file |
| +the agent wrote into the instance's directory and execs the host's live |
| +link with exactly the arguments and environment in it, refusing anything |
| +that is not the platform's own live link. `ExecStart=` of the three |
| +instance templates. |
| + |
| +### carlos instance-exit |
| + |
| +`ExecStopPost=` of the three instance templates. It leaves an advisory |
| +exit record in the instance's run directory from the four variables |
| +systemd provides; the agent proves termination from the unit's state and |
| +its cgroup, not from this file. |
| + |
| +### carlos instance-probe |
| + |
| +The isolation probe, run under the same namespace text as the instance |
| +templates. From inside it checks what a tenant would see: only its own |
| +directories, only its own pid, a read-only cgroupfs, no `/etc/carlos`, a |
| +symlink that is not followed, and a metadata service that a packet filter |
| +refuses. It prints a verdict; `carlos-instance-control` reads it and |
| +refuses to start isolated instances until every line passes. |
| + |
| ### carlos ops |
| Box operations, run by a timer or by an operator standing on the box. |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 2b7435ab3..39aead0ff 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -76,6 +76,11 @@ const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + |
| "d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| + // The readiness probe's own instance directory (design §8.5): a name |
| + // no host can have, created every boot so the probe unit's bind |
| + // sources exist before it starts. |
| + "d /data/.instances/_probe 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /run/carlos/.instances/_probe 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| "d /run/carlos/.logs 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| "d /run/carlos/logs 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| // The root instance-control channel's directory (design §5.2): root's, |
| @@ -143,7 +148,10 @@ 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", |
| // The isolated-exec mounts (design §3.2): the edge Requires= them. |
| - "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} |
| + "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", |
| + // The root instance-control channel (design §5.2); socket-activated, so |
| + // only the socket is enabled and started. |
| + "carlos-instance-control.socket"} |
| // inertUnits are installed everywhere and deliberately never armed by |
| // bootstrap or by a self-update apply: restore-verify until an operator |
| @@ -353,6 +361,11 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| {filepath.Join(dataDir, "layout"), 0o700}, |
| {filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid}, |
| {filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid}, |
| + // The readiness probe's own instance directory (design §8.5): a |
| + // name no host can have, created here so a staging root has it |
| + // too (see tmpfilesConf for the modes and why both exist). |
| + {filepath.Join(dataDir, ".instances", "_probe"), 0o700}, |
| + {filepath.Join(runDir, ".instances", "_probe"), 0o750 | os.ModeSetgid}, |
| {filepath.Join(runDir, ".logs"), 0o700}, |
| {filepath.Join(runDir, "logs"), 0o700}, |
| // Root-owned on a live box (privilegedPlan chowns /run/carlos |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 37558c533..09ad50cc6 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -36,6 +36,12 @@ var expectedUnits = []string{ |
| "carlos-edge.service", |
| "carlos-hibernate-verify.service", |
| "carlos-hibernate-verify.timer", |
| + "carlos-instance-control.service", |
| + "carlos-instance-control.socket", |
| + "carlos-instance-probe.service", |
| + "carlos-instance-seed@.service", |
| + "carlos-instance-sidecar@.service", |
| + "carlos-instance@.service", |
| "carlos-instances.slice", |
| "carlos-litestream.service", |
| "carlos-relay.service", |
diff --git a/internal/host/systemunits.go b/internal/host/systemunits.go| index c84e70561..4c5c41d7f 100644 |
| --- a/internal/host/systemunits.go |
| +++ b/internal/host/systemunits.go |
| @@ -11,7 +11,14 @@ import ( |
| // rather than repeated as a string literal at each call site below. |
| const systemRecoverUnit = "carlos-system-recover.service" |
| -var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit} |
| +// instanceControlSocket is the root instance-control channel's socket |
| +// (design §5.2). ensureSystemUnits arms it the same way it arms the |
| +// self-update units: this pass is the second belt for any unit a roll |
| +// script forgot, and a box with the socket unarmed has no isolated-exec |
| +// control channel at all. |
| +const instanceControlSocket = "carlos-instance-control.socket" |
| + |
| +var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", systemRecoverUnit, instanceControlSocket, "carlos-instance-control.service"} |
| // ensureSystemUnits is the second belt of spec §7: a box the roll script |
| // forgot to arm is armed within the hour by the root adopt pass. It |
| @@ -61,6 +68,10 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st |
| // with no systemctl at all, so the printed word is the answer and the |
| // exit status is ignored, exactly as probes.go has always done it. |
| active, activeErr := probe("is-active", systemUpdatePathUnit) |
| + // The control socket's own is-active check, alongside the self-update |
| + // path unit's: the same silent-systemctl-vs-unarmed-unit question, one |
| + // more unit wide (design §5.2). |
| + ctlActiveOut, ctlActiveErr := probe("is-active", instanceControlSocket) |
| // The is-enabled error is dropped on purpose: its cases are the words |
| // parseIsEnabled reads, and "no such unit file" is a perfectly good |
| // "not enabled" on the box we are here to fix. |
| @@ -70,8 +81,9 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st |
| // of, so no word at all means the question could not be asked — no |
| // binary, or an exec that failed outright. |
| isActive := parseIsActive(active) |
| - if isActive == nil { |
| - logf("adopt: system units: systemctl said nothing; not touching the self-update units: %v", activeErr) |
| + ctlActive := parseIsActive(ctlActiveOut) |
| + if isActive == nil || ctlActive == nil { |
| + logf("adopt: system units: systemctl said nothing; not touching the self-update units: %v / %v", activeErr, ctlActiveErr) |
| return |
| } |
| isEnabled := parseIsEnabled(enabled) |
| @@ -81,7 +93,7 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st |
| missing = true |
| } |
| } |
| - if !missing && *isActive && isEnabled != nil && *isEnabled { |
| + if !missing && *isActive && isEnabled != nil && *isEnabled && *ctlActive { |
| return |
| } |
| if err := os.MkdirAll(unitDir, 0o755); err != nil { |
| @@ -99,7 +111,7 @@ func ensureSystemUnits(etcDir, lockPath string, systemctl, probe func(args ...st |
| return |
| } |
| } |
| - for _, args := range systemctlPlan([]string{systemUpdatePathUnit, systemRecoverUnit}) { |
| + for _, args := range systemctlPlan([]string{systemUpdatePathUnit, systemRecoverUnit, instanceControlSocket}) { |
| if _, err := systemctl(args...); err != nil { |
| logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err) |
| return |
diff --git a/internal/host/systemunits_test.go b/internal/host/systemunits_test.go| index 4947d965d..eb003b870 100644 |
| --- a/internal/host/systemunits_test.go |
| +++ b/internal/host/systemunits_test.go |
| @@ -51,7 +51,9 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| if strings.Contains(joined, "start carlos-system-recover.service") || strings.Contains(joined, "start carlos-system-update.service") { |
| t.Fatal("a service was started") |
| } |
| - // All present and armed: no writes, no systemctl beyond the two checks. |
| + // All present and armed: no writes, no systemctl beyond the three checks |
| + // (is-active x2 — the self-update path unit and the control socket — |
| + // plus is-enabled). |
| calls = nil |
| probe2 := func(args ...string) (string, error) { |
| calls = append(calls, strings.Join(args, " ")) |
| @@ -64,7 +66,7 @@ func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) { |
| return "", nil |
| } |
| ensureSystemUnits(etc, lockPath, systemctl, probe2, t.Logf) |
| - if len(calls) != 2 { |
| + if len(calls) != 3 { |
| t.Fatalf("an armed box got %v", calls) |
| } |
| } |
diff --git a/internal/host/units/carlos-instance-control.service b/internal/host/units/carlos-instance-control.service| new file mode 100644 |
| index 000000000..364de03cb |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance-control.service |
| @@ -0,0 +1,32 @@ |
| +# The root instance-control service (isolated-exec design §5). Socket- |
| +# activated, so it runs only while there is work. Its whole job is |
| +# systemctl start/stop/set-property on the three instance templates' units |
| +# and their host slices, on the agent's request, plus running the readiness |
| +# probe. Every unit name it passes to systemctl is built from a validated |
| +# host by the platform's own naming; nothing from a request reaches |
| +# systemctl as a string. |
| +# |
| +# Runs as ROOT deliberately, with no new-privileges lockout and no syscall |
| +# filter, for the systemctl child (the same rationale as |
| +# carlos-stage.service). It accepts a connection only from the edge unit's |
| +# main process (SO_PEERCRED uid carlos, pid == MainPID of |
| +# carlos-edge.service): a flat tenant is a child of that pid, never the pid |
| +# itself, and a nested tenant cannot reach the socket at all. |
| +[Unit] |
| +Description=carlos instance control (root: start/stop carlos-instance*@ units for the agent) |
| +Requires=carlos-instance-control.socket data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| +After=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| + |
| +[Service] |
| +ExecStart=/opt/carlos/carlos instance-control |
| +ProtectSystem=strict |
| +ReadWritePaths=/run/carlos-control |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +RestrictAddressFamilies=AF_UNIX |
| +MemoryDenyWriteExecute=yes |
| +MemoryMax=64M |
diff --git a/internal/host/units/carlos-instance-control.socket b/internal/host/units/carlos-instance-control.socket| new file mode 100644 |
| index 000000000..1835c41a0 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance-control.socket |
| @@ -0,0 +1,18 @@ |
| +# The root instance-control channel's socket (isolated-exec design §5.2). |
| +# In /run/carlos-control, a root-owned directory beside /run/carlos, never |
| +# under it: an instance's namespace replaces /run/carlos with an empty |
| +# tmpfs, and nothing an instance can reach must resolve to this path. Owned |
| +# by carlos so the agent can connect; the service authenticates the peer |
| +# process itself (§5.4). |
| +[Unit] |
| +Description=carlos instance control socket (agent to root) |
| + |
| +[Socket] |
| +ListenStream=/run/carlos-control/control.sock |
| +SocketUser=carlos |
| +SocketGroup=carlos |
| +SocketMode=0600 |
| +Accept=no |
| + |
| +[Install] |
| +WantedBy=sockets.target |
diff --git a/internal/host/units/carlos-instance-probe.service b/internal/host/units/carlos-instance-probe.service| new file mode 100644 |
| index 000000000..5ab462f10 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance-probe.service |
| @@ -0,0 +1,71 @@ |
| +# The readiness probe (isolated-exec design §8.5): a oneshot under the SAME |
| +# namespace text as the tenant templates (the test pins the block byte for |
| +# byte), run by carlos-instance-control at its start and every five |
| +# minutes. From inside it asserts what a tenant would see and prints a |
| +# verdict; the unit's StandardOutput=truncate: lands it where the control |
| +# service reads it. It exits 0 whatever the verdict: the verdict is the |
| +# output. Its instance directory is _probe, a name no host can have; the |
| +# tmpfiles lines create it. |
| +[Unit] |
| +Description=carlos instance isolation probe |
| +Requires=data-instances.mount run-carlos-instances.mount |
| +After=data-instances.mount run-carlos-instances.mount |
| + |
| +[Service] |
| +Type=oneshot |
| +ExecStart=/opt/carlos/carlos instance-probe |
| +User=carlos |
| +Group=carlos |
| +Slice=carlos-instances.slice |
| +StandardOutput=truncate:/run/carlos-control/probe.out |
| +StandardError=journal |
| +TimeoutStartSec=30 |
| +# The instance's own two directories, the only writable data paths. |
| +BindPaths=/data/instances/_probe /run/carlos/instances/_probe |
| +ReadWritePaths=/data/instances/_probe /run/carlos/instances/_probe |
| + |
| +# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) --- |
| +ProtectSystem=strict |
| +TemporaryFileSystem=/data:ro |
| +TemporaryFileSystem=/run/carlos:ro |
| +# Every other tree a carlos-uid unit writes on the box is hidden the same |
| +# way (codex R9-2): the restore drills' cache under /var/cache, and |
| +# /var/lib as a class. An instance needs nothing from either. |
| +TemporaryFileSystem=/var/cache:ro |
| +TemporaryFileSystem=/var/lib:ro |
| +# Root-written credentials a carlos-GROUP process may read live here: |
| +# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service, |
| +# and an instance's Group=carlos would read it too (codex R10-1). An |
| +# instance gets its configuration through its launch file and needs |
| +# nothing from /etc/carlos. |
| +InaccessiblePaths=/etc/carlos |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned, |
| +# and its cgroup.kill / cgroup.freeze would otherwise be one write away |
| +# for a same-uid tenant (codex R9-1). |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +ProcSubset=pid |
| +PrivatePIDs=yes |
| +RestrictNamespaces=yes |
| +NoNewPrivileges=yes |
| +CapabilityBoundingSet= |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +RemoveIPC=yes |
| +LockPersonality=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallArchitectures=native |
| + |
| +# --- per-service resources; the tier lives on the host slice (§4.3) --- |
| +TasksMax=64 |
| +MemoryMax=64M |
diff --git a/internal/host/units/carlos-instance-seed@.service b/internal/host/units/carlos-instance-seed@.service| new file mode 100644 |
| index 000000000..50a15df7e |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance-seed@.service |
| @@ -0,0 +1,99 @@ |
| +# One isolated exec-backed instance (isolated-exec design §4.1). The |
| +# activator owns it exactly as it owned the exec child this replaces: lease, |
| +# restore, seed, replication, hibernation, control socket, logs. What |
| +# changed is that the process is a unit, still uid carlos, inside a mount |
| +# namespace in which nothing but the instance's own two directories exists, |
| +# on mounts where no symlink is ever followed. The instance name is the |
| +# escaped bounded identifier (%i); paths use the unescaped one (%I). |
| +[Unit] |
| +Description=carlos exec-backed instance %I (seed) |
| +# Stop-only coupling: when the edge stops (including the stop half of a |
| +# restart) this unit stops, and nothing starts it again but the agent. |
| +# PartOf= would propagate the RESTART and race the agent's own startup |
| +# (codex R1-7). |
| +BindsTo=carlos-edge.service |
| +Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| +After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| + |
| +[Service] |
| +Type=oneshot |
| +RemainAfterExit=yes |
| +# After ExecStart exits the unit stays active (exited) or failed, loaded, |
| +# with its InvocationID, Result and ExecMainStatus retained until the |
| +# control service stops it, so the seed's result can never be garbage- |
| +# collected before it is read (codex R2-4). |
| +TimeoutStartSec=120 |
| +# Today's seed budget; a oneshot has no start timeout by default (codex R1-5). |
| +ExecStart=/opt/carlos/carlos instance-exec seed %I |
| +# Runs inside the same namespace, as carlos, after this invocation's |
| +# processes have been signalled: leaves an ADVISORY exit record in the |
| +# instance's own run directory (§6.5). Not "+": the "+" prefix drops User= |
| +# and the Private*/Protect* settings but keeps the explicit |
| +# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from |
| +# here can see the host's /run/carlos anyway. |
| +ExecStopPost=/opt/carlos/carlos instance-exit seed %I |
| +User=carlos |
| +Group=carlos |
| +# One slice per host holds the instance, its seed and its sidecar, and |
| +# carries the tier (§4.3, §6.6). Slices referenced here are created on |
| +# demand; no per-host unit file exists. |
| +Slice=carlos-instances-%i.slice |
| +# The log stream: stdout and stderr connect to the agent's per-role |
| +# listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| +# nosymfollow logs mount, at a path no tenant mount contains. |
| +StandardOutput=file:/run/carlos/logs/%I.seed.sock |
| +StandardError=inherit |
| +# The instance's own two directories, the only writable data paths. |
| +BindPaths=/data/instances/%I /run/carlos/instances/%I |
| +ReadWritePaths=/data/instances/%I /run/carlos/instances/%I |
| + |
| +# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) --- |
| +ProtectSystem=strict |
| +TemporaryFileSystem=/data:ro |
| +TemporaryFileSystem=/run/carlos:ro |
| +# Every other tree a carlos-uid unit writes on the box is hidden the same |
| +# way (codex R9-2): the restore drills' cache under /var/cache, and |
| +# /var/lib as a class. An instance needs nothing from either. |
| +TemporaryFileSystem=/var/cache:ro |
| +TemporaryFileSystem=/var/lib:ro |
| +# Root-written credentials a carlos-GROUP process may read live here: |
| +# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service, |
| +# and an instance's Group=carlos would read it too (codex R10-1). An |
| +# instance gets its configuration through its launch file and needs |
| +# nothing from /etc/carlos. |
| +InaccessiblePaths=/etc/carlos |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned, |
| +# and its cgroup.kill / cgroup.freeze would otherwise be one write away |
| +# for a same-uid tenant (codex R9-1). |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +ProcSubset=pid |
| +PrivatePIDs=yes |
| +RestrictNamespaces=yes |
| +NoNewPrivileges=yes |
| +CapabilityBoundingSet= |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +RemoveIPC=yes |
| +LockPersonality=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallArchitectures=native |
| + |
| +# --- per-service resources; the tier lives on the host slice (§4.3) --- |
| +TasksMax=256 |
| +# The kernel picks a tenant before it picks anything else in carlos.slice. |
| +OOMScoreAdjust=500 |
| +# One OOM kill takes every process of THIS service (systemd sets |
| +# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar |
| +# is a sibling service with its own family rule (§4.3). |
| +OOMPolicy=kill |
diff --git a/internal/host/units/carlos-instance-sidecar@.service b/internal/host/units/carlos-instance-sidecar@.service| new file mode 100644 |
| index 000000000..711336448 |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance-sidecar@.service |
| @@ -0,0 +1,99 @@ |
| +# One isolated exec-backed instance (isolated-exec design §4.1). The |
| +# activator owns it exactly as it owned the exec child this replaces: lease, |
| +# restore, seed, replication, hibernation, control socket, logs. What |
| +# changed is that the process is a unit, still uid carlos, inside a mount |
| +# namespace in which nothing but the instance's own two directories exists, |
| +# on mounts where no symlink is ever followed. The instance name is the |
| +# escaped bounded identifier (%i); paths use the unescaped one (%I). |
| +[Unit] |
| +Description=carlos exec-backed instance %I (sidecar) |
| +# Stop-only coupling: when the edge stops (including the stop half of a |
| +# restart) this unit stops, and nothing starts it again but the agent. |
| +# PartOf= would propagate the RESTART and race the agent's own startup |
| +# (codex R1-7). |
| +BindsTo=carlos-edge.service |
| +Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| +After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| + |
| +[Service] |
| +Type=simple |
| +ExecStart=/opt/carlos/carlos instance-exec sidecar %I |
| +# Runs inside the same namespace, as carlos, after this invocation's |
| +# processes have been signalled: leaves an ADVISORY exit record in the |
| +# instance's own run directory (§6.5). Not "+": the "+" prefix drops User= |
| +# and the Private*/Protect* settings but keeps the explicit |
| +# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from |
| +# here can see the host's /run/carlos anyway. |
| +ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I |
| +User=carlos |
| +Group=carlos |
| +# One slice per host holds the instance, its seed and its sidecar, and |
| +# carries the tier (§4.3, §6.6). Slices referenced here are created on |
| +# demand; no per-host unit file exists. |
| +Slice=carlos-instances-%i.slice |
| +# The activator owns recycling: a crash stands the entry down and the next |
| +# request wakes it. systemd must not race that. |
| +Restart=no |
| +# SIGTERM to the app, SIGKILL to anything it left behind after |
| +# TimeoutStopSec; the activator's stop budget is 20s. |
| +KillMode=mixed |
| +TimeoutStopSec=20 |
| +# The log stream: stdout and stderr connect to the agent's per-role |
| +# listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| +# nosymfollow logs mount, at a path no tenant mount contains. |
| +StandardOutput=file:/run/carlos/logs/%I.sidecar.sock |
| +StandardError=inherit |
| +# The instance's own two directories, the only writable data paths. |
| +BindPaths=/data/instances/%I /run/carlos/instances/%I |
| +ReadWritePaths=/data/instances/%I /run/carlos/instances/%I |
| + |
| +# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) --- |
| +ProtectSystem=strict |
| +TemporaryFileSystem=/data:ro |
| +TemporaryFileSystem=/run/carlos:ro |
| +# Every other tree a carlos-uid unit writes on the box is hidden the same |
| +# way (codex R9-2): the restore drills' cache under /var/cache, and |
| +# /var/lib as a class. An instance needs nothing from either. |
| +TemporaryFileSystem=/var/cache:ro |
| +TemporaryFileSystem=/var/lib:ro |
| +# Root-written credentials a carlos-GROUP process may read live here: |
| +# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service, |
| +# and an instance's Group=carlos would read it too (codex R10-1). An |
| +# instance gets its configuration through its launch file and needs |
| +# nothing from /etc/carlos. |
| +InaccessiblePaths=/etc/carlos |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned, |
| +# and its cgroup.kill / cgroup.freeze would otherwise be one write away |
| +# for a same-uid tenant (codex R9-1). |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +ProcSubset=pid |
| +PrivatePIDs=yes |
| +RestrictNamespaces=yes |
| +NoNewPrivileges=yes |
| +CapabilityBoundingSet= |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +RemoveIPC=yes |
| +LockPersonality=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallArchitectures=native |
| + |
| +# --- per-service resources; the tier lives on the host slice (§4.3) --- |
| +TasksMax=256 |
| +# The kernel picks a tenant before it picks anything else in carlos.slice. |
| +OOMScoreAdjust=500 |
| +# One OOM kill takes every process of THIS service (systemd sets |
| +# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar |
| +# is a sibling service with its own family rule (§4.3). |
| +OOMPolicy=kill |
diff --git a/internal/host/units/carlos-instance@.service b/internal/host/units/carlos-instance@.service| new file mode 100644 |
| index 000000000..38ade8b5b |
| --- /dev/null |
| +++ b/internal/host/units/carlos-instance@.service |
| @@ -0,0 +1,99 @@ |
| +# One isolated exec-backed instance (isolated-exec design §4.1). The |
| +# activator owns it exactly as it owned the exec child this replaces: lease, |
| +# restore, seed, replication, hibernation, control socket, logs. What |
| +# changed is that the process is a unit, still uid carlos, inside a mount |
| +# namespace in which nothing but the instance's own two directories exists, |
| +# on mounts where no symlink is ever followed. The instance name is the |
| +# escaped bounded identifier (%i); paths use the unescaped one (%I). |
| +[Unit] |
| +Description=carlos exec-backed instance %I |
| +# Stop-only coupling: when the edge stops (including the stop half of a |
| +# restart) this unit stops, and nothing starts it again but the agent. |
| +# PartOf= would propagate the RESTART and race the agent's own startup |
| +# (codex R1-7). |
| +BindsTo=carlos-edge.service |
| +Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| +After=carlos-edge.service data-instances.mount run-carlos-instances.mount run-carlos-logs.mount |
| + |
| +[Service] |
| +Type=simple |
| +ExecStart=/opt/carlos/carlos instance-exec run %I |
| +# Runs inside the same namespace, as carlos, after this invocation's |
| +# processes have been signalled: leaves an ADVISORY exit record in the |
| +# instance's own run directory (§6.5). Not "+": the "+" prefix drops User= |
| +# and the Private*/Protect* settings but keeps the explicit |
| +# TemporaryFileSystem=/BindPaths= mounts (codex R3-2), so nothing run from |
| +# here can see the host's /run/carlos anyway. |
| +ExecStopPost=/opt/carlos/carlos instance-exit run %I |
| +User=carlos |
| +Group=carlos |
| +# One slice per host holds the instance, its seed and its sidecar, and |
| +# carries the tier (§4.3, §6.6). Slices referenced here are created on |
| +# demand; no per-host unit file exists. |
| +Slice=carlos-instances-%i.slice |
| +# The activator owns recycling: a crash stands the entry down and the next |
| +# request wakes it. systemd must not race that. |
| +Restart=no |
| +# SIGTERM to the app, SIGKILL to anything it left behind after |
| +# TimeoutStopSec; the activator's stop budget is 20s. |
| +KillMode=mixed |
| +TimeoutStopSec=20 |
| +# The log stream: stdout and stderr connect to the agent's per-role |
| +# listener (§6.4). PID 1 opens this as root, outside the namespace, on the |
| +# nosymfollow logs mount, at a path no tenant mount contains. |
| +StandardOutput=file:/run/carlos/logs/%I.run.sock |
| +StandardError=inherit |
| +# The instance's own two directories, the only writable data paths. |
| +BindPaths=/data/instances/%I /run/carlos/instances/%I |
| +ReadWritePaths=/data/instances/%I /run/carlos/instances/%I |
| + |
| +# --- the namespace (titogo@.service, 2026-07-31 design, on systemd 257) --- |
| +ProtectSystem=strict |
| +TemporaryFileSystem=/data:ro |
| +TemporaryFileSystem=/run/carlos:ro |
| +# Every other tree a carlos-uid unit writes on the box is hidden the same |
| +# way (codex R9-2): the restore drills' cache under /var/cache, and |
| +# /var/lib as a class. An instance needs nothing from either. |
| +TemporaryFileSystem=/var/cache:ro |
| +TemporaryFileSystem=/var/lib:ro |
| +# Root-written credentials a carlos-GROUP process may read live here: |
| +# /etc/carlos/relay-masters is root:carlos 0640 for carlos-relay.service, |
| +# and an instance's Group=carlos would read it too (codex R10-1). An |
| +# instance gets its configuration through its launch file and needs |
| +# nothing from /etc/carlos. |
| +InaccessiblePaths=/etc/carlos |
| +ProtectHome=yes |
| +PrivateTmp=yes |
| +PrivateDevices=yes |
| +# /sys/fs/cgroup read-only: the edge's delegated subtree is carlos-owned, |
| +# and its cgroup.kill / cgroup.freeze would otherwise be one write away |
| +# for a same-uid tenant (codex R9-1). |
| +ProtectControlGroups=yes |
| +ProtectProc=invisible |
| +ProcSubset=pid |
| +PrivatePIDs=yes |
| +RestrictNamespaces=yes |
| +NoNewPrivileges=yes |
| +CapabilityBoundingSet= |
| +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| +IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128 |
| +ProtectKernelTunables=yes |
| +ProtectKernelModules=yes |
| +ProtectKernelLogs=yes |
| +ProtectClock=yes |
| +ProtectHostname=yes |
| +RemoveIPC=yes |
| +LockPersonality=yes |
| +RestrictRealtime=yes |
| +RestrictSUIDSGID=yes |
| +MemoryDenyWriteExecute=yes |
| +SystemCallArchitectures=native |
| + |
| +# --- per-service resources; the tier lives on the host slice (§4.3) --- |
| +TasksMax=256 |
| +# The kernel picks a tenant before it picks anything else in carlos.slice. |
| +OOMScoreAdjust=500 |
| +# One OOM kill takes every process of THIS service (systemd sets |
| +# memory.oom.group on the service cgroup for OOMPolicy=kill). The sidecar |
| +# is a sibling service with its own family rule (§4.3). |
| +OOMPolicy=kill |
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go| index 12a4ea4c8..1fd4a43b2 100644 |
| --- a/internal/host/units_isolation_test.go |
| +++ b/internal/host/units_isolation_test.go |
| @@ -197,3 +197,157 @@ func TestEnabledUnitsIncludeTheMounts(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The three tenant templates and the probe carry one namespace: byte- |
| +// identical hardening blocks, so a property proven by the probe is the |
| +// property a tenant gets (design §4.2, §8.5). |
| +func TestInstanceTemplatesShareOneNamespace(t *testing.T) { |
| + block := func(name string) string { |
| + u := readUnit(t, name) |
| + start := strings.Index(u, "# --- the namespace") |
| + end := strings.Index(u, "# --- per-service resources") |
| + if start < 0 || end < 0 || end < start { |
| + t.Fatalf("%s: namespace block markers missing", name) |
| + } |
| + return u[start:end] |
| + } |
| + ref := block("carlos-instance@.service") |
| + for _, name := range []string{"carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-probe.service"} { |
| + if block(name) != ref { |
| + t.Errorf("%s namespace block differs from carlos-instance@.service", name) |
| + } |
| + } |
| + for _, want := range []string{ |
| + "ProtectSystem=strict", "TemporaryFileSystem=/data:ro", "TemporaryFileSystem=/run/carlos:ro", |
| + "TemporaryFileSystem=/var/cache:ro", "TemporaryFileSystem=/var/lib:ro", "InaccessiblePaths=/etc/carlos", |
| + "ProtectControlGroups=yes", "ProtectProc=invisible", "ProcSubset=pid", "PrivatePIDs=yes", |
| + "RestrictNamespaces=yes", "NoNewPrivileges=yes", "CapabilityBoundingSet=", |
| + "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128", "MemoryDenyWriteExecute=yes", |
| + } { |
| + if !strings.Contains(ref, want) { |
| + t.Errorf("namespace block lacks %q", want) |
| + } |
| + } |
| + for name, wants := range map[string][]string{ |
| + "carlos-instance@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I", |
| + "BindPaths=/data/instances/%I /run/carlos/instances/%I", "StandardOutput=file:/run/carlos/logs/%I.run.sock", |
| + "Slice=carlos-instances-%i.slice", "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service", |
| + "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple"}, |
| + "carlos-instance-seed@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=120", "ExecStart=/opt/carlos/carlos instance-exec seed %I", |
| + "ExecStopPost=/opt/carlos/carlos instance-exit seed %I", "StandardOutput=file:/run/carlos/logs/%I.seed.sock"}, |
| + "carlos-instance-sidecar@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec sidecar %I", |
| + "ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I", "StandardOutput=file:/run/carlos/logs/%I.sidecar.sock"}, |
| + "carlos-instance-probe.service": {"Type=oneshot", "ExecStart=/opt/carlos/carlos instance-probe", |
| + "BindPaths=/data/instances/_probe /run/carlos/instances/_probe", "StandardOutput=truncate:/run/carlos-control/probe.out"}, |
| + } { |
| + u := readUnit(t, name) |
| + for _, w := range wants { |
| + if !strings.Contains(u, w) { |
| + t.Errorf("%s lacks %q", name, w) |
| + } |
| + } |
| + if strings.Contains(u, "ExecStopPost=+") { |
| + t.Errorf("%s uses ExecStopPost=+: the plus prefix keeps the unit's tmpfs (codex R3-2)", name) |
| + } |
| + if name != "carlos-instance-probe.service" && !strings.Contains(u, "User=carlos") { |
| + t.Errorf("%s does not run as carlos", name) |
| + } |
| + } |
| +} |
| + |
| +func TestControlUnitsShape(t *testing.T) { |
| + s := readUnit(t, "carlos-instance-control.socket") |
| + for _, want := range []string{"ListenStream=/run/carlos-control/control.sock", "SocketUser=carlos", "SocketGroup=carlos", "SocketMode=0600", "Accept=no", "WantedBy=sockets.target"} { |
| + if !strings.Contains(s, want) { |
| + t.Errorf("socket unit lacks %q", want) |
| + } |
| + } |
| + u := readUnit(t, "carlos-instance-control.service") |
| + for _, want := range []string{"ExecStart=/opt/carlos/carlos instance-control", "Requires=carlos-instance-control.socket", |
| + "data-instances.mount", "ProtectSystem=strict", "ReadWritePaths=/run/carlos-control", "RestrictAddressFamilies=AF_UNIX", "MemoryMax=64M"} { |
| + if !strings.Contains(u, want) { |
| + t.Errorf("control service lacks %q", want) |
| + } |
| + } |
| + for _, never := range []string{"User=", "NoNewPrivileges"} { |
| + if strings.Contains(u, never) { |
| + t.Errorf("control service must run as root for systemctl; found %q", never) |
| + } |
| + } |
| + have := map[string]bool{} |
| + for _, e := range enabledUnits { |
| + have[e] = true |
| + } |
| + if !have["carlos-instance-control.socket"] { |
| + t.Error("enabledUnits lacks the control socket") |
| + } |
| + if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] { |
| + t.Error("a template, the probe or the socket-activated service must not be enabled") |
| + } |
| + for _, want := range []string{"d /data/.instances/_probe 0700 carlos carlos -", "d /run/carlos/.instances/_probe 2750 carlos carlos-sockets -"} { |
| + if !strings.Contains(tmpfilesConf, want+"\n") { |
| + t.Errorf("tmpfilesConf lacks %q", want) |
| + } |
| + } |
| +} |
| + |
| +// The same-uid audit (design §9, codex R9-1, R9-2, R10-1): every tree a |
| +// carlos-uid unit writes, every carlos-owned cgroup, and every credential a |
| +// carlos-group process reads must be hidden from the tenant templates. |
| +func TestSameUIDAuditIsCoveredByTheTemplates(t *testing.T) { |
| + ns := readUnit(t, "carlos-instance@.service") |
| + hidden := func(path string) bool { |
| + for _, line := range strings.Split(ns, "\n") { |
| + for _, key := range []string{"TemporaryFileSystem=", "InaccessiblePaths="} { |
| + if strings.HasPrefix(line, key) { |
| + for _, p := range strings.Fields(strings.TrimPrefix(line, key)) { |
| + p = strings.TrimSuffix(p, ":ro") |
| + if path == p || strings.HasPrefix(path, p+"/") { |
| + return true |
| + } |
| + } |
| + } |
| + } |
| + } |
| + return false |
| + } |
| + names, err := unitNames() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range names { |
| + u := readUnit(t, name) |
| + if !strings.Contains(u, "User=carlos") { |
| + continue |
| + } |
| + for _, line := range strings.Split(u, "\n") { |
| + for _, key := range []string{"ReadWritePaths=", "CacheDirectory=", "StateDirectory=", "RuntimeDirectory="} { |
| + if !strings.HasPrefix(line, key) { |
| + continue |
| + } |
| + for _, p := range strings.Fields(strings.TrimPrefix(line, key)) { |
| + switch key { |
| + case "CacheDirectory=": |
| + p = "/var/cache/" + p |
| + case "StateDirectory=": |
| + p = "/var/lib/" + p |
| + case "RuntimeDirectory=": |
| + p = "/run/" + p |
| + } |
| + if !hidden(p) && !strings.HasPrefix(p, "/run/carlos/instances/") && !strings.HasPrefix(p, "/data/instances/") { |
| + t.Errorf("%s writes %s, which the tenant template does not hide", name, p) |
| + } |
| + } |
| + } |
| + } |
| + if strings.Contains(u, "Delegate=") && !strings.Contains(ns, "ProtectControlGroups=yes") { |
| + t.Errorf("%s delegates a cgroup subtree and the template leaves cgroupfs writable", name) |
| + } |
| + } |
| + // Credential inputs a carlos-group process reads (the second table). |
| + for _, cred := range []string{"/etc/carlos/relay-masters"} { |
| + if !hidden(cred) { |
| + t.Errorf("credential input %s is not hidden from tenants", cred) |
| + } |
| + } |
| +} |
diff --git a/main.go b/main.go| index 5b2ea85cf..5bc223afd 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -10,6 +10,8 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/host" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instanceexec" |
| "github.com/carlosframework/platform/internal/ops" |
| "github.com/carlosframework/platform/internal/version" |
| "github.com/carlosframework/platform/internal/vet" |
| @@ -134,6 +136,14 @@ func main() { |
| host.RunBootstrap(args) |
| case "adopt": |
| host.RunAdopt(args) |
| + case "instance-control": |
| + instancectl.Run(args) |
| + case "instance-exec": |
| + instanceexec.RunExec(args) |
| + case "instance-exit": |
| + instanceexec.RunExit(args) |
| + case "instance-probe": |
| + instanceexec.RunProbe(args) |
| case "vet": |
| vet.Run(args) |
| case "update": |