carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func 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.
22func 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).
84func 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.
130func 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.
151func 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.
181func 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.
197func 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
216func 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).
231func 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
319func 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.
358func 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.
421func 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).
481func 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