carlos / platform Public

units: carlos.slice outer wall and the three nosymfollow instance mounts (plan 1, task 4)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
Paul Campbell pushed by paul@keymail.dev 67e40b9a2703cb17e115a69d40a46ca555284ddb parent 9e2c95d
10 files changed, +271 −26
  • internal/host/bootstrap.go +1 −1
  • internal/host/host_test.go +27 −7
  • internal/host/units/carlos-edge.service +12 −17
  • internal/host/units/carlos-instances.slice +10 −0
  • internal/host/units/carlos-litestream.service +1 −1
  • internal/host/units/carlos.slice +18 −0
  • internal/host/units/data-instances.mount +38 −0
  • internal/host/units/run-carlos-instances.mount +23 −0
  • internal/host/units/run-carlos-logs.mount +23 −0
  • internal/host/units_isolation_test.go +118 −0
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 0b258385d..bc9175c0d 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -30,7 +30,7 @@ import (
// no separate files to copy. Ported from Eleven's deploy/systemd (renamed,
// same hardening) and titogo's deploy/systemd (litestream + restore-verify).
//
-//go:embed units/*.service units/*.timer units/*.socket units/*.path
+//go:embed units/*.service units/*.timer units/*.socket units/*.path units/*.slice units/*.mount
var unitsFS embed.FS
// serviceUser is the unprivileged account the edge unit runs as; bootstrap
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index 1a5b09dbd..9dbc0a1d7 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -36,6 +36,7 @@ var expectedUnits = []string{
"carlos-edge.service",
"carlos-hibernate-verify.service",
"carlos-hibernate-verify.timer",
+ "carlos-instances.slice",
"carlos-litestream.service",
"carlos-relay.service",
"carlos-restart.path",
@@ -47,6 +48,10 @@ var expectedUnits = []string{
"carlos-system-recover.service",
"carlos-system-update.path",
"carlos-system-update.service",
+ "carlos.slice",
+ "data-instances.mount",
+ "run-carlos-instances.mount",
+ "run-carlos-logs.mount",
}
// requireUnixSockets skips a test when the sandbox forbids unix-socket
@@ -178,19 +183,34 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) {
// cgroup manager degrades to nil and every exec-backed tenant goes
// blind on the Instances page.
"Delegate=cpu memory io pids",
- // The outer wall (memory-pressure spec §2.2): the edge tree is
- // where every exec-backed tenant lives, so this is the one place a
- // runaway tenant meets a ceiling before the kernel starts thrashing.
- // OOMPolicy=continue is the line that keeps one tenant's OOM kill
- // from becoming a whole-edge restart (systemd's default is stop).
- "MemoryHigh=70%",
- "MemoryMax=80%",
+ // The outer wall (memory-pressure spec §2.2) is now on carlos.slice
+ // (isolated-exec design §4.3). The edge is in the slice, so it still
+ // participates in the ceiling. OOMPolicy=continue is the line that
+ // keeps one tenant's OOM kill from becoming a whole-edge restart
+ // (systemd's default is stop).
+ "Slice=carlos.slice",
"OOMPolicy=continue",
} {
if !strings.Contains(string(edge), want) {
t.Errorf("carlos-edge.service: missing %q", want)
}
}
+
+ // The outer wall moved from the edge to carlos.slice (isolated-exec
+ // design §4.3): the slice bounds the edge and every awake instance
+ // as before, but instances are now sibling units instead of children.
+ slice, err := os.ReadFile(filepath.Join(unitDir, "carlos.slice"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "MemoryHigh=70%",
+ "MemoryMax=80%",
+ } {
+ if !strings.Contains(string(slice), want) {
+ t.Errorf("carlos.slice: missing %q", want)
+ }
+ }
// The counterpart of Delegate=: ProtectControlGroups would mount the
// cgroup tree read-only and silently un-arm delegation. Its hardening is
// retained by ownership (DAC) instead — see the unit file's comment. A
diff --git a/internal/host/units/carlos-edge.service b/internal/host/units/carlos-edge.service
index 018304cec..c988f2af8 100644
--- a/internal/host/units/carlos-edge.service
+++ b/internal/host/units/carlos-edge.service
@@ -5,7 +5,11 @@
# managed instances get their own units.
[Unit]
Description=carlos edge (TLS terminator, host router, activator, release adoption)
-After=network-online.target
+# The three nosymfollow mounts of the isolated-exec design (§3.2) are hard
+# requirements: an edge that starts without them would start unprotected,
+# so it does not start at all.
+Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
+After=network-online.target data-instances.mount run-carlos-instances.mount run-carlos-logs.mount
Wants=network-online.target
[Service]
@@ -54,22 +58,13 @@ MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
-# The outer wall (memory-pressure spec 2026-08-28 §2.2). Exec-backed
-# instances are this unit's children, so these DO cap the whole set of
-# awake instances at once — that is now the point: on 2026-08-28 the
-# flagship thrashed for 15 minutes with no swap, no ceiling and an OOM
-# killer that never fired, because nothing bounded the tree. Percentages
-# so one file fits a t4g.micro and a t4g.medium; 80% leaves what the
-# box's other units (keymail, console, ssm, litestream, journald) use.
-# Past High the kernel reclaims the tree (page cache first, then zram);
-# at Max the memcg OOM killer picks the highest-scoring PROCESS in the
-# tree. By RSS alone that would be the agent itself (~110 MB against
-# 20-60 MB tenant processes), so the agent writes oom_score_adj=+500 on
-# every exec child it starts (internal/cgroup ExecOOMScoreAdj) — a tenant
-# process is always the first pick. Per-instance caps below this
-# (carlos-exec@<host> memory.max, internal/cgroup) are the inner walls.
-MemoryHigh=70%
-MemoryMax=80%
+# The outer wall (memory-pressure spec 2026-08-28 §2.2) used to be two
+# MemoryHigh/MemoryMax lines here, bounding the agent plus its exec
+# children. Instances are sibling units now (isolated-exec design §4.3), so
+# the wall is carlos.slice's, which holds this unit and every instance and
+# bounds exactly what it bounded before. The oom_score_adj story is
+# unchanged: tenants carry OOMScoreAdjust=500 in their template.
+Slice=carlos.slice
# systemd's default OOMPolicy=stop STOPS the whole service when the kernel
# OOM-kills ANY process in the unit's cgroup tree (memory.events is
# hierarchical, delegation or not). Restart=always would bring the edge
diff --git a/internal/host/units/carlos-instances.slice b/internal/host/units/carlos-instances.slice
new file mode 100644
index 000000000..2930ac8bc
--- /dev/null
+++ b/internal/host/units/carlos-instances.slice
@@ -0,0 +1,10 @@
+# The parent of every per-host slice (carlos-instances-<iid>.slice, created
+# on demand by the first carlos-instance*@ unit that names it; isolated-exec
+# design §4.3). No cap of its own: the outer wall is carlos.slice, the inner
+# walls are the per-host slices the control service sets the tier on. This
+# level exists so the sampler has one root to scan and so an operator can
+# `systemctl status carlos-instances.slice`.
+[Unit]
+Description=carlos exec-backed instances
+
+[Slice]
diff --git a/internal/host/units/carlos-litestream.service b/internal/host/units/carlos-litestream.service
index dbc923f8e..edb17ddf5 100644
--- a/internal/host/units/carlos-litestream.service
+++ b/internal/host/units/carlos-litestream.service
@@ -9,7 +9,7 @@
[Unit]
Description=carlos litestream (replicate /data SQLite files to S3)
Documentation=https://litestream.io
-After=network-online.target
+After=network-online.target data-instances.mount
Wants=network-online.target
[Service]
diff --git a/internal/host/units/carlos.slice b/internal/host/units/carlos.slice
new file mode 100644
index 000000000..6f78ac1a9
--- /dev/null
+++ b/internal/host/units/carlos.slice
@@ -0,0 +1,18 @@
+# The outer memory wall of the memory-pressure containment design
+# (2026-08-28 §2.2), moved here from carlos-edge.service by the isolated-exec
+# design (§4.3): exec-backed instances used to be the edge's children, so a
+# ceiling on the edge unit bounded the agent plus every awake instance at
+# once. They are sibling units now, so the ceiling lives one level up, on
+# a slice that holds the edge AND carlos-instances.slice, and bounds exactly
+# what it bounded before. Borrowing between agent and tenants and the OOM
+# candidate set (a tenant at OOMScoreAdjust=500 is picked before the agent)
+# are unchanged.
+#
+# Percentages so one file fits a t4g.micro and a t4g.medium; 80% leaves what
+# the box's other units (keymail, console, ssm, litestream, journald) use.
+[Unit]
+Description=carlos (edge, activator and every exec-backed instance)
+
+[Slice]
+MemoryHigh=70%
+MemoryMax=80%
diff --git a/internal/host/units/data-instances.mount b/internal/host/units/data-instances.mount
new file mode 100644
index 000000000..b2ce7f094
--- /dev/null
+++ b/internal/host/units/data-instances.mount
@@ -0,0 +1,38 @@
+# The protected view of the instance data tree (isolated-exec design §3.2).
+#
+# /data/.instances is the RAW directory, opened by the layout pass only;
+# this bind of it onto /data/instances is the view everything else uses.
+# nosymfollow is the isolation's belt: path resolution under this mount
+# returns ELOOP at any symlink, for every process on the box, in every
+# namespace that sees the mount, with no check-then-use race. An instance
+# runs as the agent's uid and can write its own directory, so any symlink
+# it plants would otherwise be followed by the agent (reading a sibling's
+# env file as this instance's), by the edge (dialling a sibling's socket
+# as this host) and by PID 1. Here none of them follow it.
+#
+# It is a bind of a DIFFERENT directory, not of itself, because rename(2)
+# refuses to cross a mount boundary (EXDEV) even on one filesystem, and
+# the migration moves /data/<host>.db into this tree by rename (codex R4-2).
+#
+# DefaultDependencies=no, with the ordering written out: a mount unit's
+# default place is before local-fs.target, and systemd-tmpfiles-setup,
+# which creates the two directories, runs after it, so the defaults form a
+# cycle systemd breaks by dropping this mount (codex R2-2). Shutdown
+# ordering is written out for the same reason.
+[Unit]
+Description=carlos instance data (bind, nosymfollow)
+DefaultDependencies=no
+RequiresMountsFor=/data
+After=local-fs.target systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service carlos-litestream.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/data/.instances
+Where=/data/instances
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
diff --git a/internal/host/units/run-carlos-instances.mount b/internal/host/units/run-carlos-instances.mount
new file mode 100644
index 000000000..9de118149
--- /dev/null
+++ b/internal/host/units/run-carlos-instances.mount
@@ -0,0 +1,23 @@
+# The protected view of the instance socket tree (isolated-exec design
+# §3.2): /run/carlos/.instances bound onto /run/carlos/instances with
+# nosymfollow. See data-instances.mount for why a bind, why of a different
+# directory, and why DefaultDependencies=no. Nothing is ever renamed into
+# this tree, so the raw directory has no caller at all. /run is tmpfs, so
+# the two directories come from the tmpfiles line every boot, which is why
+# this is ordered after systemd-tmpfiles-setup.service.
+[Unit]
+Description=carlos instance sockets (bind, nosymfollow)
+DefaultDependencies=no
+After=systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/run/carlos/.instances
+Where=/run/carlos/instances
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
diff --git a/internal/host/units/run-carlos-logs.mount b/internal/host/units/run-carlos-logs.mount
new file mode 100644
index 000000000..8f605ee24
--- /dev/null
+++ b/internal/host/units/run-carlos-logs.mount
@@ -0,0 +1,23 @@
+# The protected view of the agent's per-role log sockets (isolated-exec
+# design §3.2, §6.4): /run/carlos/.logs bound onto /run/carlos/logs with
+# nosymfollow. This directory is bound into NO instance namespace; the
+# mount option is for the transition release, in which unisolated flat
+# tenants still share the agent's uid and could otherwise turn a log
+# socket path into a symlink that PID 1 opens as root for some unit's
+# stdout (codex R3-1). See data-instances.mount for the ordering.
+[Unit]
+Description=carlos instance log sockets (bind, nosymfollow)
+DefaultDependencies=no
+After=systemd-tmpfiles-setup.service
+Before=carlos-edge.service carlos-instance-control.service
+Conflicts=umount.target
+Before=umount.target
+
+[Mount]
+What=/run/carlos/.logs
+Where=/run/carlos/logs
+Type=none
+Options=bind,nosymfollow,nosuid,nodev,noexec
+
+[Install]
+WantedBy=multi-user.target
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go
new file mode 100644
index 000000000..27a770336
--- /dev/null
+++ b/internal/host/units_isolation_test.go
@@ -0,0 +1,118 @@
+package host
+
+import (
+ "strings"
+ "testing"
+)
+
+func readUnit(t *testing.T, name string) string {
+ t.Helper()
+ b, err := unitsFS.ReadFile("units/" + name)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ return string(b)
+}
+
+// The outer memory wall moves from the edge unit to carlos.slice
+// (isolated-exec design §4.3): it bounds the agent plus every awake
+// instance as before, but instances are now sibling units, not children.
+func TestCarlosSliceCarriesTheOuterWall(t *testing.T) {
+ s := readUnit(t, "carlos.slice")
+ for _, want := range []string{"[Slice]", "MemoryHigh=70%", "MemoryMax=80%"} {
+ if !strings.Contains(s, want) {
+ t.Errorf("carlos.slice lacks %q", want)
+ }
+ }
+ e := readUnit(t, "carlos-edge.service")
+ if !strings.Contains(e, "Slice=carlos.slice") {
+ t.Error("carlos-edge.service is not in carlos.slice")
+ }
+ for _, never := range []string{"MemoryHigh=", "MemoryMax="} {
+ if strings.Contains(e, never) {
+ t.Errorf("carlos-edge.service still carries %q; the wall is the slice's now", never)
+ }
+ }
+ // What must NOT change on the edge unit.
+ for _, keep := range []string{"OOMPolicy=continue", "Delegate=cpu memory io pids", "User=carlos",
+ "AmbientCapabilities=CAP_NET_BIND_SERVICE", "ReadWritePaths=/data /run/carlos", "RestrictNamespaces=yes"} {
+ if !strings.Contains(e, keep) {
+ t.Errorf("carlos-edge.service lost %q", keep)
+ }
+ }
+ i := readUnit(t, "carlos-instances.slice")
+ if !strings.Contains(i, "[Slice]") {
+ t.Error("carlos-instances.slice has no [Slice] section")
+ }
+ for _, never := range []string{"MemoryHigh=", "MemoryMax="} {
+ if strings.Contains(i, never) {
+ t.Errorf("carlos-instances.slice carries %q; the walls are carlos.slice and the per-host slices", never)
+ }
+ }
+}
+
+// The three mounts: a bind of a raw directory onto its protected view,
+// nosymfollow, with DefaultDependencies=no and explicit ordering, because
+// the default ordering (before local-fs.target) and tmpfiles (after it)
+// would form a cycle systemd breaks by dropping the mount (codex R2-2).
+func TestInstanceMountsAreShapedForNosymfollow(t *testing.T) {
+ cases := map[string][2]string{
+ "data-instances.mount": {"/data/.instances", "/data/instances"},
+ "run-carlos-instances.mount": {"/run/carlos/.instances", "/run/carlos/instances"},
+ "run-carlos-logs.mount": {"/run/carlos/.logs", "/run/carlos/logs"},
+ }
+ for name, ww := range cases {
+ m := readUnit(t, name)
+ for _, want := range []string{
+ "What=" + ww[0], "Where=" + ww[1], "Type=none",
+ "Options=bind,nosymfollow,nosuid,nodev,noexec",
+ "DefaultDependencies=no",
+ "After=", "systemd-tmpfiles-setup.service",
+ "Before=", "carlos-edge.service",
+ "Conflicts=umount.target", "Before=umount.target",
+ "WantedBy=multi-user.target",
+ } {
+ if !strings.Contains(m, want) {
+ t.Errorf("%s lacks %q", name, want)
+ }
+ }
+ if ww[0] == ww[1] {
+ t.Errorf("%s binds a directory onto itself; the migration rename would fail with EXDEV (codex R4-2)", name)
+ }
+ }
+ d := readUnit(t, "data-instances.mount")
+ for _, want := range []string{"RequiresMountsFor=/data", "local-fs.target", "carlos-litestream.service"} {
+ if !strings.Contains(d, want) {
+ t.Errorf("data-instances.mount lacks %q", want)
+ }
+ }
+ e := readUnit(t, "carlos-edge.service")
+ for _, want := range []string{"Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount",
+ "After=network-online.target data-instances.mount run-carlos-instances.mount run-carlos-logs.mount"} {
+ if !strings.Contains(e, want) {
+ t.Errorf("carlos-edge.service lacks %q: an edge that starts without the mounts must not start", want)
+ }
+ }
+ l := readUnit(t, "carlos-litestream.service")
+ if !strings.Contains(l, "After=network-online.target data-instances.mount") {
+ t.Error("carlos-litestream.service is not ordered after data-instances.mount")
+ }
+}
+
+// Every unit file, whatever its suffix, is embedded: a .slice or .mount
+// left out of the glob would be silently absent from every box.
+func TestEmbeddedUnitsIncludeSlicesAndMounts(t *testing.T) {
+ names, err := unitNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ have := map[string]bool{}
+ for _, n := range names {
+ have[n] = true
+ }
+ for _, want := range []string{"carlos.slice", "carlos-instances.slice", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} {
+ if !have[want] {
+ t.Errorf("%s is not embedded (bootstrap.go's go:embed glob)", want)
+ }
+ }
+}