| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func readUnit(t *testing.T, name string) string { |
| 11 | t.Helper() |
| 12 | b, err := unitsFS.ReadFile("units/" + name) |
| 13 | if err != nil { |
| 14 | t.Fatalf("%s: %v", name, err) |
| 15 | } |
| 16 | return string(b) |
| 17 | } |
| 18 | |
| 19 | // The outer memory wall moves from the edge unit to carlos.slice |
| 20 | // (isolated-exec design §4.3): it bounds the agent plus every awake |
| 21 | // instance as before, but instances are now sibling units, not children. |
| 22 | func TestCarlosSliceCarriesTheOuterWall(t *testing.T) { |
| 23 | s := readUnit(t, "carlos.slice") |
| 24 | for _, want := range []string{"[Slice]", "MemoryHigh=70%", "MemoryMax=80%"} { |
| 25 | if !strings.Contains(s, want) { |
| 26 | t.Errorf("carlos.slice lacks %q", want) |
| 27 | } |
| 28 | } |
| 29 | e := readUnit(t, "carlos-edge.service") |
| 30 | if !strings.Contains(e, "Slice=carlos.slice") { |
| 31 | t.Error("carlos-edge.service is not in carlos.slice") |
| 32 | } |
| 33 | for _, never := range []string{"MemoryHigh=", "MemoryMax="} { |
| 34 | if strings.Contains(e, never) { |
| 35 | t.Errorf("carlos-edge.service still carries %q; the wall is the slice's now", never) |
| 36 | } |
| 37 | } |
| 38 | // What must NOT change on the edge unit. The bounding set and |
| 39 | // NoNewPrivileges= are here because dropping RestrictSUIDSGID= (below) |
| 40 | // rests on them: with no CAP_CHOWN, CAP_FSETID or CAP_SETUID a set-id |
| 41 | // file the agent makes is owned by carlos, and under no_new_privs its |
| 42 | // own children ignore the bit. |
| 43 | for _, keep := range []string{"OOMPolicy=continue", "Delegate=cpu memory io pids", "User=carlos", |
| 44 | "AmbientCapabilities=CAP_NET_BIND_SERVICE", "CapabilityBoundingSet=CAP_NET_BIND_SERVICE", "NoNewPrivileges=yes", |
| 45 | "ReadWritePaths=/data /run/carlos", "RestrictNamespaces=yes"} { |
| 46 | if !strings.Contains(e, keep) { |
| 47 | t.Errorf("carlos-edge.service lost %q", keep) |
| 48 | } |
| 49 | } |
| 50 | // RestrictSUIDSGID= makes openat2 answer ENOSYS, and the agent runs in |
| 51 | // this unit: its nested opens and every rename of the layout pass are |
| 52 | // openat2 calls (#126, the plan 4 blocker). The probe cannot see this |
| 53 | // unit's text, so the test pins it. |
| 54 | for _, line := range strings.Split(e, "\n") { |
| 55 | if strings.HasPrefix(line, "RestrictSUIDSGID=") { |
| 56 | t.Errorf("carlos-edge.service carries %q, which blocks openat2 for the agent", line) |
| 57 | } |
| 58 | } |
| 59 | // The same argument needs every other unit that runs as carlos, or |
| 60 | // could execute something under /data, to ignore set-id bits too: |
| 61 | // /data itself carries no nosuid, so NoNewPrivileges= on each of them |
| 62 | // is what makes a set-id file the agent could make worthless to them. |
| 63 | for _, name := range []string{"carlos-app@.service", "carlos-relay.service", "carlos-litestream.service", |
| 64 | "carlos-hibernate-verify.service", "carlos-restore-verify.service"} { |
| 65 | if !strings.Contains(readUnit(t, name), "NoNewPrivileges=yes") { |
| 66 | t.Errorf("%s lacks NoNewPrivileges=yes; the edge unit's dropped RestrictSUIDSGID= rests on it", name) |
| 67 | } |
| 68 | } |
| 69 | i := readUnit(t, "carlos-instances.slice") |
| 70 | if !strings.Contains(i, "[Slice]") { |
| 71 | t.Error("carlos-instances.slice has no [Slice] section") |
| 72 | } |
| 73 | for _, never := range []string{"MemoryHigh=", "MemoryMax="} { |
| 74 | if strings.Contains(i, never) { |
| 75 | t.Errorf("carlos-instances.slice carries %q; the walls are carlos.slice and the per-host slices", never) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The three mounts: a bind of a raw directory onto its protected view, |
| 81 | // nosymfollow, with DefaultDependencies=no and explicit ordering, because |
| 82 | // the default ordering (before local-fs.target) and tmpfiles (after it) |
| 83 | // would form a cycle systemd breaks by dropping the mount (codex R2-2). |
| 84 | func TestInstanceMountsAreShapedForNosymfollow(t *testing.T) { |
| 85 | cases := map[string][2]string{ |
| 86 | "data-instances.mount": {"/data/.instances", "/data/instances"}, |
| 87 | "run-carlos-instances.mount": {"/run/carlos/.instances", "/run/carlos/instances"}, |
| 88 | "run-carlos-logs.mount": {"/run/carlos/.logs", "/run/carlos/logs"}, |
| 89 | } |
| 90 | for name, ww := range cases { |
| 91 | m := readUnit(t, name) |
| 92 | for _, want := range []string{ |
| 93 | "What=" + ww[0], "Where=" + ww[1], "Type=none", |
| 94 | "Options=bind,nosymfollow,nosuid,nodev,noexec", |
| 95 | "DefaultDependencies=no", |
| 96 | "After=", "systemd-tmpfiles-setup.service", |
| 97 | "Before=", "carlos-edge.service", |
| 98 | "Conflicts=umount.target", "Before=umount.target", |
| 99 | "WantedBy=multi-user.target", |
| 100 | } { |
| 101 | if !strings.Contains(m, want) { |
| 102 | t.Errorf("%s lacks %q", name, want) |
| 103 | } |
| 104 | } |
| 105 | if ww[0] == ww[1] { |
| 106 | t.Errorf("%s binds a directory onto itself; the migration rename would fail with EXDEV (codex R4-2)", name) |
| 107 | } |
| 108 | } |
| 109 | d := readUnit(t, "data-instances.mount") |
| 110 | for _, want := range []string{"RequiresMountsFor=/data", "local-fs.target", "carlos-litestream.service"} { |
| 111 | if !strings.Contains(d, want) { |
| 112 | t.Errorf("data-instances.mount lacks %q", want) |
| 113 | } |
| 114 | } |
| 115 | e := readUnit(t, "carlos-edge.service") |
| 116 | for _, want := range []string{"Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", |
| 117 | "After=network-online.target data-instances.mount run-carlos-instances.mount run-carlos-logs.mount"} { |
| 118 | if !strings.Contains(e, want) { |
| 119 | t.Errorf("carlos-edge.service lacks %q: an edge that starts without the mounts must not start", want) |
| 120 | } |
| 121 | } |
| 122 | l := readUnit(t, "carlos-litestream.service") |
| 123 | if !strings.Contains(l, "After=network-online.target data-instances.mount") { |
| 124 | t.Error("carlos-litestream.service is not ordered after data-instances.mount") |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // Every unit file, whatever its suffix, is embedded: a .slice or .mount |
| 129 | // left out of the glob would be silently absent from every box. |
| 130 | func TestEmbeddedUnitsIncludeSlicesAndMounts(t *testing.T) { |
| 131 | names, err := unitNames() |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | have := map[string]bool{} |
| 136 | for _, n := range names { |
| 137 | have[n] = true |
| 138 | } |
| 139 | for _, want := range []string{"carlos.slice", "carlos-instances.slice", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} { |
| 140 | if !have[want] { |
| 141 | t.Errorf("%s is not embedded (bootstrap.go's go:embed glob)", want) |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // tmpfiles creates the raw directories and the mountpoints every boot |
| 147 | // (/run is tmpfs), with the modes the design gives them (§3.1, §3.2): |
| 148 | // the socket trees keep the carlos-sockets group and setgid (issue #103), |
| 149 | // the log tree and data trees are the agent's alone, and the control |
| 150 | // directory is root's so no carlos process can create anything in it. |
| 151 | func TestTmpfilesCreatesTheInstanceTrees(t *testing.T) { |
| 152 | for _, want := range []string{ |
| 153 | "d /data/.instances 0700 carlos carlos -", |
| 154 | "d /data/instances 0700 carlos carlos -", |
| 155 | "d /data/layout 0700 carlos carlos -", |
| 156 | "d /run/carlos/.instances 2750 carlos carlos-sockets -", |
| 157 | "d /run/carlos/instances 2750 carlos carlos-sockets -", |
| 158 | "d /run/carlos/.logs 0700 carlos carlos -", |
| 159 | "d /run/carlos/logs 0700 carlos carlos -", |
| 160 | "d /run/carlos-control 0750 root carlos -", |
| 161 | "d /run/carlos/hostguard 2770 carlos carlos -", |
| 162 | "d /data/hostguard 2770 carlos carlos -", |
| 163 | "f /etc/carlos/probe-secret 0640 root carlos - probe", |
| 164 | "f /dev/shm/carlos-probe 0644 root root - probe", |
| 165 | } { |
| 166 | if !strings.Contains(tmpfilesConf, want+"\n") { |
| 167 | t.Errorf("tmpfilesConf lacks %q", want) |
| 168 | } |
| 169 | } |
| 170 | // The existing lines stay: the flat socket dir and the apps subtree. |
| 171 | for _, keep := range []string{"d /run/carlos 2750 carlos carlos-sockets -", "d /run/carlos/apps 0750 carlos carlos -"} { |
| 172 | if !strings.Contains(tmpfilesConf, keep+"\n") { |
| 173 | t.Errorf("tmpfilesConf lost %q", keep) |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // bootstrap -root <dir> creates the same directories in the staging tree |
| 179 | // (a live run's chown -R covers them), so a fresh box has them before the |
| 180 | // mounts try to bind them. |
| 181 | func TestBootstrapCreatesTheInstanceDirectories(t *testing.T) { |
| 182 | root := t.TempDir() |
| 183 | if err := bootstrap(root, nil); err != nil { |
| 184 | t.Fatalf("bootstrap: %v", err) |
| 185 | } |
| 186 | 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"} { |
| 187 | if fi, err := os.Stat(filepath.Join(root, d)); err != nil || !fi.IsDir() { |
| 188 | t.Errorf("%s: %v", d, err) |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | // The mounts are enabled AND started by the plan: `systemctl enable` on a |
| 194 | // mount unit writes a symlink for the next boot and nothing else, the same |
| 195 | // trap timers and path units fell into (codex R1-14). A socket unit the |
| 196 | // same. The relay is still the only service the plan starts. |
| 197 | func TestSystemctlPlanStartsMountsAndSockets(t *testing.T) { |
| 198 | plan := systemctlPlan([]string{"carlos-edge.service", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"}) |
| 199 | var started []string |
| 200 | for _, args := range plan { |
| 201 | if args[0] == "start" { |
| 202 | started = append(started, args[1:]...) |
| 203 | } |
| 204 | } |
| 205 | got := strings.Join(started, " ") |
| 206 | for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", "carlos-adopt.timer", "carlos-instance-control.socket"} { |
| 207 | if !strings.Contains(got, want) { |
| 208 | t.Errorf("plan does not start %s: %v", want, plan) |
| 209 | } |
| 210 | } |
| 211 | if strings.Contains(got, "carlos-edge.service") { |
| 212 | t.Errorf("plan starts the edge: %v", plan) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | func TestEnabledUnitsIncludeTheMounts(t *testing.T) { |
| 217 | have := map[string]bool{} |
| 218 | for _, u := range enabledUnits { |
| 219 | have[u] = true |
| 220 | } |
| 221 | for _, want := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} { |
| 222 | if !have[want] { |
| 223 | t.Errorf("enabledUnits lacks %s", want) |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // The three tenant templates and the probe carry one namespace: byte- |
| 229 | // identical hardening blocks, so a property proven by the probe is the |
| 230 | // property a tenant gets (design §4.2, §8.5). |
| 231 | func TestInstanceTemplatesShareOneNamespace(t *testing.T) { |
| 232 | block := func(name string) string { |
| 233 | u := readUnit(t, name) |
| 234 | start := strings.Index(u, "# --- the namespace") |
| 235 | end := strings.Index(u, "# --- per-service resources") |
| 236 | if start < 0 || end < 0 || end < start { |
| 237 | t.Fatalf("%s: namespace block markers missing", name) |
| 238 | } |
| 239 | return u[start:end] |
| 240 | } |
| 241 | ref := block("carlos-instance@.service") |
| 242 | for _, name := range []string{"carlos-instance-gen@.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance-cmd@.service", "carlos-instance-probe.service"} { |
| 243 | if block(name) != ref { |
| 244 | t.Errorf("%s namespace block differs from carlos-instance@.service", name) |
| 245 | } |
| 246 | } |
| 247 | for _, want := range []string{ |
| 248 | "ProtectSystem=strict", "TemporaryFileSystem=/data:ro", "TemporaryFileSystem=/run/carlos:ro", |
| 249 | "TemporaryFileSystem=/var/cache:ro", "TemporaryFileSystem=/var/lib:ro", "InaccessiblePaths=/etc/carlos", |
| 250 | "TemporaryFileSystem=/dev/shm:mode=1777,nosuid,nodev", |
| 251 | "ProtectControlGroups=yes", "ProtectProc=invisible", "ProcSubset=pid", "PrivatePIDs=yes", |
| 252 | "RestrictNamespaces=yes", "NoNewPrivileges=yes", "CapabilityBoundingSet=", |
| 253 | "IPAddressDeny=localhost ", " 169.254.169.254/32 ", " fd00:ec2::254/128 ", "PrivateIPC=yes", "MemoryDenyWriteExecute=yes", |
| 254 | } { |
| 255 | if !strings.Contains(ref, want) { |
| 256 | t.Errorf("namespace block lacks %q", want) |
| 257 | } |
| 258 | } |
| 259 | // RestrictSUIDSGID= makes openat2 answer ENOSYS, and every instancefs |
| 260 | // open inside the namespace is an openat2 (#126, first roll). |
| 261 | for _, line := range strings.Split(ref, "\n") { |
| 262 | if strings.HasPrefix(line, "RestrictSUIDSGID=") { |
| 263 | t.Errorf("namespace block carries %q, which breaks openat2", line) |
| 264 | } |
| 265 | } |
| 266 | for name, wants := range map[string][]string{ |
| 267 | "carlos-instance@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I", |
| 268 | "BindPaths=/data/instances/%I /run/carlos/instances/%I", "StandardOutput=file:/run/carlos/logs/%I.run.sock", |
| 269 | // The instance's own data directory, where a flat child's |
| 270 | // relative paths now resolve (#256 D); the IE box's emergency |
| 271 | // drop-in 50-workdir-incident-256.conf carries this exact line, |
| 272 | // so removing it changes nothing. |
| 273 | "WorkingDirectory=/data/instances/%I", |
| 274 | "Slice=carlos-instances-%i.slice", "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service", |
| 275 | "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple", |
| 276 | // The trampoline's init exits 128+n for a death by signal n (#126). |
| 277 | "SuccessExitStatus=129 130 141 143"}, |
| 278 | "carlos-instance-gen@.service": {"ExecStart=/opt/carlos/carlos instance-exec run %I", "ExecStopPost=/opt/carlos/carlos instance-exit run %I", |
| 279 | "Restart=no", "KillMode=mixed", "TimeoutStopSec=20", "BindsTo=carlos-edge.service", |
| 280 | "Requires=data-instances.mount run-carlos-instances.mount run-carlos-logs.mount", "OOMScoreAdjust=500", "OOMPolicy=kill", "TasksMax=256", "Type=simple", |
| 281 | "SuccessExitStatus=129 130 141 143"}, |
| 282 | "carlos-instance-seed@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=120", "ExecStart=/opt/carlos/carlos instance-exec seed %I", |
| 283 | "ExecStopPost=/opt/carlos/carlos instance-exit seed %I", "StandardOutput=file:/run/carlos/logs/%I.seed.sock", |
| 284 | "KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"}, |
| 285 | "carlos-instance-cmd@.service": {"Type=oneshot", "RemainAfterExit=yes", "TimeoutStartSec=6h5m", "ExecStart=/opt/carlos/carlos instance-exec cmd %I", |
| 286 | "ExecStopPost=/opt/carlos/carlos instance-exit cmd %I", "StandardOutput=file:/run/carlos/logs/%I.cmd.sock", |
| 287 | "StandardError=file:/run/carlos/logs/%I.cmd-err.sock", "KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"}, |
| 288 | "carlos-instance-sidecar@.service": {"Type=simple", "ExecStart=/opt/carlos/carlos instance-exec sidecar %I", |
| 289 | "ExecStopPost=/opt/carlos/carlos instance-exit sidecar %I", "StandardOutput=file:/run/carlos/logs/%I.sidecar.sock", |
| 290 | "SuccessExitStatus=129 130 141 143", "WorkingDirectory=/data/instances/%I"}, |
| 291 | "carlos-instance-probe.service": {"Type=oneshot", "ExecStart=/opt/carlos/carlos instance-probe", |
| 292 | "BindPaths=/data/instances/_probe /run/carlos/instances/_probe", "StandardOutput=truncate:/run/carlos-control/probe.out"}, |
| 293 | } { |
| 294 | u := readUnit(t, name) |
| 295 | for _, w := range wants { |
| 296 | if !strings.Contains(u, w) { |
| 297 | t.Errorf("%s lacks %q", name, w) |
| 298 | } |
| 299 | } |
| 300 | // Exactly one WorkingDirectory= per tenant template, and none in |
| 301 | // the probe, whose directory is _probe and whose cwd does not |
| 302 | // matter: a second line would silently win (#256 D). |
| 303 | if n := strings.Count(u, "\nWorkingDirectory="); (name != "carlos-instance-probe.service" && n != 1) || (name == "carlos-instance-probe.service" && n != 0) { |
| 304 | t.Errorf("%s has %d WorkingDirectory= lines", name, n) |
| 305 | } |
| 306 | if strings.Contains(u, "ExecStopPost=+") { |
| 307 | t.Errorf("%s uses ExecStopPost=+: the plus prefix keeps the unit's tmpfs (codex R3-2)", name) |
| 308 | } |
| 309 | // A seed a signal ended has not seeded (#126). |
| 310 | if (name == "carlos-instance-seed@.service" || name == "carlos-instance-cmd@.service") && strings.Contains(u, "\nSuccessExitStatus=") { |
| 311 | t.Errorf("%s counts a signal's exit status as success", name) |
| 312 | } |
| 313 | if name != "carlos-instance-probe.service" && !strings.Contains(u, "User=carlos") { |
| 314 | t.Errorf("%s does not run as carlos", name) |
| 315 | } |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | func TestControlUnitsShape(t *testing.T) { |
| 320 | s := readUnit(t, "carlos-instance-control.socket") |
| 321 | for _, want := range []string{"ListenStream=/run/carlos-control/control.sock", "SocketUser=carlos", "SocketGroup=carlos", "SocketMode=0600", "Accept=no", "WantedBy=sockets.target"} { |
| 322 | if !strings.Contains(s, want) { |
| 323 | t.Errorf("socket unit lacks %q", want) |
| 324 | } |
| 325 | } |
| 326 | u := readUnit(t, "carlos-instance-control.service") |
| 327 | for _, want := range []string{"ExecStart=/opt/carlos/carlos instance-control", "Requires=carlos-instance-control.socket", |
| 328 | "data-instances.mount", "ProtectSystem=strict", "ReadWritePaths=/run/carlos-control /run/systemd/system", "RestrictAddressFamilies=AF_UNIX", "MemoryMax=64M"} { |
| 329 | if !strings.Contains(u, want) { |
| 330 | t.Errorf("control service lacks %q", want) |
| 331 | } |
| 332 | } |
| 333 | for _, never := range []string{"User=", "NoNewPrivileges"} { |
| 334 | if strings.Contains(u, never) { |
| 335 | t.Errorf("control service must run as root for systemctl; found %q", never) |
| 336 | } |
| 337 | } |
| 338 | have := map[string]bool{} |
| 339 | for _, e := range enabledUnits { |
| 340 | have[e] = true |
| 341 | } |
| 342 | if !have["carlos-instance-control.socket"] { |
| 343 | t.Error("enabledUnits lacks the control socket") |
| 344 | } |
| 345 | if have["carlos-instance-control.service"] || have["carlos-instance-probe.service"] || have["carlos-instance@.service"] || have["carlos-instance-gen@.service"] { |
| 346 | t.Error("a template, the probe or the socket-activated service must not be enabled") |
| 347 | } |
| 348 | for _, want := range []string{"d /data/.instances/_probe 0700 carlos carlos -", "d /run/carlos/.instances/_probe 2750 carlos carlos-sockets -"} { |
| 349 | if !strings.Contains(tmpfilesConf, want+"\n") { |
| 350 | t.Errorf("tmpfilesConf lacks %q", want) |
| 351 | } |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // The same-uid audit (design §9, codex R9-1, R9-2, R10-1): every tree a |
| 356 | // carlos-uid unit writes, every carlos-owned cgroup, and every credential a |
| 357 | // carlos-group process reads must be hidden from the tenant templates. |
| 358 | func TestSameUIDAuditIsCoveredByTheTemplates(t *testing.T) { |
| 359 | ns := readUnit(t, "carlos-instance@.service") |
| 360 | hidden := func(path string) bool { |
| 361 | for _, line := range strings.Split(ns, "\n") { |
| 362 | for _, key := range []string{"TemporaryFileSystem=", "InaccessiblePaths="} { |
| 363 | if strings.HasPrefix(line, key) { |
| 364 | for _, p := range strings.Fields(strings.TrimPrefix(line, key)) { |
| 365 | p = strings.TrimSuffix(p, ":ro") |
| 366 | if path == p || strings.HasPrefix(path, p+"/") { |
| 367 | return true |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | return false |
| 374 | } |
| 375 | names, err := unitNames() |
| 376 | if err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | for _, name := range names { |
| 380 | u := readUnit(t, name) |
| 381 | if !strings.Contains(u, "User=carlos") { |
| 382 | continue |
| 383 | } |
| 384 | for _, line := range strings.Split(u, "\n") { |
| 385 | for _, key := range []string{"ReadWritePaths=", "CacheDirectory=", "StateDirectory=", "RuntimeDirectory="} { |
| 386 | if !strings.HasPrefix(line, key) { |
| 387 | continue |
| 388 | } |
| 389 | for _, p := range strings.Fields(strings.TrimPrefix(line, key)) { |
| 390 | switch key { |
| 391 | case "CacheDirectory=": |
| 392 | p = "/var/cache/" + p |
| 393 | case "StateDirectory=": |
| 394 | p = "/var/lib/" + p |
| 395 | case "RuntimeDirectory=": |
| 396 | p = "/run/" + p |
| 397 | } |
| 398 | if !hidden(p) && !strings.HasPrefix(p, "/run/carlos/instances/") && !strings.HasPrefix(p, "/data/instances/") { |
| 399 | t.Errorf("%s writes %s, which the tenant template does not hide", name, p) |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | if strings.Contains(u, "Delegate=") && !strings.Contains(ns, "ProtectControlGroups=yes") { |
| 405 | t.Errorf("%s delegates a cgroup subtree and the template leaves cgroupfs writable", name) |
| 406 | } |
| 407 | } |
| 408 | // Credential inputs a carlos-group process reads (the second table). |
| 409 | for _, cred := range []string{"/etc/carlos/relay-masters"} { |
| 410 | if !hidden(cred) { |
| 411 | t.Errorf("credential input %s is not hidden from tenants", cred) |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | // The cmd template is the seed template outside seven named lines (run |
| 417 | // spec §5.4, codex 4; the executable mount's own sources, merge ruling |
| 418 | // M4): every sandbox line is the seed's, so a command has exactly the |
| 419 | // reach a seed has. Comments and blank lines are free; every |
| 420 | // directive is compared, in order. |
| 421 | func TestCmdTemplateEqualsSeedOutsideTheNamedLines(t *testing.T) { |
| 422 | directives := func(name string) []string { |
| 423 | var out []string |
| 424 | for _, line := range strings.Split(readUnit(t, name), "\n") { |
| 425 | line = strings.TrimSpace(line) |
| 426 | if line == "" || strings.HasPrefix(line, "#") { |
| 427 | continue |
| 428 | } |
| 429 | out = append(out, line) |
| 430 | } |
| 431 | return out |
| 432 | } |
| 433 | swap := map[string]string{ |
| 434 | "Description=carlos exec-backed instance %I (seed)": "Description=carlos exec-backed instance %I (cmd)", |
| 435 | "TimeoutStartSec=120": "TimeoutStartSec=6h5m", |
| 436 | "ExecStart=/opt/carlos/carlos instance-exec seed %I": "ExecStart=/opt/carlos/carlos instance-exec cmd %I", |
| 437 | "ExecStopPost=/opt/carlos/carlos instance-exit seed %I": "ExecStopPost=/opt/carlos/carlos instance-exit cmd %I", |
| 438 | "StandardOutput=file:/run/carlos/logs/%I.seed.sock": "StandardOutput=file:/run/carlos/logs/%I.cmd.sock", |
| 439 | "StandardError=inherit": "StandardError=file:/run/carlos/logs/%I.cmd-err.sock", |
| 440 | "BindReadOnlyPaths=/run/carlos-control/exe/%I/app:/opt/carlos/app /run/carlos-control/exe/%I/live:/opt/carlos/live": "BindReadOnlyPaths=/run/carlos-control/exe/%I/cmd-app:/opt/carlos/app /run/carlos-control/exe/%I/cmd-live:/opt/carlos/live", |
| 441 | } |
| 442 | seed, cmd := directives("carlos-instance-seed@.service"), directives("carlos-instance-cmd@.service") |
| 443 | want := make([]string, len(seed)) |
| 444 | used := map[string]bool{} |
| 445 | for i, l := range seed { |
| 446 | if c, ok := swap[l]; ok { |
| 447 | want[i], used[l] = c, true |
| 448 | continue |
| 449 | } |
| 450 | want[i] = l |
| 451 | } |
| 452 | for l := range swap { |
| 453 | if !used[l] { |
| 454 | t.Errorf("the seed template no longer has %q; the run spec names it as a cmd difference", l) |
| 455 | } |
| 456 | } |
| 457 | for i := 0; i < len(want) || i < len(cmd); i++ { |
| 458 | var w, g string |
| 459 | if i < len(want) { |
| 460 | w = want[i] |
| 461 | } |
| 462 | if i < len(cmd) { |
| 463 | g = cmd[i] |
| 464 | } |
| 465 | if w != g { |
| 466 | t.Fatalf("directive %d: cmd template has %q, want %q (the seed's line with the named swaps)", i, g, w) |
| 467 | } |
| 468 | } |
| 469 | for _, keep := range []string{"Type=oneshot", "RemainAfterExit=yes", "KillMode=mixed", "TimeoutStopSec=20", "WorkingDirectory=/data/instances/%I"} { |
| 470 | if !hasDirective(readUnit(t, "carlos-instance-cmd@.service"), keep) { |
| 471 | t.Errorf("cmd template lacks %q", keep) |
| 472 | } |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | // The generation template's instance string is "<iid>:<n>" (zero-downtime |
| 477 | // spec §7.6, plan 2 D1). No specifier can take the instance ID out of it, |
| 478 | // so the template keys NOTHING by %i or %I but the trampoline's argument. |
| 479 | // The slice, the data and executable mounts and the log socket come from |
| 480 | // the control service's per-generation drop-in (instancectl.GenDropIn). |
| 481 | func TestGenerationTemplateKeysNothingByItsInstanceString(t *testing.T) { |
| 482 | u := readUnit(t, "carlos-instance-gen@.service") |
| 483 | for _, d := range directives(u) { |
| 484 | for _, key := range []string{"Slice=", "BindPaths=", "ReadWritePaths=", "StandardOutput="} { |
| 485 | if strings.HasPrefix(d, key) { |
| 486 | t.Errorf("generation template carries %q; it belongs in the drop-in", d) |
| 487 | } |
| 488 | } |
| 489 | if strings.HasPrefix(d, "BindReadOnlyPaths=") && strings.Contains(d, "/run/carlos-control/exe") { |
| 490 | t.Errorf("generation template binds an executable itself: %q", d) |
| 491 | } |
| 492 | if strings.Contains(d, "/opt/carlos/versions") { |
| 493 | t.Errorf("generation template names the version store: %q", d) |
| 494 | } |
| 495 | if strings.Contains(d, "%i") || (strings.Contains(d, "%I") && !strings.HasPrefix(d, "ExecStart=") && !strings.HasPrefix(d, "ExecStopPost=") && !strings.HasPrefix(d, "Description=")) { |
| 496 | t.Errorf("generation template keys %q by its instance string", d) |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | |