bootstrap: create and arm the isolated-exec trees, mounts and control directory (plan 1, task 5)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
3 files changed,
+131
−4
internal/host/bootstrap.go+48 −3internal/host/host_test.go+2 −1internal/host/units_isolation_test.go+81 −0
CI passed — run details
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index bc9175c0d..b4ba32877 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -59,7 +59,29 @@ const socketsGroup = "carlos-sockets" |
| // carlos:carlos, so consumer enrollment deliberately does not reach unit |
| // tenants. other=0 on both dirs keeps every unenrolled uid out. |
| const tmpfilesConf = "d /run/carlos 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| - "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" |
| + "d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n" + |
| + // The isolated-exec trees (design §3.1, §3.2). Each protected view is |
| + // a nosymfollow bind of the dotted raw directory beside it (the mount |
| + // units); tmpfiles creates both every boot because /run is tmpfs and |
| + // because the mounts are ordered after it. The socket trees keep the |
| + // consumers group and setgid, exactly like /run/carlos above, so an |
| + // enrolled router can still dial a migrated instance (issue #103). |
| + "d /data/.instances 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /data/instances 0755 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /data/layout 0700 " + serviceUser + " " + serviceUser + " -\n" + |
| + "d /run/carlos/.instances 2750 " + serviceUser + " " + socketsGroup + " -\n" + |
| + "d /run/carlos/instances 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, |
| + // so no carlos process can create, rename or replace anything in it; |
| + // group carlos with search so the agent can connect. |
| + "d /run/carlos-control 0750 root " + serviceUser + " -\n" + |
| + // The readiness probe's fixture (design §8.5, codex R11-1): a file |
| + // root:carlos 0640 that an isolated instance must NOT be able to read. |
| + // Written here, not by the control service, whose ProtectSystem=strict |
| + // makes /etc read-only to it. |
| + "f /etc/carlos/probe-secret 0640 root " + serviceUser + " - probe\n" |
| // journaldConfName is the journald drop-in every box gets (discussion #111). |
| // |
| @@ -114,7 +136,9 @@ const journaldConf = "# Written by carlos bootstrap (discussion #111). Do not ed |
| 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"} |
| + "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"} |
| // inertUnits are installed everywhere and deliberately never armed by |
| // bootstrap or by a self-update apply: restore-verify until an operator |
| @@ -162,6 +186,12 @@ func currentBootstrapPlan() bootstrapPlan { |
| // 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. |
| +// |
| +// Socket and mount units are armed the same way (isolated-exec design |
| +// §8.6, codex R1-14): an enabled-but-unstarted socket is a control channel |
| +// nobody is listening on, and an enabled-but-unmounted bind is an instance |
| +// tree with no protection, until the next reboot. Starting either is |
| +// idempotent. |
| func systemctlPlan(units []string) [][]string { |
| plan := [][]string{ |
| {"daemon-reload"}, |
| @@ -169,7 +199,8 @@ func systemctlPlan(units []string) [][]string { |
| } |
| var armed []string |
| for _, u := range units { |
| - if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || u == relayUnitName { |
| + if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") || |
| + strings.HasSuffix(u, ".socket") || strings.HasSuffix(u, ".mount") || u == relayUnitName { |
| armed = append(armed, u) |
| } |
| } |
| @@ -306,6 +337,20 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| // The carlos-app@ tenant sockets (see tmpfilesConf for the perms |
| // rationale). |
| {filepath.Join(runDir, "apps"), 0o750}, |
| + // The isolated-exec trees (design §3.1). The dotted names are the |
| + // raw directories the mount units bind onto the undotted views; |
| + // see tmpfilesConf for the modes and why both exist. |
| + {filepath.Join(dataDir, ".instances"), 0o700}, |
| + {filepath.Join(dataDir, "instances"), 0o755}, |
| + {filepath.Join(dataDir, "layout"), 0o700}, |
| + {filepath.Join(runDir, ".instances"), 0o750 | os.ModeSetgid}, |
| + {filepath.Join(runDir, "instances"), 0o750 | os.ModeSetgid}, |
| + {filepath.Join(runDir, ".logs"), 0o700}, |
| + {filepath.Join(runDir, "logs"), 0o700}, |
| + // Root-owned on a live box (privilegedPlan chowns /run/carlos |
| + // recursively to the service user; this one sits BESIDE it, not |
| + // under it, precisely so that chown never reaches it). |
| + {filepath.Join(root, "run", "carlos-control"), 0o750}, |
| // The version store + per-host live symlinks (release/versions.go). |
| // World-readable/executable so sandboxed instance units can exec their |
| // binary; only root (the adopt pass) writes here. |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 9dbc0a1d7..37558c533 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -1216,7 +1216,8 @@ 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} |
| + "carlos-stage.path": true, "carlos-restart.path": true, "carlos-system-update.path": true, "carlos-system-recover.service": true, |
| + "data-instances.mount": true, "run-carlos-instances.mount": true, "run-carlos-logs.mount": true} |
| got := map[string]bool{} |
| for _, u := range p.Enabled { |
| got[u] = true |
diff --git a/internal/host/units_isolation_test.go b/internal/host/units_isolation_test.go| index 27a770336..db678d790 100644 |
| --- a/internal/host/units_isolation_test.go |
| +++ b/internal/host/units_isolation_test.go |
| @@ -1,6 +1,8 @@ |
| package host |
| import ( |
| + "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| ) |
| @@ -116,3 +118,82 @@ func TestEmbeddedUnitsIncludeSlicesAndMounts(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// tmpfiles creates the raw directories and the mountpoints every boot |
| +// (/run is tmpfs), with the modes the design gives them (§3.1, §3.2): |
| +// the socket trees keep the carlos-sockets group and setgid (issue #103), |
| +// the log tree and data trees are the agent's alone, and the control |
| +// directory is root's so no carlos process can create anything in it. |
| +func TestTmpfilesCreatesTheInstanceTrees(t *testing.T) { |
| + for _, want := range []string{ |
| + "d /data/.instances 0700 carlos carlos -", |
| + "d /data/instances 0755 carlos carlos -", |
| + "d /data/layout 0700 carlos carlos -", |
| + "d /run/carlos/.instances 2750 carlos carlos-sockets -", |
| + "d /run/carlos/instances 2750 carlos carlos-sockets -", |
| + "d /run/carlos/.logs 0700 carlos carlos -", |
| + "d /run/carlos/logs 0700 carlos carlos -", |
| + "d /run/carlos-control 0750 root carlos -", |
| + "f /etc/carlos/probe-secret 0640 root carlos - probe", |
| + } { |
| + if !strings.Contains(tmpfilesConf, want+"\n") { |
| + t.Errorf("tmpfilesConf lacks %q", want) |
| + } |
| + } |
| + // The existing lines stay: the flat socket dir and the apps subtree. |
| + for _, keep := range []string{"d /run/carlos 2750 carlos carlos-sockets -", "d /run/carlos/apps 0750 carlos carlos -"} { |
| + if !strings.Contains(tmpfilesConf, keep+"\n") { |
| + t.Errorf("tmpfilesConf lost %q", keep) |
| + } |
| + } |
| +} |
| + |
| +// bootstrap -root <dir> creates the same directories in the staging tree |
| +// (a live run's chown -R covers them), so a fresh box has them before the |
| +// mounts try to bind them. |
| +func TestBootstrapCreatesTheInstanceDirectories(t *testing.T) { |
| + root := t.TempDir() |
| + if err := bootstrap(root, nil); err != nil { |
| + t.Fatalf("bootstrap: %v", err) |
| + } |
| + for _, d := range []string{"data/.instances", "data/instances", "data/layout", "run/carlos/.instances", "run/carlos/instances", "run/carlos/.logs", "run/carlos/logs", "run/carlos-control"} { |
| + if fi, err := os.Stat(filepath.Join(root, d)); err != nil || !fi.IsDir() { |
| + t.Errorf("%s: %v", d, err) |
| + } |
| + } |
| +} |
| + |
| +// The mounts are enabled AND started by the plan: `systemctl enable` on a |
| +// mount unit writes a symlink for the next boot and nothing else, the same |
| +// trap timers and path units fell into (codex R1-14). A socket unit the |
| +// same. The relay is still the only service the plan starts. |
| +func TestSystemctlPlanStartsMountsAndSockets(t *testing.T) { |
| + plan := systemctlPlan([]string{"carlos-edge.service", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"}) |
| + var started []string |
| + for _, args := range plan { |
| + if args[0] == "start" { |
| + started = append(started, args[1:]...) |
| + } |
| + } |
| + got := strings.Join(started, " ") |
| + for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"} { |
| + if !strings.Contains(got, want) { |
| + t.Errorf("plan does not start %s: %v", want, plan) |
| + } |
| + } |
| + if strings.Contains(got, "carlos-edge.service") { |
| + t.Errorf("plan starts the edge: %v", plan) |
| + } |
| +} |
| + |
| +func TestEnabledUnitsIncludeTheMounts(t *testing.T) { |
| + have := map[string]bool{} |
| + for _, u := range enabledUnits { |
| + have[u] = true |
| + } |
| + for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} { |
| + if !have[want] { |
| + t.Errorf("enabledUnits lacks %s", want) |
| + } |
| + } |
| +} |