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 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/base64"
8 "encoding/json"
9 "fmt"
10 "log"
11 "net"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "reflect"
16 "runtime"
17 "slices"
18 "strings"
19 "testing"
20 "time"
21
22 "github.com/carlosframework/platform/internal/domains"
23 "github.com/carlosframework/platform/internal/metrics"
24 "github.com/carlosframework/platform/internal/moves"
25 "github.com/carlosframework/platform/internal/registry"
26 "github.com/carlosframework/platform/internal/release"
27 "github.com/carlosframework/platform/internal/status"
28)
29
30// expectedUnits is the contract for what bootstrap installs — a new unit file
31// must be added here (and a removed one taken out) or TestUnitsEmbedComplete
32// fails, so the embed directive and the fleet's unit set can't drift apart.
33var expectedUnits = []string{
34 "carlos-adopt.path",
35 "carlos-adopt.service",
36 "carlos-adopt.timer",
37 "carlos-app@.service",
38 "carlos-app@.socket",
39 "carlos-config-sync.service",
40 "carlos-dbread.socket",
41 "carlos-dbread@.service",
42 "carlos-edge.service",
43 "carlos-hibernate-verify.service",
44 "carlos-hibernate-verify.timer",
45 "carlos-instance-cmd@.service",
46 "carlos-instance-control.service",
47 "carlos-instance-control.socket",
48 "carlos-instance-gen@.service",
49 "carlos-instance-probe.service",
50 "carlos-instance-seed@.service",
51 "carlos-instance-sidecar@.service",
52 "carlos-instance@.service",
53 "carlos-instances.slice",
54 "carlos-litestream.service",
55 "carlos-relay.service",
56 "carlos-restart.path",
57 "carlos-restart.service",
58 "carlos-restore-verify.service",
59 "carlos-restore-verify.timer",
60 "carlos-stage.path",
61 "carlos-stage.service",
62 "carlos-system-join.path",
63 "carlos-system-join.service",
64 "carlos-system-recover.service",
65 "carlos-system-update.path",
66 "carlos-system-update.service",
67 "carlos.slice",
68 "data-instances.mount",
69 "run-carlos-instances.mount",
70 "run-carlos-logs.mount",
71}
72
73// requireUnixSockets skips a test when the sandbox forbids unix-socket
74// creation (probe: net.Listen("unix") in a temp dir). None of the current
75// tests need one; the helper is here for the host tests that will (agent
76// serving loops), matching the pattern used across the repo.
77func requireUnixSockets(t *testing.T) {
78 t.Helper()
79 dir, err := os.MkdirTemp("", "unix-probe-")
80 if err != nil {
81 t.Fatal(err)
82 }
83 t.Cleanup(func() { os.RemoveAll(dir) })
84 l, err := net.Listen("unix", filepath.Join(dir, "probe.sock"))
85 if err != nil {
86 if strings.Contains(err.Error(), "operation not permitted") {
87 t.Skipf("sandbox forbids unix sockets: %v", err)
88 }
89 t.Fatalf("unix socket probe: %v", err)
90 }
91 l.Close()
92}
93
94func TestUnitsEmbedComplete(t *testing.T) {
95 names, err := unitNames()
96 if err != nil {
97 t.Fatal(err)
98 }
99 got := map[string]bool{}
100 for _, n := range names {
101 got[n] = true
102 }
103 for _, want := range expectedUnits {
104 if !got[want] {
105 t.Errorf("unit %s missing from the embed FS", want)
106 }
107 }
108 if len(names) != len(expectedUnits) {
109 t.Errorf("embed FS has %d units %v, expected list has %d — keep them in step", len(names), names, len(expectedUnits))
110 }
111}
112
113// TestBootstrapStartsRelayUnit pins the one service bootstrap starts and the
114// one it must not. The relay is safe to start fleet-wide because its
115// ConditionPathExists gates it on a masters file; the edge is not, and a
116// change that quietly promoted every service into the start line would arm
117// half-staged boxes.
118func TestBootstrapStartsRelayUnit(t *testing.T) {
119 plan := systemctlPlan(enabledUnits)
120 var started []string
121 for _, step := range plan {
122 if step[0] == "start" {
123 started = step[1:]
124 }
125 }
126 if !slices.Contains(started, "carlos-relay.service") {
127 t.Fatalf("relay not started by plan: %v", plan)
128 }
129 if slices.Contains(started, "carlos-edge.service") {
130 t.Fatalf("edge must stay start-by-hand: %v", plan)
131 }
132}
133
134// TestRelayUnitIsGatedAndLosesFirst pins the three lines that make it safe
135// to enable and start carlos-relay.service on every box in the fleet. The
136// condition is what keeps an unarmed box from ever listening; the OOM
137// adjustment and the absence of the bind capability are what keep the relay
138// from costing the edge anything when they share a box (relay spec §3).
139func TestRelayUnitIsGatedAndLosesFirst(t *testing.T) {
140 b, err := unitsFS.ReadFile("units/" + relayUnitName)
141 if err != nil {
142 t.Fatal(err)
143 }
144 unit := string(b)
145 for _, want := range []string{
146 "ConditionPathExists=/etc/carlos/relay-masters",
147 "OOMScoreAdjust=1000",
148 "MemoryMax=256M",
149 "ExecStart=/opt/carlos/carlos relay serve",
150 "ReadWritePaths=/data/relay /run/carlos",
151 } {
152 if !strings.Contains(unit, want) {
153 t.Errorf("%s: missing %q", relayUnitName, want)
154 }
155 }
156 // 3478 is unprivileged; only the edge may bind below 1024.
157 if strings.Contains(unit, "CAP_NET_BIND_SERVICE") {
158 t.Errorf("%s: must not carry CAP_NET_BIND_SERVICE — 3478 needs no capability", relayUnitName)
159 }
160}
161
162func TestBootstrapWritesUnitsToStagingDir(t *testing.T) {
163 root := t.TempDir()
164 if err := bootstrap(root, nil); err != nil {
165 t.Fatalf("bootstrap: %v", err)
166 }
167
168 unitDir := filepath.Join(root, "etc", "systemd", "system")
169 for _, name := range expectedUnits {
170 b, err := os.ReadFile(filepath.Join(unitDir, name))
171 if err != nil {
172 t.Errorf("unit not installed: %v", err)
173 continue
174 }
175 content := string(b)
176 // Only the edge binds privileged ports; every other unit must not
177 // carry the capability.
178 if name == "carlos-edge.service" {
179 if !strings.Contains(content, "AmbientCapabilities=CAP_NET_BIND_SERVICE") {
180 t.Errorf("%s: missing AmbientCapabilities=CAP_NET_BIND_SERVICE", name)
181 }
182 } else if strings.Contains(content, "CAP_NET_BIND_SERVICE") {
183 t.Errorf("%s: must not carry CAP_NET_BIND_SERVICE", name)
184 }
185 }
186
187 // The edge unit is the agent role.
188 edge, err := os.ReadFile(filepath.Join(unitDir, "carlos-edge.service"))
189 if err != nil {
190 t.Fatal(err)
191 }
192 for _, want := range []string{
193 "ExecStart=/opt/carlos/carlos agent",
194 "EnvironmentFile=-/etc/carlos/host.env",
195 "ProtectSystem=strict",
196 "ReadWritePaths=/data /run/carlos",
197 "NoNewPrivileges=yes",
198 "MemoryDenyWriteExecute=yes",
199 "SystemCallFilter=@system-service",
200 "User=carlos",
201 "Restart=always",
202 // Delegation is the SOLE mechanism behind exec-instance readings
203 // fleet-wide (exec-child accounting spec): without it the agent's
204 // cgroup manager degrades to nil and every exec-backed tenant goes
205 // blind on the Instances page.
206 "Delegate=cpu memory io pids",
207 // The outer wall (memory-pressure spec §2.2) is now on carlos.slice
208 // (isolated-exec design §4.3). The edge is in the slice, so it still
209 // participates in the ceiling. OOMPolicy=continue is the line that
210 // keeps one tenant's OOM kill from becoming a whole-edge restart
211 // (systemd's default is stop).
212 "Slice=carlos.slice",
213 "OOMPolicy=continue",
214 } {
215 if !strings.Contains(string(edge), want) {
216 t.Errorf("carlos-edge.service: missing %q", want)
217 }
218 }
219
220 // The outer wall moved from the edge to carlos.slice (isolated-exec
221 // design §4.3): the slice bounds the edge and every awake instance
222 // as before, but instances are now sibling units instead of children.
223 slice, err := os.ReadFile(filepath.Join(unitDir, "carlos.slice"))
224 if err != nil {
225 t.Fatal(err)
226 }
227 for _, want := range []string{
228 "MemoryHigh=70%",
229 "MemoryMax=80%",
230 } {
231 if !strings.Contains(string(slice), want) {
232 t.Errorf("carlos.slice: missing %q", want)
233 }
234 }
235 // The counterpart of Delegate=: ProtectControlGroups would mount the
236 // cgroup tree read-only and silently un-arm delegation. Its hardening is
237 // retained by ownership (DAC) instead — see the unit file's comment. A
238 // future "hardening cleanup" that re-adds it must fail here, not on the
239 // fleet.
240 if strings.Contains(string(edge), "ProtectControlGroups=") {
241 t.Error("carlos-edge.service: the ProtectControlGroups= directive is deliberately absent (Delegate= needs the subtree writable); do not re-add it")
242 }
243
244 // The tenant socket unit: systemd owns the listener as carlos so the
245 // edge can connect and tenants can't reach each other (the whole point).
246 sockUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-app@.socket"))
247 if err != nil {
248 t.Fatal(err)
249 }
250 for _, want := range []string{
251 "ListenStream=/run/carlos/apps/%i.sock",
252 "SocketUser=carlos",
253 "SocketMode=0600",
254 "WantedBy=sockets.target",
255 } {
256 if !strings.Contains(string(sockUnit), want) {
257 t.Errorf("carlos-app@.socket: missing %q", want)
258 }
259 }
260
261 // The config fast-convergence relay (issue #44). The invariant worth a
262 // test is WHICH unit the path starts: carlos-adopt.service also runs
263 // release.Pass and the canary-TTL nag, so aiming a per-config-write
264 // trigger at it would let one app's `carlos env set` cut over another
265 // app's pending release seconds later and nag on every write instead of
266 // hourly. The relay must reach the config half alone.
267 pathUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-adopt.path"))
268 if err != nil {
269 t.Fatal(err)
270 }
271 for _, want := range []string{
272 "PathChanged=/data/config-sync-requested",
273 "Unit=carlos-config-sync.service",
274 "WantedBy=multi-user.target",
275 } {
276 if !strings.Contains(string(pathUnit), want) {
277 t.Errorf("carlos-adopt.path: missing %q", want)
278 }
279 }
280 if strings.Contains(string(pathUnit), "Unit=carlos-adopt.service") {
281 t.Error("carlos-adopt.path must not start carlos-adopt.service — that unit adopts releases and nags on expired canaries")
282 }
283
284 // The release fast-convergence relay, and the same invariant: which unit
285 // the path starts. carlos-adopt.service prints the canary-TTL nag, an
286 // HOURLY visibility feature; this trigger fires on agent request, so
287 // aiming it there would repeat the same expired-canary lines every time a
288 // promotion could not be staged.
289 stagePath, err := os.ReadFile(filepath.Join(unitDir, "carlos-stage.path"))
290 if err != nil {
291 t.Fatal(err)
292 }
293 for _, want := range []string{
294 "PathChanged=/data/stage-requested",
295 "Unit=carlos-stage.service",
296 "WantedBy=multi-user.target",
297 } {
298 if !strings.Contains(string(stagePath), want) {
299 t.Errorf("carlos-stage.path: missing %q", want)
300 }
301 }
302 if strings.Contains(string(stagePath), "Unit=carlos-adopt.service") {
303 t.Error("carlos-stage.path must not start carlos-adopt.service — that unit nags on expired canaries, and this trigger is per-request")
304 }
305
306 // The staging pass must run -stage-only, and must NOT be startable by
307 // anything but its path unit.
308 stageSvc, err := os.ReadFile(filepath.Join(unitDir, "carlos-stage.service"))
309 if err != nil {
310 t.Fatal(err)
311 }
312 if !strings.Contains(string(stageSvc), "carlos adopt --stage-only") {
313 t.Error("carlos-stage.service must run `carlos adopt --stage-only` — the full pass would nag per request")
314 }
315 // A SECTION header, not any mention: the unit's own comment explains why
316 // it has no [Install], and a substring match would fail on the
317 // explanation. Same shape as carlos-config-sync.service, which documents
318 // the same choice the same way.
319 for _, line := range strings.Split(string(stageSvc), "\n") {
320 if strings.TrimSpace(line) == "[Install]" {
321 t.Error("carlos-stage.service must have no [Install] section: only carlos-stage.path should ever start it")
322 }
323 }
324
325 syncUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-config-sync.service"))
326 if err != nil {
327 t.Fatal(err)
328 }
329 for _, want := range []string{
330 "ExecStart=/opt/carlos/carlos adopt --config-only",
331 "Type=oneshot",
332 "EnvironmentFile=-/etc/carlos/host.env",
333 // A path-triggered unit must never latch into start-limit-hit after a
334 // burst of config writes and then ignore every later trigger.
335 "StartLimitIntervalSec=0",
336 } {
337 if !strings.Contains(string(syncUnit), want) {
338 t.Errorf("carlos-config-sync.service: missing %q", want)
339 }
340 }
341 // Started only by the path unit: an [Install] section would invite an
342 // operator to enable a oneshot that has nothing to do at boot. Matched
343 // as a whole line, since the unit's own comment names the section.
344 for _, line := range strings.Split(string(syncUnit), "\n") {
345 if strings.TrimSpace(line) == "[Install]" {
346 t.Error("carlos-config-sync.service must have no [Install] section — carlos-adopt.path is its only starter")
347 }
348 }
349
350 // The standard dirs.
351 for _, d := range []string{
352 filepath.Join(root, "data"),
353 filepath.Join(root, "opt", "carlos", "versions"),
354 filepath.Join(root, "opt", "carlos", "live"),
355 filepath.Join(root, "etc", "carlos"),
356 filepath.Join(root, "run", "carlos"),
357 filepath.Join(root, "run", "carlos", "apps"),
358 } {
359 fi, err := os.Stat(d)
360 if err != nil {
361 t.Errorf("dir not created: %v", err)
362 continue
363 }
364 if !fi.IsDir() {
365 t.Errorf("%s: not a directory", d)
366 }
367 }
368
369 // The tmpfiles.d entry that keeps /run/carlos across reboots.
370 tf, err := os.ReadFile(filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf"))
371 if err != nil {
372 t.Fatalf("tmpfiles conf: %v", err)
373 }
374 if !strings.Contains(string(tf), "/run/carlos") {
375 t.Errorf("tmpfiles conf %q does not mention /run/carlos", tf)
376 }
377 if !strings.Contains(string(tf), "/run/carlos/apps") {
378 t.Errorf("tmpfiles conf %q does not keep the tenant socket dir", tf)
379 }
380}
381
382// TestResolveStatusBucketDefaultsToDeploymentBucket pins the I2 fix: the
383// status loop and the deployment (release-adoption) loop must agree on which
384// bucket to serve status out of by default, since the edge/console read
385// status/ from the deployment bucket — an explicit CARLOS_STATUS_BUCKET
386// still wins when set, and a CARLOS_DEPLOYMENT_DIR-only deployment (no S3
387// bucket to fall back to) gets no default at all.
388func TestResolveStatusBucketDefaultsToDeploymentBucket(t *testing.T) {
389 env := func(m map[string]string) func(string) string {
390 return func(k string) string { return m[k] }
391 }
392
393 cases := []struct {
394 name string
395 env map[string]string
396 want string
397 }{
398 {"explicit status bucket wins", map[string]string{
399 "CARLOS_STATUS_BUCKET": "status-bucket", "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "status-bucket"},
400 {"falls back to deployment bucket when status unset", map[string]string{
401 "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "deploy-bucket"},
402 {"neither set is still neither set", map[string]string{}, ""},
403 {"deployment dir gives no fallback (S3-only)", map[string]string{
404 "CARLOS_DEPLOYMENT_DIR": "/data/deploy"}, ""},
405 }
406 for _, c := range cases {
407 t.Run(c.name, func(t *testing.T) {
408 if got := resolveStatusBucket(env(c.env)); got != c.want {
409 t.Errorf("resolveStatusBucket(%v) = %q, want %q", c.env, got, c.want)
410 }
411 })
412 }
413}
414
415// ---- Task 8: statusTick — the loop's pure part -----------------------------
416
417// TestStatusTickWritesBoxAndPublishesOnlyWhenAsked is the plan's Step-1 test:
418// statusTick against a DirStore + fixture registry must always land the
419// box's own snapshot under status/boxes/, but only land the merged
420// status/fleet.json (+ the embedded page assets PublishFleet ships beside
421// it) when publish=true — publish=false is the "another box holds the
422// lease" tick, which must still self-report without touching the fleet-wide
423// objects.
424func TestStatusTickWritesBoxAndPublishesOnlyWhenAsked(t *testing.T) {
425 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
426 if err != nil {
427 t.Fatal(err)
428 }
429 if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "static"}); err != nil {
430 t.Fatal(err)
431 }
432
433 store := release.NewDirStore(t.TempDir())
434 probes := status.Probes{MachineID: "test-machine"}
435 ctx := context.Background()
436
437 if err := statusTick(ctx, store, reg, probes, false, ""); err != nil {
438 t.Fatalf("statusTick (publish=false): %v", err)
439 }
440 boxes, err := store.List(ctx, "status/boxes/")
441 if err != nil {
442 t.Fatalf("List status/boxes/: %v", err)
443 }
444 if len(boxes) != 1 {
445 t.Fatalf("status/boxes/ = %v, want exactly one snapshot", boxes)
446 }
447 if _, _, err := store.Get(ctx, "status/fleet.json"); err == nil {
448 t.Error("publish=false must not write status/fleet.json")
449 }
450
451 if err := statusTick(ctx, store, reg, probes, true, ""); err != nil {
452 t.Fatalf("statusTick (publish=true): %v", err)
453 }
454 for _, key := range []string{"status/fleet.json", "status/index.html", "status/status.css"} {
455 if _, _, err := store.Get(ctx, key); err != nil {
456 t.Errorf("publish=true must write %s: %v", key, err)
457 }
458 }
459 // The box snapshot count must stay at one: the second tick republishes
460 // the SAME box's key (WriteBox is last-writer-wins on the label), not a
461 // second file.
462 boxes, err = store.List(ctx, "status/boxes/")
463 if err != nil {
464 t.Fatalf("List status/boxes/ after publish tick: %v", err)
465 }
466 if len(boxes) != 1 {
467 t.Errorf("status/boxes/ = %v after second tick, want still exactly one snapshot", boxes)
468 }
469}
470
471// ---- Task 5 (customer-fleets plan): publisher guard + fleet-prefixed store -
472
473// TestShouldSkipPublisherElection pins the CARLOS_FLEET contract: a fleet
474// box (CARLOS_FLEET set to anything non-empty) must skip the publisher
475// election, and a platform box (unset, the every-deployment-today case)
476// must not. statusLoop checks this BEFORE calling lease.Acquire — see the
477// call site — so this also documents that the decision needs no lease, no
478// bucket, no network at all: it's a pure function of one env var.
479func TestShouldSkipPublisherElection(t *testing.T) {
480 env := func(m map[string]string) func(string) string {
481 return func(k string) string { return m[k] }
482 }
483 cases := []struct {
484 name string
485 env map[string]string
486 want bool
487 }{
488 {"fleet box skips the election", map[string]string{"CARLOS_FLEET": "pi"}, true},
489 {"platform box still elects", map[string]string{}, false},
490 {"empty CARLOS_FLEET is not set", map[string]string{"CARLOS_FLEET": ""}, false},
491 }
492 for _, c := range cases {
493 t.Run(c.name, func(t *testing.T) {
494 if got := shouldSkipPublisherElection(env(c.env)); got != c.want {
495 t.Errorf("shouldSkipPublisherElection(%v) = %v, want %v", c.env, got, c.want)
496 }
497 })
498 }
499}
500
501// TestFleetBoxHeartbeatLandsUnderFleetPrefixNeverPublishes is the end-to-end
502// pin for Task 5's whole contract, composing release.NewPrefixStore (whose
503// own contract is pinned in isolation by internal/release's
504// TestNewPrefixStoreRebasesKeys) with statusTick: a fleet box's tick (store
505// wrapped in CARLOS_STATUS_PREFIX="fleets/pi/", publish forced false the way
506// shouldSkipPublisherElection drives statusLoop) writes ITS OWN Box
507// snapshot at "fleets/pi/status/boxes/<label>.json" in the underlying
508// bucket — never at the shared "status/boxes/<label>.json" a platform box
509// uses, and never status/fleet.json either.
510//
511// Critical 1b (Opus review, fix round, controller ruling): <label> here MUST
512// be the fleet box's operator-chosen fleet_boxes.label — resolveFleetIdentity's
513// result, passed as statusTick's labelOverride — NEVER
514// status.Label(probes.MachineID)'s hashed pseudonym. The public status
515// page's anonymization rationale for that hash doesn't apply to a path
516// (fleets/<fleet>/status/boxes/…) no public page ever reads; using the
517// hashed value here would leave the heartbeat filename unrelated to the
518// name anything else (the edge's tunnel hub, a fleet binding) calls this
519// box by.
520func TestFleetBoxHeartbeatLandsUnderFleetPrefixNeverPublishes(t *testing.T) {
521 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
522 if err != nil {
523 t.Fatal(err)
524 }
525 if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "static"}); err != nil {
526 t.Fatal(err)
527 }
528
529 under := release.NewDirStore(t.TempDir())
530 fleetStore := release.NewPrefixStore(under, "fleets/pi/")
531 ctx := context.Background()
532 // MachineID deliberately differs from the fleet-box label passed below —
533 // proving the filename tracks the identity override, not the probe.
534 probes := status.Probes{MachineID: "some-hashed-machine-id"}
535
536 // publish=false is what shouldSkipPublisherElection forces statusLoop
537 // into for a fleet box — lease.Acquire is never even called.
538 const fleetBoxLabel = "amber-gliding"
539 if err := statusTick(ctx, fleetStore, reg, probes, false, fleetBoxLabel); err != nil {
540 t.Fatalf("statusTick: %v", err)
541 }
542
543 // The filename is pinned to the CHANNEL label, not a hash of MachineID.
544 if _, _, err := under.Get(ctx, "fleets/pi/status/boxes/"+fleetBoxLabel+".json"); err != nil {
545 t.Fatalf("Get fleets/pi/status/boxes/%s.json: %v", fleetBoxLabel, err)
546 }
547
548 boxes, err := under.List(ctx, "fleets/pi/status/boxes/")
549 if err != nil {
550 t.Fatal(err)
551 }
552 if len(boxes) != 1 {
553 t.Fatalf("fleets/pi/status/boxes/ = %v, want exactly one snapshot", boxes)
554 }
555
556 // The shared platform namespace must stay untouched: no box snapshot,
557 // and definitely no fleet.json (which would only ever appear had
558 // publish been true).
559 sharedBoxes, err := under.List(ctx, "status/boxes/")
560 if err != nil {
561 t.Fatal(err)
562 }
563 if len(sharedBoxes) != 0 {
564 t.Errorf("status/boxes/ = %v, want none — a fleet box must never write the shared namespace", sharedBoxes)
565 }
566 if _, _, err := under.Get(ctx, "status/fleet.json"); err == nil {
567 t.Error("a fleet box must never publish status/fleet.json")
568 }
569}
570
571// ---- Task 6 (multi-upstream plan): per-upstream probe state in the snapshot -
572
573// TestStatusTickCarriesProbedUpstreams is Task 6's Step-1 test: the state the
574// edge's probers hold for a multi-upstream route must reach the box's OWN
575// snapshot object through the real tick — statusTick over a DirStore, read
576// back as the bytes an operator (or the console) would parse — not a
577// hand-built Box.
578//
579// It drives the same seam production does: setUpstreamSource is what RunAgent
580// hands the live *edge.Edge's UpstreamStates method to from the edge-ready
581// callback, and upstreamStates() is what statusLoop reads on every tick. The
582// fixture stands in for the Edge here for the same reason every other probe in
583// this file is injected — a real prober would need a live listener and a
584// goroutine this package cannot stop.
585func TestStatusTickCarriesProbedUpstreams(t *testing.T) {
586 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
587 if err != nil {
588 t.Fatal(err)
589 }
590 if err := reg.Upsert(registry.Route{Host: "ha.example.com", Kind: "service", Status: "ready"}); err != nil {
591 t.Fatal(err)
592 }
593
594 store := release.NewDirStore(t.TempDir())
595 ctx := context.Background()
596 since := time.Now().Add(-90 * time.Second).UTC().Truncate(time.Second)
597
598 setUpstreamSource(func() map[string][]status.UpstreamState {
599 return map[string][]status.UpstreamState{"ha.example.com": {
600 {Addr: "10.0.0.11:8080", Up: true, Since: since},
601 {Addr: "10.0.0.12:8080", Up: false, Since: since, Fails: 3, LastError: "dial: connection refused"},
602 }}
603 })
604 t.Cleanup(func() { setUpstreamSource(nil) })
605
606 // Exactly what statusLoop builds each tick.
607 probes := status.Probes{MachineID: "test-machine", Upstreams: upstreamStates()}
608 if err := statusTick(ctx, store, reg, probes, false, ""); err != nil {
609 t.Fatalf("statusTick: %v", err)
610 }
611
612 key := "status/boxes/" + status.Label("test-machine") + ".json"
613 raw, _, err := store.Get(ctx, key)
614 if err != nil {
615 t.Fatalf("Get %s: %v", key, err)
616 }
617 var box status.Box
618 if err := json.Unmarshal(raw, &box); err != nil {
619 t.Fatalf("unmarshal %s: %v", key, err)
620 }
621 if box.Metrics == nil {
622 t.Fatalf("metrics block missing from %s: %s", key, raw)
623 }
624 got := box.Metrics.Upstreams["ha.example.com"]
625 want := []status.UpstreamState{
626 {Addr: "10.0.0.11:8080", Up: true, Since: since},
627 {Addr: "10.0.0.12:8080", Up: false, Since: since, Fails: 3, LastError: "dial: connection refused"},
628 }
629 if !reflect.DeepEqual(got, want) {
630 t.Errorf("upstreams for ha.example.com = %+v, want %+v: %s", got, want, raw)
631 }
632
633 // Staleness: the map is rebuilt from the prober's State() every tick, so a
634 // box that stops probing a route (route deleted, addrs cleared) must leave
635 // NO ghost entry behind in the next snapshot — and with nothing probed at
636 // all the key must be absent, not an empty object.
637 setUpstreamSource(func() map[string][]status.UpstreamState { return nil })
638 probes.Upstreams = upstreamStates()
639 if err := statusTick(ctx, store, reg, probes, false, ""); err != nil {
640 t.Fatalf("statusTick (no probers): %v", err)
641 }
642 raw, _, err = store.Get(ctx, key)
643 if err != nil {
644 t.Fatalf("Get %s after second tick: %v", key, err)
645 }
646 if strings.Contains(string(raw), "ha.example.com") || strings.Contains(string(raw), "upstreams") {
647 t.Errorf("second snapshot still carries a retired route's probe state: %s", raw)
648 }
649}
650
651// TestUpstreamStatesUnsetSourceIsNil pins the honest degrade: `carlos edge`
652// and every test that never calls setUpstreamSource must read nil, not panic
653// — statusLoop calls upstreamStates() on every tick regardless of whether the
654// edge-ready callback has fired yet (it runs its first tick a minute in, but
655// the ordering is not something this loop gets to assume).
656func TestUpstreamStatesUnsetSourceIsNil(t *testing.T) {
657 setUpstreamSource(nil)
658 if got := upstreamStates(); got != nil {
659 t.Errorf("upstreamStates() with no source = %+v, want nil", got)
660 }
661}
662
663// TestWrapListeningProbeNullsUnitBackedSockets is fix round 1's regression
664// test: listeningProbe's raw /proc/net/unix read is confidently WRONG for
665// systemd-socket-activated (registry.BackingUnit) routes, since the
666// carlos-app@.socket unit holds the kernel-level listener open at all times
667// regardless of whether the paired service is actually awake — so a raw
668// probe would report "awake" forever for a sleeping unit-backed tenant. This
669// exercises the pure seam (unitBackedSockets + wrapListeningProbe) statusLoop
670// wires together, with a stand-in raw probe standing in for
671// /proc/net/unix's "yes, bound" answer on both sockets — exactly what a real
672// kernel-table read would say, since the unit's socket is always bound.
673func TestWrapListeningProbeNullsUnitBackedSockets(t *testing.T) {
674 routes := []registry.Route{
675 {Host: "exec-app", Kind: "instance", Hibernate: true, Socket: "/run/carlos/exec-app.sock"},
676 {Host: "unit-app", Kind: "instance", Hibernate: true, Socket: "/run/carlos/apps/unit-app.sock", Backing: registry.BackingUnit},
677 }
678
679 // rawAlwaysListening stands in for listeningProbe: both sockets read as
680 // bound/listening, which is the honest kernel-table answer for BOTH
681 // routes here (the unit-backed one's systemd .socket unit never closes
682 // its listener) — the wrapper's job is to know that answer can't be
683 // trusted for the unit-backed one.
684 rawAlwaysListening := func(string) *bool { v := true; return &v }
685 wrapped := wrapListeningProbe(unitBackedSockets(routes), rawAlwaysListening)
686
687 if got := wrapped(routes[0].Socket); got == nil || !*got {
688 t.Errorf("exec-backed socket must pass through to the raw probe unchanged, got %v", got)
689 }
690 if got := wrapped(routes[1].Socket); got != nil {
691 t.Errorf("unit-backed socket must report nil (unknown), got %v — its .socket unit's always-open listener can't distinguish awake from asleep", got)
692 }
693
694 // One level up, the observable effect on a published snapshot: CollectBox
695 // nulls the WHOLE box's Awake/Asleep pair the instant any hibernating
696 // route's Listening call returns nil (snapshot.go's partial-knowledge
697 // rule — "some known, some not" publishes as null, never a partial
698 // count). So even though the exec-backed route's own state IS known
699 // here, the box-wide totals still come back nil because the unit-backed
700 // route's is not. That's the correct, if blunt, behavior this fix
701 // produces: a box carrying even one systemd-socket-activated hibernating
702 // tenant can't yet publish a trustworthy awake/asleep split at all.
703 b := status.CollectBox(routes, status.Probes{MachineID: "m", Listening: wrapped}, time.Now())
704 if b.Routes.Awake != nil || b.Routes.Asleep != nil {
705 t.Errorf("box awake=%v asleep=%v, want both nil: one unit-backed hibernating route must null the whole box's counts, not just its own", b.Routes.Awake, b.Routes.Asleep)
706 }
707}
708
709// ---- domains: the reconcile loop's pure config builder ----------------------
710
711// TestDomainsTickConfig pins the env contract: PLATFORM_APPS_DOMAIN unset
712// means a zero Config (Reconcile is inert on it — the feature does not
713// exist); set means every seam is populated, with PLATFORM_EDGE_IPS parsed
714// comma-separated and the box's public IP as the fallback.
715//
716// That parsed list is a RETURN VALUE, not a Config field, and this test
717// asserts it there. Config.EdgeIPs is now a func answering the deployment's
718// LIVE edge set — every box's published edges file folded against its status
719// snapshot — which needs a deployment store, and this builder must stay pure
720// and getenv-injectable. domainsLoop unions the two once its store is open,
721// so what is pinned here is exactly the ENV half of that union.
722//
723// It follows that this builder never populates EdgeIPs at all, and that
724// Resolve no longer waits on this box knowing an edge address: a box with no
725// PLATFORM_EDGE_IPS whose fold supplies edges must still stamp. The
726// stand-down when nothing is knowable moved to domains.Reconcile's section 4,
727// where the set is actually known — see TestReconcileStandsDownWithNoEdgeIPs.
728func TestDomainsTickConfig(t *testing.T) {
729 env := func(m map[string]string) func(string) string {
730 return func(k string) string { return m[k] }
731 }
732
733 if got, envIPs, err := domainsTickConfig(env(map[string]string{}), "9.9.9.9"); !reflect.DeepEqual(got, domains.Config{}) || envIPs != nil || err != nil {
734 t.Errorf("unset PLATFORM_APPS_DOMAIN: config = %+v, envIPs = %v, err = %v, want zero, nil, nil", got, envIPs, err)
735 }
736
737 got, envIPs, err := domainsTickConfig(env(map[string]string{
738 "PLATFORM_APPS_DOMAIN": "oncarlos.test",
739 "PLATFORM_EDGE_IPS": "203.0.113.7, 198.51.100.9",
740 "CARLOS_DATA_DIR": "/custom",
741 }), "9.9.9.9")
742 if err != nil {
743 t.Errorf("configured deployment with no release key: err = %v, want nil", err)
744 }
745 if got.AppsDomain != "oncarlos.test" {
746 t.Errorf("AppsDomain = %q", got.AppsDomain)
747 }
748 if !reflect.DeepEqual(envIPs, []string{"203.0.113.7", "198.51.100.9"}) {
749 t.Errorf("envIPs = %v, want the parsed PLATFORM_EDGE_IPS (public IP must not override an explicit list)", envIPs)
750 }
751 if got.EdgeIPs != nil {
752 t.Error("EdgeIPs must be left nil here: only domainsLoop, which has the store, can answer the live set")
753 }
754 if got.AcmeDir != filepath.Join("/custom", "acme") {
755 t.Errorf("AcmeDir = %q, want the edge's cache dir under CARLOS_DATA_DIR", got.AcmeDir)
756 }
757 if got.Resolve == nil || got.Now == nil {
758 t.Error("Resolve/Now seams not populated on a configured deployment")
759 }
760
761 // No explicit edge IPs: the box's own public IP (IMDS) is the default.
762 got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "9.9.9.9")
763 if !reflect.DeepEqual(envIPs, []string{"9.9.9.9"}) {
764 t.Errorf("envIPs = %v, want the public-IP fallback", envIPs)
765 }
766 if got.AcmeDir != filepath.Join("/data", "acme") {
767 t.Errorf("AcmeDir = %q, want the /data default", got.AcmeDir)
768 }
769
770 // No edge IP knowable from the ENVIRONMENT at all. The env half is empty
771 // — but Resolve is still armed, because the fold may well know edges this
772 // box's variables never named. Minting runs regardless.
773 got, envIPs, _ = domainsTickConfig(env(map[string]string{"PLATFORM_APPS_DOMAIN": "oncarlos.test"}), "")
774 if envIPs != nil {
775 t.Errorf("envIPs = %v, want nothing: neither PLATFORM_EDGE_IPS nor IMDS answered", envIPs)
776 }
777 if got.Resolve == nil {
778 t.Error("Resolve must stay armed with no env edge IPs — the fold, not this box's variables, decides whether a pass can stamp")
779 }
780 if got.AppsDomain != "oncarlos.test" {
781 t.Error("minting must stay configured even without edge IPs")
782 }
783}
784
785// TestDomainsTickConfigReleasePubKey pins the auto-purge's arming condition.
786// The reconcile pass destroys objects only when it can VERIFY an app's
787// production flag, so the key is what switches purging on — and a missing or
788// unparseable one leaves it off rather than failing the loop, because hiding
789// a trashed app (reversible) must keep working on a box that may not destroy
790// anything (irreversible).
791func TestDomainsTickConfigReleasePubKey(t *testing.T) {
792 env := func(m map[string]string) func(string) string {
793 return func(k string) string { return m[k] }
794 }
795 _, pubB64, err := release.GenerateKey()
796 if err != nil {
797 t.Fatal(err)
798 }
799 got, _, err := domainsTickConfig(env(map[string]string{
800 "PLATFORM_APPS_DOMAIN": "oncarlos.test",
801 "CARLOS_RELEASE_PUBKEY": pubB64,
802 }), "9.9.9.9")
803 if err != nil {
804 t.Fatalf("a good key returned err = %v", err)
805 }
806 want, err := release.ParsePublicKey(pubB64, "test")
807 if err != nil {
808 t.Fatal(err)
809 }
810 if !got.ReleasePub.Equal(want) {
811 t.Errorf("ReleasePub = %x, want %x", got.ReleasePub, want)
812 }
813
814 // UNSET and UNPARSEABLE both leave purging off, and the caller must be
815 // able to tell them apart — release.ParsePublicKey says the same sentence
816 // for both, so the distinction lives here: unset is (nil, nil), a typo is
817 // (nil, error). domainsLoop logs the error verbatim, which is the one line
818 // an operator reads when purging silently is not happening.
819 for _, key := range []string{"", " "} {
820 got, _, err = domainsTickConfig(env(map[string]string{
821 "PLATFORM_APPS_DOMAIN": "oncarlos.test",
822 "CARLOS_RELEASE_PUBKEY": key,
823 }), "9.9.9.9")
824 if got.ReleasePub != nil || err != nil {
825 t.Errorf("CARLOS_RELEASE_PUBKEY=%q: ReleasePub = %x, err = %v — unset must be silent, not an error", key, got.ReleasePub, err)
826 }
827 }
828 for _, key := range []string{"not-base64!!", base64.StdEncoding.EncodeToString([]byte("short"))} {
829 got, _, err = domainsTickConfig(env(map[string]string{
830 "PLATFORM_APPS_DOMAIN": "oncarlos.test",
831 "CARLOS_RELEASE_PUBKEY": key,
832 }), "9.9.9.9")
833 if got.ReleasePub != nil {
834 t.Errorf("CARLOS_RELEASE_PUBKEY=%q armed purging with %x", key, got.ReleasePub)
835 }
836 if err == nil {
837 t.Errorf("CARLOS_RELEASE_PUBKEY=%q returned no error — a typo would be logged as \"not set\"", key)
838 } else if !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") {
839 t.Errorf("CARLOS_RELEASE_PUBKEY=%q: err = %v, want it to name the variable", key, err)
840 }
841 // The rest of the pass is unaffected: hiding trashed apps is
842 // reversible and must not stop because a key is mistyped.
843 if got.AppsDomain != "oncarlos.test" || got.Resolve == nil {
844 t.Errorf("CARLOS_RELEASE_PUBKEY=%q disabled the whole pass: %+v", key, got)
845 }
846 }
847}
848
849// ---- instance provisioning: the reconcile loop's pure config builder ------
850
851// TestProvisionTickConfig pins the CARLOS_REGION/CARLOS_POOLS env contract.
852// Unlike domainsTickConfig, there is no inert zero-Config case: an unset
853// region/pools is a legal, still-armed state — see provision.Reconcile's
854// doc comment (a box with no region still serves ""-scoped records) — so
855// DataDir always defaults to /data regardless of whether region/pools are
856// set.
857func TestProvisionTickConfig(t *testing.T) {
858 env := func(m map[string]string) func(string) string {
859 return func(k string) string { return m[k] }
860 }
861
862 got := provisionTickConfig(env(map[string]string{}))
863 if got.Region != "" {
864 t.Errorf("unset CARLOS_REGION: Region = %q, want empty", got.Region)
865 }
866 if got.Pools != nil {
867 t.Errorf("unset CARLOS_POOLS: Pools = %v, want nil", got.Pools)
868 }
869 if got.DataDir != "/data" {
870 t.Errorf("DataDir = %q, want /data default", got.DataDir)
871 }
872 if got.Now == nil {
873 t.Error("Now seam not populated")
874 }
875
876 got = provisionTickConfig(env(map[string]string{
877 "CARLOS_REGION": "ie",
878 "CARLOS_POOLS": "tito, shared",
879 }))
880 if got.Region != "ie" {
881 t.Errorf("Region = %q, want ie", got.Region)
882 }
883 if !reflect.DeepEqual(got.Pools, []string{"tito", "shared"}) {
884 t.Errorf("Pools = %v, want [tito shared] (whitespace trimmed)", got.Pools)
885 }
886
887 // Whitespace-only and empty entries between commas are dropped, not
888 // preserved as "".
889 got = provisionTickConfig(env(map[string]string{
890 "CARLOS_POOLS": " , tito ,, shared ,",
891 }))
892 if !reflect.DeepEqual(got.Pools, []string{"tito", "shared"}) {
893 t.Errorf("Pools = %v, want empties dropped and whitespace trimmed", got.Pools)
894 }
895
896 got = provisionTickConfig(env(map[string]string{"CARLOS_DATA_DIR": "/custom"}))
897 if got.DataDir != "/custom" {
898 t.Errorf("DataDir = %q, want /custom", got.DataDir)
899 }
900
901 // Region set with no pools: Pools stays nil (the shared "" pool only —
902 // NOT "every pool").
903 got = provisionTickConfig(env(map[string]string{"CARLOS_REGION": "ie"}))
904 if got.Region != "ie" || got.Pools != nil {
905 t.Errorf("Region = %q Pools = %v, want ie / nil", got.Region, got.Pools)
906 }
907
908 // The release pubkey (#340): present and parseable arms the production
909 // guard on trash-driven route retirement.
910 if got.ReleasePub != nil {
911 t.Error("ReleasePub set with CARLOS_RELEASE_PUBKEY unset")
912 }
913 _, pubB64, err := release.GenerateKey()
914 if err != nil {
915 t.Fatal(err)
916 }
917 got = provisionTickConfig(env(map[string]string{"CARLOS_RELEASE_PUBKEY": pubB64}))
918 if got.ReleasePub == nil {
919 t.Error("ReleasePub nil with a valid CARLOS_RELEASE_PUBKEY")
920 }
921
922 // A key SET but unparseable must not be mistaken for a key that parsed.
923 // It lands on nil — the same unguarded end state domains.Config accepts
924 // for the same decision — and says so in the log rather than silently.
925 var logged bytes.Buffer
926 log.SetOutput(&logged)
927 t.Cleanup(func() { log.SetOutput(os.Stderr) })
928 got = provisionTickConfig(env(map[string]string{"CARLOS_RELEASE_PUBKEY": "not-a-key"}))
929 if got.ReleasePub != nil {
930 t.Error("ReleasePub set from an unparseable CARLOS_RELEASE_PUBKEY")
931 }
932 if !strings.Contains(logged.String(), "CARLOS_RELEASE_PUBKEY") {
933 t.Errorf("an unparseable key was swallowed; log was:\n%s", logged.String())
934 }
935}
936
937// TestProvisionTickConfigReadsInbound pins CARLOS_INBOUND's contract: an
938// unset var is the SAME apps-domain-style inert gate CARLOS_REGION/
939// CARLOS_POOLS already use — a legal "no constraint" state, never a
940// "feature not configured" one — so it must read back as "" (today's
941// behaviour, byte for byte), and only an explicit "none" changes Config.
942func TestProvisionTickConfigReadsInbound(t *testing.T) {
943 env := func(m map[string]string) func(string) string {
944 return func(k string) string { return m[k] }
945 }
946
947 got := provisionTickConfig(env(map[string]string{
948 "CARLOS_REGION": "ie", "CARLOS_POOLS": "eleven", "CARLOS_INBOUND": "none",
949 }))
950 if got.Inbound != "none" {
951 t.Errorf("Inbound = %q, want none", got.Inbound)
952 }
953
954 got = provisionTickConfig(env(map[string]string{}))
955 if got.Inbound != "" {
956 t.Fatal("an unset CARLOS_INBOUND must be an inert default — today's behaviour exactly")
957 }
958
959 // Review fix (Task 10 finding): provisionTickConfig must fold case and
960 // whitespace, and must read an unrecognised value as restrictive rather
961 // than letting it silently fall through as "any". These pin the actual
962 // production reading path (getenv -> provision.NormalizeInbound), not
963 // just the pure function in the provision package.
964 got = provisionTickConfig(env(map[string]string{"CARLOS_INBOUND": " NONE "}))
965 if got.Inbound != "none" {
966 t.Errorf("Inbound = %q, want none (case/whitespace folded)", got.Inbound)
967 }
968 got = provisionTickConfig(env(map[string]string{"CARLOS_INBOUND": "off"}))
969 if got.Inbound != "none" {
970 t.Errorf("Inbound = %q, want none (unrecognised value reads restrictive)", got.Inbound)
971 }
972}
973
974func TestBootstrapIdempotent(t *testing.T) {
975 root := t.TempDir()
976 if err := bootstrap(root, nil); err != nil {
977 t.Fatalf("first bootstrap: %v", err)
978 }
979 if err := bootstrap(root, nil); err != nil {
980 t.Fatalf("second bootstrap: %v", err)
981 }
982 // Still all present after the re-run.
983 for _, name := range expectedUnits {
984 if _, err := os.Stat(filepath.Join(root, "etc", "systemd", "system", name)); err != nil {
985 t.Errorf("after re-run: %v", err)
986 }
987 }
988}
989
990func TestIsCanaryRoute(t *testing.T) {
991 cases := []struct {
992 channel string
993 want bool
994 }{
995 {"canary/x", true},
996 {"canary/pr-142", true},
997 {"stable", false},
998 {"beta", false},
999 {"canary", false}, // no slash — same convention as bake.go's DefaultHolds wildcard
1000 {"", false},
1001 }
1002 for _, c := range cases {
1003 rt := registry.Route{Channel: c.channel}
1004 if got := isCanaryRoute(rt); got != c.want {
1005 t.Errorf("isCanaryRoute(Channel=%q) = %v, want %v", c.channel, got, c.want)
1006 }
1007 }
1008}
1009
1010// ---- Pipelines Task 5: the install path arms timers -------------------------
1011
1012// TestSystemctlPlanArmsTimers is the regression test for the flagship's
1013// silent-adoption bug: carlos-adopt.timer was enabled — the .wants symlink was
1014// right there — but never active, because `systemctl enable` only writes the
1015// symlink and the timer does not arm until something starts it or the box
1016// reboots. Nothing had rebooted, so no release ever adopted itself and every
1017// deploy quietly needed a hand-run `systemctl start carlos-adopt.service`.
1018// Every timer bootstrap enables must therefore also be started, and no
1019// SERVICE may be (carlos-edge needs its binary and host.env staged first —
1020// bootstrap's own closing line tells the operator to start it). The one
1021// exemption is carlos-relay.service, whose ConditionPathExists makes the
1022// start a no-op on any box the operator has not armed with a region master
1023// (relay spec §3); TestBootstrapStartsRelayUnit pins that it IS started.
1024//
1025// PATH units are the same trap with the same fix: an enabled-but-unstarted
1026// carlos-adopt.path watches nothing until the next reboot, so the agent's
1027// config-sync marker would be written to a file no one reads and unit-backed
1028// apps would silently keep only the hourly floor.
1029func TestSystemctlPlanArmsTimers(t *testing.T) {
1030 plan := systemctlPlan(enabledUnits)
1031 if len(plan) == 0 || plan[0][0] != "daemon-reload" {
1032 t.Fatalf("plan must reload units first, got %v", plan)
1033 }
1034
1035 started := map[string]bool{}
1036 enabled := map[string]bool{}
1037 for _, args := range plan {
1038 for _, unit := range args[1:] {
1039 switch args[0] {
1040 case "start":
1041 started[unit] = true
1042 case "enable":
1043 enabled[unit] = true
1044 }
1045 }
1046 }
1047 for _, u := range enabledUnits {
1048 if !enabled[u] {
1049 t.Errorf("%s: enabled by the plan? no — bootstrap must still enable everything it did before", u)
1050 }
1051 if (strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path")) && !started[u] {
1052 t.Errorf("%s: enabled but never started — that is exactly the bug (enable only symlinks; the unit arms at the next boot)", u)
1053 }
1054 if strings.HasSuffix(u, ".service") && u != relayUnitName && started[u] {
1055 t.Errorf("%s: bootstrap must not start a service — the binary and /etc/carlos/host.env may not be staged yet", u)
1056 }
1057 }
1058}
1059
1060// TestBootstrapStagingRunsNoSystemctl guards the other half of the same
1061// change: `carlos bootstrap -root <dir>` is how the unit-refresh path stages
1062// units into a temp root on a LIVE box (see pinfra's
1063// update-platform-binary.sh, which stages into /tmp/carlos-unitstage). Arming
1064// timers from a staging run would touch the live system from a command whose
1065// entire contract is "write files, change nothing" — so a staging run must
1066// print what a live one would do and execute none of it. The fake systemctl on
1067// PATH records any invocation; the file must never appear.
1068func TestBootstrapStagingRunsNoSystemctl(t *testing.T) {
1069 bin := t.TempDir()
1070 log := filepath.Join(t.TempDir(), "systemctl-calls")
1071 script := "#!/bin/sh\necho \"$@\" >> " + log + "\n"
1072 if err := os.WriteFile(filepath.Join(bin, "systemctl"), []byte(script), 0o755); err != nil {
1073 t.Fatal(err)
1074 }
1075 t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
1076
1077 if err := bootstrap(t.TempDir(), nil); err != nil {
1078 t.Fatalf("bootstrap: %v", err)
1079 }
1080 if b, err := os.ReadFile(log); err == nil {
1081 t.Errorf("staging bootstrap ran systemctl: %s", b)
1082 }
1083}
1084
1085// TestBootstrapTmpfilesBlessesConsumersGroup pins the instance-socket
1086// consumers contract (issue #103): /run/carlos carries the dedicated
1087// carlos-sockets group with the setgid bit — so every socket an exec child
1088// binds inside inherits the consumers group — while /run/carlos/apps stays
1089// carlos:carlos (unit tenants get their fd passed; enrollment deliberately
1090// does not reach them). The group is dedicated, never `carlos`: membership
1091// in `carlos` also grants group-read of /data, i.e. every instance's DB and
1092// CARLOS_ADMIN_TOKEN.
1093func TestBootstrapTmpfilesBlessesConsumersGroup(t *testing.T) {
1094 root := t.TempDir()
1095 if err := bootstrap(root, nil); err != nil {
1096 t.Fatalf("bootstrap: %v", err)
1097 }
1098 tf, err := os.ReadFile(filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf"))
1099 if err != nil {
1100 t.Fatalf("tmpfiles conf: %v", err)
1101 }
1102 if want := "d /run/carlos 2750 carlos carlos-sockets -"; !strings.Contains(string(tf), want) {
1103 t.Errorf("tmpfiles conf %q missing %q", tf, want)
1104 }
1105 if want := "d /run/carlos/apps 0750 carlos carlos -"; !strings.Contains(string(tf), want) {
1106 t.Errorf("tmpfiles conf %q missing %q — the tenant socket dir must stay out of the consumers group", tf, want)
1107 }
1108
1109 // Bootstrap's own dir handling must agree with the tmpfiles line: setgid
1110 // on /run/carlos (mode is what makes group inheritance real between
1111 // boots), and NOT on apps/.
1112 fi, err := os.Stat(filepath.Join(root, "run", "carlos"))
1113 if err != nil {
1114 t.Fatal(err)
1115 }
1116 if fi.Mode()&os.ModeSetgid == 0 {
1117 t.Errorf("/run/carlos mode %v: missing setgid — bound sockets would not inherit the consumers group", fi.Mode())
1118 }
1119 if fi.Mode().Perm() != 0o750 {
1120 t.Errorf("/run/carlos perm %o, want 750", fi.Mode().Perm())
1121 }
1122 afi, err := os.Stat(filepath.Join(root, "run", "carlos", "apps"))
1123 if err != nil {
1124 t.Fatal(err)
1125 }
1126 if afi.Mode()&os.ModeSetgid != 0 {
1127 t.Errorf("/run/carlos/apps mode %v: setgid must not leak onto the tenant socket dir", afi.Mode())
1128 }
1129
1130 // The sysusers.d snippet is what makes the tmpfiles line safe at boot on
1131 // a box whose conf arrived via a staged-tree refresh (never a live
1132 // groupadd): systemd-sysusers runs Before=systemd-tmpfiles-setup, and a
1133 // tmpfiles line whose group doesn't resolve is DROPPED — /run/carlos then
1134 // isn't created and carlos-edge (ReadWritePaths, no "-") can't start.
1135 su, err := os.ReadFile(filepath.Join(root, "etc", "sysusers.d", "carlos.conf"))
1136 if err != nil {
1137 t.Fatalf("sysusers conf: %v", err)
1138 }
1139 if want := "g carlos-sockets -"; !strings.Contains(string(su), want) {
1140 t.Errorf("sysusers conf %q missing %q", su, want)
1141 }
1142}
1143
1144// TestBootstrapPrivilegedPlanEnrollsConsumers pins the printed-equals-run
1145// contract for the group half, the same discipline systemctlPlan established:
1146// a staging run prints exactly the commands a live run executes, so the two
1147// can never drift. -socket-consumer users each get a usermod enrollment into
1148// the dedicated group; no consumers means no usermod lines but the group and
1149// dir convergence always run.
1150func TestBootstrapPrivilegedPlanEnrollsConsumers(t *testing.T) {
1151 got := privilegedPlan([]string{"titogo", "titogo-relay"})
1152 // The /run/carlos chown is recursive AND targets the consumers group: a
1153 // re-run is the documented enrollment path, and a recursive chown back
1154 // to carlos:carlos would reset every live instance socket's group,
1155 // stranding already-enrolled consumers until each instance rebinds.
1156 // apps/ is put back to carlos:carlos right after (unit tenants stay out
1157 // of consumer reach).
1158 want := [][]string{
1159 {"useradd", "--system", "--user-group", "--no-create-home", "--shell", "/usr/sbin/nologin", "carlos"},
1160 {"groupadd", "--system", "carlos-sockets"},
1161 {"chown", "-R", "carlos:carlos", "/data"},
1162 {"chown", "-R", "carlos:carlos-sockets", "/run/carlos"},
1163 {"chown", "-R", "carlos:carlos", "/run/carlos/apps"},
1164 {"chmod", "2750", "/run/carlos"},
1165 {"usermod", "-aG", "carlos-sockets", "titogo"},
1166 {"usermod", "-aG", "carlos-sockets", "titogo-relay"},
1167 }
1168 if !reflect.DeepEqual(got, want) {
1169 t.Errorf("privilegedPlan = %v, want %v", got, want)
1170 }
1171 // No consumers: same plan minus the usermod tail.
1172 if got := privilegedPlan(nil); !reflect.DeepEqual(got, want[:6]) {
1173 t.Errorf("privilegedPlan(nil) = %v, want %v", got, want[:6])
1174 }
1175}
1176
1177// TestBootstrapOffcloudRequiresCredentialsFile pins the off-cloud precondition
1178// (issue #53 point 4): a box with no EC2 instance profile must have its scoped
1179// credentials staged — and staged at 0600 — before bootstrap proceeds. This is
1180// the staging-root path (no root privileges available in tests), so the
1181// root:service ownership repair never runs; that only happens on a live root.
1182func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) {
1183 root := t.TempDir()
1184 if err := bootstrapOffcloud(root, nil); err == nil {
1185 t.Fatal("want error when <root>/etc/carlos/credentials is absent")
1186 }
1187 credDir := filepath.Join(root, "etc", "carlos")
1188 os.MkdirAll(credDir, 0o755)
1189 credFile := filepath.Join(credDir, "credentials")
1190 os.WriteFile(credFile, []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n"), 0o644)
1191 if err := bootstrapOffcloud(root, nil); err == nil {
1192 t.Fatal("want error on 0644 credentials (must be 0600)")
1193 }
1194 os.Chmod(credFile, 0o600)
1195 if err := bootstrapOffcloud(root, nil); err != nil {
1196 t.Fatalf("offcloud bootstrap with staged 0600 credentials: %v", err)
1197 }
1198 // The normal bootstrap artifacts must exist too — offcloud is a superset.
1199 if _, err := os.Stat(filepath.Join(root, "etc", "systemd", "system", "carlos-edge.service")); err != nil {
1200 t.Fatalf("units not installed: %v", err)
1201 }
1202}
1203
1204// TestAdoptTimerStateReading pins the probe's honesty rules at the seam that
1205// needs no systemd to test: systemd's own word for the timer's state maps to
1206// running/not-running, and anything this probe cannot interpret — no systemd
1207// at all, a systemctl that failed to run, a word from a future version —
1208// reports nil (unknown), never a reassuring true.
1209func TestAdoptTimerStateReading(t *testing.T) {
1210 cases := []struct {
1211 out string
1212 want *bool
1213 }{
1214 {"active\n", boolPtr(true)},
1215 {"inactive\n", boolPtr(false)},
1216 {"failed\n", boolPtr(false)},
1217 {"deactivating\n", boolPtr(false)},
1218 {"unknown\n", boolPtr(false)}, // systemd answered: it has no such unit loaded
1219 {"", nil}, // systemctl never ran (not a systemd box)
1220 {"something-new\n", nil}, // a word this probe does not understand
1221 }
1222 for _, c := range cases {
1223 got := parseIsActive(c.out)
1224 switch {
1225 case c.want == nil && got != nil:
1226 t.Errorf("parseIsActive(%q) = %v, want nil", c.out, *got)
1227 case c.want != nil && got == nil:
1228 t.Errorf("parseIsActive(%q) = nil, want %v", c.out, *c.want)
1229 case c.want != nil && *got != *c.want:
1230 t.Errorf("parseIsActive(%q) = %v, want %v", c.out, *got, *c.want)
1231 }
1232 }
1233}
1234
1235func boolPtr(v bool) *bool { return &v }
1236
1237func TestParseMeminfoBytes(t *testing.T) {
1238 total, avail := parseMeminfoBytes(strings.NewReader(
1239 "MemTotal: 3961472 kB\nMemFree: 100 kB\nMemAvailable: 1980736 kB\n"))
1240 if total == nil || *total != 3961472*1024 || avail == nil || *avail != 1980736*1024 {
1241 t.Fatalf("got %v %v", total, avail)
1242 }
1243 if tt, aa := parseMeminfoBytes(strings.NewReader("garbage")); tt != nil || aa != nil {
1244 t.Fatal("unparseable meminfo must yield nil, not zero")
1245 }
1246}
1247
1248func TestParseLoad1(t *testing.T) {
1249 if l := parseLoad1("0.42 0.36 0.30 1/234 5678\n"); l == nil || *l != 0.42 {
1250 t.Fatalf("got %v", l)
1251 }
1252 if l := parseLoad1(""); l != nil {
1253 t.Fatal("empty loadavg must yield nil")
1254 }
1255}
1256
1257// TestProbedUnitsAreTheOnesBootstrapArms pins the pairing that makes both
1258// systemd probes meaningful: each one must name a unit bootstrap actually
1259// installs AND arms. A probe pointed at a unit no box has answers "unknown"
1260// (systemd's own word for "no such unit"), which this package maps to a
1261// confident false — so a typo here would report every box in the fleet as
1262// broken, and a renamed unit would report a live box as dead.
1263func TestProbedUnitsAreTheOnesBootstrapArms(t *testing.T) {
1264 for _, unit := range []string{adoptTimerUnit, configPathUnit, systemUpdatePathUnit} {
1265 if !slices.Contains(enabledUnits, unit) {
1266 t.Errorf("%s: probed but not in enabledUnits — bootstrap never arms it", unit)
1267 }
1268 if !slices.Contains(expectedUnits, unit) {
1269 t.Errorf("%s: probed but not an installed unit", unit)
1270 }
1271 }
1272}
1273
1274func TestBootstrapPlanJSONNamesWhatALiveRunArms(t *testing.T) {
1275 p := currentBootstrapPlan()
1276 want := map[string]bool{"carlos-edge.service": true, "carlos-adopt.timer": true, "carlos-adopt.path": true,
1277 "carlos-stage.path": true, "carlos-restart.path": true, "carlos-system-update.path": true, "carlos-system-recover.service": true,
1278 "data-instances.mount": true, "run-carlos-instances.mount": true, "run-carlos-logs.mount": true}
1279 got := map[string]bool{}
1280 for _, u := range p.Enabled {
1281 got[u] = true
1282 }
1283 for u := range want {
1284 if !got[u] {
1285 t.Errorf("plan lacks %s", u)
1286 }
1287 }
1288 if got["carlos-system-update.service"] {
1289 t.Error("the path-triggered oneshot must not be enabled: it is started by its .path")
1290 }
1291 inert := map[string]bool{}
1292 for _, u := range p.Inert {
1293 inert[u] = true
1294 }
1295 if !inert["carlos-restore-verify.timer"] || !inert["carlos-hibernate-verify.timer"] {
1296 t.Errorf("inert = %v", p.Inert)
1297 }
1298 // And systemctlPlan starts no service but the relay, whatever is
1299 // enabled — the relay is safe because its ConditionPathExists gates it
1300 // on a masters file the operator stages (relay spec §3).
1301 for _, args := range systemctlPlan(p.Enabled) {
1302 if args[0] == "start" {
1303 for _, u := range args[1:] {
1304 if strings.HasSuffix(u, ".service") && u != relayUnitName {
1305 t.Errorf("plan starts a service: %v", args)
1306 }
1307 }
1308 }
1309 }
1310}
1311
1312func TestSystemUnitsAreShapedLikeTheStageRelay(t *testing.T) {
1313 read := func(name string) string {
1314 b, err := unitsFS.ReadFile("units/" + name)
1315 if err != nil {
1316 t.Fatal(err)
1317 }
1318 return string(b)
1319 }
1320 p := read("carlos-system-update.path")
1321 for _, want := range []string{"PathChanged=/data/system-update-requested", "Unit=carlos-system-update.service", "WantedBy=multi-user.target"} {
1322 if !strings.Contains(p, want) {
1323 t.Errorf("path unit lacks %q", want)
1324 }
1325 }
1326 u := read("carlos-system-update.service")
1327 for _, want := range []string{"Type=oneshot", "StartLimitIntervalSec=0", "TimeoutStartSec=15min", "OnFailure=carlos-system-recover.service",
1328 "EnvironmentFile=-/etc/carlos/host.env", "system apply", "carlos.prev", "MemoryMax=256M",
1329 "floor.min-release", "carlos.prev.allowed", floorGateCall, "seeds-floor.min-release", "carlos.prev.seeds-allowed", seedsFloorGateCall} {
1330 if !strings.Contains(u, want) {
1331 t.Errorf("update unit lacks %q", want)
1332 }
1333 }
1334 if strings.Contains(u, "cmp ") {
1335 t.Errorf("update unit still compares the floor file with cmp, which follows a symlink and blocks on a FIFO")
1336 }
1337 for _, never := range []string{"ProtectSystem", "User="} {
1338 if strings.Contains(u, never) {
1339 t.Errorf("update unit must run as root with /opt writable; found %q", never)
1340 }
1341 }
1342 r := read("carlos-system-recover.service")
1343 for _, want := range []string{"Type=oneshot", "Before=carlos-edge.service", "WantedBy=multi-user.target", "TimeoutStartSec=10min", "system apply --recover", "carlos.prev",
1344 "floor.min-release", "carlos.prev.allowed", floorGateCall, "seeds-floor.min-release", "carlos.prev.seeds-allowed", seedsFloorGateCall} {
1345 if !strings.Contains(r, want) {
1346 t.Errorf("recover unit lacks %q", want)
1347 }
1348 }
1349 if strings.Contains(r, "cmp ") {
1350 t.Errorf("recover unit still compares the floor file with cmp, which follows a symlink and blocks on a FIFO")
1351 }
1352}
1353
1354// TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the
1355// monitoring spec commits to: operator-grade needles live under metrics/ and
1356// alerts/, and nothing the public status publisher writes may ever be
1357// derived from them. Sentinel values planted in metrics objects must not
1358// appear anywhere under status/ after a full publish tick.
1359func TestStatusPublishNeverReadsMetricsPrefixes(t *testing.T) {
1360 ctx := context.Background()
1361 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
1362 if err != nil {
1363 t.Fatal(err)
1364 }
1365 store := release.NewDirStore(t.TempDir())
1366 sentinel := 0.987654321
1367 chunk := metrics.RawChunk{V: 1, Box: "box-a", Hour: "2026-08-02-10", Samples: []metrics.Sample{
1368 {At: time.Now(), Host: metrics.HostSample{CPUFrac: &sentinel}},
1369 }}
1370 if err := metrics.WriteRaw(ctx, store, chunk); err != nil {
1371 t.Fatal(err)
1372 }
1373
1374 // Plant sentinel in alerts/boxes/ as well
1375 alertState := metrics.AlertState{
1376 V: 1,
1377 Box: "box-a",
1378 UpdatedAt: time.Now(),
1379 Rules: map[string]metrics.RuleState{
1380 "disk": {Firing: true, Value: sentinel},
1381 },
1382 }
1383 alertBody, err := json.Marshal(alertState)
1384 if err != nil {
1385 t.Fatal(err)
1386 }
1387 if _, err := store.Put(ctx, metrics.AlertKey("box-a"), alertBody, ""); err != nil {
1388 t.Fatal(err)
1389 }
1390
1391 if err := statusTick(ctx, store, reg, status.Probes{MachineID: "m"}, true, ""); err != nil {
1392 t.Fatal(err)
1393 }
1394 keys, err := store.List(ctx, "status/")
1395 if err != nil {
1396 t.Fatal(err)
1397 }
1398 // Without this the loop below has nothing to inspect and the test passes
1399 // no matter what statusTick publishes — a tripwire across a doorway
1400 // nobody walks through.
1401 if len(keys) == 0 {
1402 t.Fatal("no status/ keys after a publish tick — the tripwire is not armed")
1403 }
1404 for _, k := range keys {
1405 body, _, err := store.Get(ctx, k)
1406 if err != nil {
1407 t.Fatal(err)
1408 }
1409 if strings.Contains(string(body), "987654321") {
1410 t.Errorf("%s contains the metrics sentinel — the status publisher read a metrics object", k)
1411 }
1412 }
1413}
1414
1415// ---- Pipelines: canary TTL, the status loop's expired-canary count --------
1416
1417// wireWrapper mirrors release's unexported signedWrapper wire shape
1418// (payload/sig) — duplicated here rather than imported (it isn't exported,
1419// deliberately: forging a pointer's PromotedAt is not something production
1420// code anywhere should be able to do) so this test can write a validly
1421// SIGNED but arbitrarily-dated channel pointer straight into a DirStore,
1422// the only way to get an actually-expired pointer under test since every
1423// public promotion path (release.Promote) stamps the real clock.
1424type wireWrapper struct {
1425 Payload string `json:"payload"`
1426 Sig string `json:"sig"`
1427}
1428
1429// writeBackdatedPointer signs and writes a channel pointer with an
1430// arbitrary PromotedAt directly to key release.ChannelKey(acct, app,
1431// channel), bypassing Promote's clock stamp.
1432func writeBackdatedPointer(t *testing.T, s release.Store, priv ed25519.PrivateKey, acct, app, channel, version string, promotedAt time.Time) {
1433 t.Helper()
1434 p := struct {
1435 Schema int `json:"schema"`
1436 App string `json:"app"`
1437 Channel string `json:"channel"`
1438 Version string `json:"version"`
1439 PromotedAt time.Time `json:"promoted_at"`
1440 }{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: promotedAt}
1441 payload, err := json.Marshal(p)
1442 if err != nil {
1443 t.Fatalf("marshal backdated pointer: %v", err)
1444 }
1445 b64 := base64.StdEncoding.EncodeToString(payload)
1446 sig := ed25519.Sign(priv, []byte(b64))
1447 wrapper, err := json.Marshal(wireWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)})
1448 if err != nil {
1449 t.Fatalf("marshal backdated wrapper: %v", err)
1450 }
1451 if _, err := s.Put(context.Background(), release.ChannelKey(acct, app, channel), wrapper, ""); err != nil {
1452 t.Fatalf("write backdated pointer: %v", err)
1453 }
1454}
1455
1456func testCanaryKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
1457 t.Helper()
1458 seedB64, pubB64, err := release.GenerateKey()
1459 if err != nil {
1460 t.Fatalf("GenerateKey: %v", err)
1461 }
1462 priv, err := release.ParseSigningKey(seedB64, "test")
1463 if err != nil {
1464 t.Fatalf("ParseSigningKey: %v", err)
1465 }
1466 pub, err := release.ParsePublicKey(pubB64, "test")
1467 if err != nil {
1468 t.Fatalf("ParsePublicKey: %v", err)
1469 }
1470 return priv, pub
1471}
1472
1473// No canary routes at all needs no store to answer "none" — a confident,
1474// known 0, not the unknown/nil path a missing store would otherwise imply.
1475func TestCountExpiredCanariesNoCanaryRoutesIsZero(t *testing.T) {
1476 routes := []registry.Route{
1477 {Host: "a.example.com", Kind: "instance", App: "myapp", Channel: "stable"},
1478 {Host: "b.example.com", Kind: "static"},
1479 }
1480 got := countExpiredCanaries(context.Background(), routes, nil, nil, time.Now())
1481 if got == nil || *got != 0 {
1482 t.Errorf("count = %v, want 0 (no canary routes, no store needed)", got)
1483 }
1484}
1485
1486// A canary route exists but there is no deployment store to resolve its
1487// pointer against — the "routes managed by hand" box shape adoptLoop's own
1488// doc comment names. That must publish unknown, never a guessed 0.
1489func TestCountExpiredCanariesNilStoreIsUnknown(t *testing.T) {
1490 routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x"}}
1491 if got := countExpiredCanaries(context.Background(), routes, nil, nil, time.Now()); got != nil {
1492 t.Errorf("count = %v, want nil (no store to resolve the pointer against)", *got)
1493 }
1494}
1495
1496// A canary route whose channel was never actually promoted (no pointer at
1497// that key) is the same "can't answer" case as no store at all — release.
1498// Channel returns os.ErrNotExist, and that must null the count too, not
1499// silently skip the route and undercount.
1500func TestCountExpiredCanariesUnresolvablePointerIsUnknown(t *testing.T) {
1501 s := release.NewDirStore(t.TempDir())
1502 _, pub := testCanaryKeys(t)
1503 routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Account: "bab"}}
1504 if got := countExpiredCanaries(context.Background(), routes, s, pub, time.Now()); got != nil {
1505 t.Errorf("count = %v, want nil (channel was never promoted)", *got)
1506 }
1507}
1508
1509// A fresh canary (real Promote, real clock) counts as 0 — not expired, and
1510// known.
1511func TestCountExpiredCanariesFreshCanaryIsZero(t *testing.T) {
1512 s := release.NewDirStore(t.TempDir())
1513 ctx := context.Background()
1514 priv, pub := testCanaryKeys(t)
1515 shipVersion(t, s, "bab", "myapp", "v1")
1516 if err := release.Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
1517 t.Fatalf("Promote: %v", err)
1518 }
1519 routes := []registry.Route{{Host: "rc.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Account: "bab"}}
1520 got := countExpiredCanaries(ctx, routes, s, pub, time.Now())
1521 if got == nil || *got != 0 {
1522 t.Errorf("count = %v, want 0 (freshly promoted)", got)
1523 }
1524}
1525
1526// The core case: a backdated (expired) canary pointer counts as 1; a second,
1527// unexpired canary route on a different channel does not add to it.
1528func TestCountExpiredCanariesCountsExpiredOnly(t *testing.T) {
1529 s := release.NewDirStore(t.TempDir())
1530 ctx := context.Background()
1531 priv, pub := testCanaryKeys(t)
1532 writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/old", "v1",
1533 time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
1534 shipVersion(t, s, "bab", "myapp", "v2")
1535 if err := release.Promote(ctx, s, priv, "bab", "myapp", "canary/fresh", "v2"); err != nil {
1536 t.Fatalf("Promote: %v", err)
1537 }
1538 routes := []registry.Route{
1539 {Host: "old.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
1540 {Host: "fresh.example.com", Kind: "instance", App: "myapp", Channel: "canary/fresh", Account: "bab"},
1541 }
1542 got := countExpiredCanaries(ctx, routes, s, pub, time.Now())
1543 if got == nil || *got != 1 {
1544 t.Errorf("count = %v, want 1 (only the old channel's route is expired)", got)
1545 }
1546}
1547
1548// A platform-minted alias is a second NAME for a route already counted, not
1549// a second canary route — same convention CollectBox uses to avoid
1550// double-counting aliases (host_test.go's own
1551// TestCollectBoxSkipsPlatformMintedRoutes in internal/status covers that
1552// half; this pins the same rule for the expired-canary count).
1553func TestCountExpiredCanariesSkipsPlatformAliases(t *testing.T) {
1554 s := release.NewDirStore(t.TempDir())
1555 priv, pub := testCanaryKeys(t)
1556 writeBackdatedPointer(t, s, priv, "bab", "myapp", "canary/old", "v1",
1557 time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
1558 routes := []registry.Route{
1559 {Host: "alias.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab", Platform: true},
1560 }
1561 got := countExpiredCanaries(context.Background(), routes, s, pub, time.Now())
1562 if got == nil || *got != 0 {
1563 t.Errorf("count = %v, want 0 — a platform alias must not be counted", got)
1564 }
1565}
1566
1567// getCountingStore wraps a real release.Store and counts Get calls — used to
1568// prove countExpiredCanaries' per-call cache actually dedupes store reads,
1569// not just channel-pointer resolutions in memory.
1570type getCountingStore struct {
1571 release.Store
1572 gets int
1573}
1574
1575func (s *getCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
1576 s.gets++
1577 return s.Store.Get(ctx, key)
1578}
1579
1580// TestCountExpiredCanariesReadsEachChannelOnce pins the caching half of the
1581// 2026-08-03 review: statusLoop calls countExpiredCanaries once per
1582// 5-minute tick, and this function must resolve a given canary channel's
1583// pointer AT MOST ONCE per call however many of this box's routes follow
1584// it — N routes sharing one channel costs one store.Get, not N.
1585func TestCountExpiredCanariesReadsEachChannelOnce(t *testing.T) {
1586 real := release.NewDirStore(t.TempDir())
1587 priv, pub := testCanaryKeys(t)
1588 writeBackdatedPointer(t, real, priv, "bab", "myapp", "canary/old", "v1",
1589 time.Now().Add(-(release.CanaryTTL + 24*time.Hour)))
1590 counting := &getCountingStore{Store: real}
1591
1592 routes := []registry.Route{
1593 {Host: "a.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
1594 {Host: "b.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
1595 {Host: "c.example.com", Kind: "instance", App: "myapp", Channel: "canary/old", Account: "bab"},
1596 }
1597 got := countExpiredCanaries(context.Background(), routes, counting, pub, time.Now())
1598 if got == nil || *got != 3 {
1599 t.Fatalf("count = %v, want 3 (all three routes share the one expired channel)", got)
1600 }
1601 if counting.gets != 1 {
1602 t.Errorf("store.Get called %d times for 3 routes on ONE channel, want exactly 1 — the per-tick cache must dedupe by (account, app, channel)", counting.gets)
1603 }
1604}
1605
1606// shipVersion ships a minimal one-artifact release so Promote has something
1607// real to point at — release.Promote refuses to point a channel at a
1608// version nothing shipped.
1609func shipVersion(t *testing.T, s release.Store, acct, app, version string) {
1610 t.Helper()
1611 m := release.Manifest{
1612 Schema: 1, App: app, Version: version, Kind: "binary",
1613 Artifacts: map[string]release.Artifact{"linux-amd64": {File: app}},
1614 }
1615 if err := release.Ship(context.Background(), s, acct, m, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
1616 t.Fatalf("Ship %s/%s: %v", app, version, err)
1617 }
1618}
1619
1620// Both restore drills must write their restored copies to DISK, never to the
1621// PrivateTmp tmpfs. On tmpfs the restored database is charged to the unit's
1622// own cgroup as memory, so the drill's ceiling has to exceed the fleet's
1623// largest tenant — and the drill starts failing on exactly the databases it
1624// exists to vouch for. Measured on the flagship 2026-08-15: a 412M tenant
1625// produced a 485.6M peak against MemoryMax=512M.
1626//
1627// This is a one-line property that would regress silently (the drill still
1628// passes, right up until a tenant grows), so it is pinned here.
1629// The tenant template must deny IMDS at the unit level (issue #351): the
1630// nftables skuid gate is operator territory and a box can simply not have
1631// one (tito didn't), at which point any app process could read the box
1632// instance-role credentials off 169.254.169.254. Apps get per-app scoped
1633// IAM keys via env; the one legitimate IMDS consumer under this template
1634// is a host_role tenant, whose drop-in re-opens it with IPAddressAllow
1635// (allow beats deny in systemd's BPF firewall) — see caps.RenderService.
1636// One-line property, silent regression, so it is pinned here.
1637func TestAppTemplateDeniesIMDS(t *testing.T) {
1638 body, err := unitsFS.ReadFile("units/carlos-app@.service")
1639 if err != nil {
1640 t.Fatalf("carlos-app@.service: %v", err)
1641 }
1642 if !strings.Contains(string(body), "IPAddressDeny=169.254.169.254/32 fd00:ec2::254/128") {
1643 t.Errorf("carlos-app@.service does not deny IMDS — app code on an ungated box can take the instance role's credentials (issue #351)")
1644 }
1645}
1646
1647func TestVerifyDrillsRestoreToDiskNotTmpfs(t *testing.T) {
1648 for _, unit := range []string{"carlos-restore-verify.service", "carlos-hibernate-verify.service"} {
1649 body, err := unitsFS.ReadFile("units/" + unit)
1650 if err != nil {
1651 t.Fatalf("%s: %v", unit, err)
1652 }
1653 s := string(body)
1654 if !strings.Contains(s, "CacheDirectory=carlos-verify") {
1655 t.Errorf("%s has no CacheDirectory — nothing creates the on-disk scratch dir", unit)
1656 }
1657 if !strings.Contains(s, "Environment=TMPDIR=%C/carlos-verify") {
1658 t.Errorf("%s does not point TMPDIR at the on-disk dir; restores land on the tmpfs and count as memory", unit)
1659 }
1660 if !strings.Contains(s, "MemoryMax=") {
1661 t.Errorf("%s has no MemoryMax — a runaway drill should hit its own ceiling, not the box's", unit)
1662 }
1663 }
1664}
1665
1666// The journal cap ships with every bootstrap (discussion #111): uncapped,
1667// journald's default is 10% of the filesystem, and 1.2 GB of the flagship's
1668// 20 GB root was journal by the time it filled.
1669func TestBootstrapWritesJournaldCap(t *testing.T) {
1670 root := t.TempDir()
1671 if err := bootstrap(root, nil); err != nil {
1672 t.Fatalf("bootstrap: %v", err)
1673 }
1674 path := filepath.Join(root, "etc", "systemd", "journald.conf.d", journaldConfName)
1675 b, err := os.ReadFile(path)
1676 if err != nil {
1677 t.Fatalf("journald drop-in not written: %v", err)
1678 }
1679 got := string(b)
1680 // The section header is not optional: journald ignores a bare assignment.
1681 for _, want := range []string{"[Journal]", "SystemMaxUse=256M", "RuntimeMaxUse="} {
1682 if !strings.Contains(got, want) {
1683 t.Errorf("drop-in missing %q:\n%s", want, got)
1684 }
1685 }
1686 // The name has to match the one placed by hand on the flagship during the
1687 // 2026-09-06 relief, or the two files sit side by side and name order
1688 // decides which cap wins.
1689 if journaldConfName != "50-cap.conf" {
1690 t.Errorf("journaldConfName = %q; it must supersede the hand-placed 50-cap.conf", journaldConfName)
1691 }
1692 // A staged root is the delivery path to boxes that predate the cap: the
1693 // self-update apply copies this file out of one by name (installUnits).
1694 if err := bootstrap(root, nil); err != nil {
1695 t.Fatalf("re-run: %v", err)
1696 }
1697 if _, err := os.Stat(path); err != nil {
1698 t.Errorf("drop-in gone after a re-run: %v", err)
1699 }
1700}
1701
1702// TestTmpfilesCarriesRelayStateDir is discussion #118 as a test. The relay's
1703// unit declares ReadWritePaths=/data/relay, and systemd refuses to start a
1704// unit whose ReadWritePaths names a directory that is not there — "Failed to
1705// set up mount namespacing: /data/relay: No such file or directory", which is
1706// what the Ireland box answered on 2026-09-07.
1707//
1708// bootstrap's own dirs list creates it, and that is not enough: the dirs list
1709// runs on a FRESH box or against a staging root, never against the live root
1710// of a self-update apply. What an apply re-runs is systemd-tmpfiles. So the
1711// directory has to be in tmpfilesConf, and this test is what keeps a future
1712// unit's writable state dir from being added to one list and not the other.
1713func TestTmpfilesCarriesRelayStateDir(t *testing.T) {
1714 b, err := unitsFS.ReadFile("units/" + relayUnitName)
1715 if err != nil {
1716 t.Fatal(err)
1717 }
1718 var rw string
1719 for _, line := range strings.Split(string(b), "\n") {
1720 if v, ok := strings.CutPrefix(line, "ReadWritePaths="); ok {
1721 rw = v
1722 break
1723 }
1724 }
1725 if rw == "" {
1726 t.Fatal("carlos-relay.service declares no ReadWritePaths")
1727 }
1728 for _, p := range strings.Fields(rw) {
1729 if !strings.HasPrefix(p, "/data/") {
1730 continue // /run/carlos is tmpfs and already has its own line
1731 }
1732 want := "d " + p + " "
1733 if !strings.Contains(tmpfilesConf, want) {
1734 t.Errorf("tmpfilesConf has no %q line, so an apply onto a box that predates the unit leaves it unable to start:\n%s", want, tmpfilesConf)
1735 }
1736 }
1737 // The mode must match bootstrap's dirs list, or a re-run of one flips
1738 // what the other just set (the /data/instances lesson, one line up).
1739 // Read the mode bootstrap actually uses rather than restating it: a
1740 // hardcoded "0750" here passes happily while the two lists disagree.
1741 root := t.TempDir()
1742 if err := bootstrapWith(root, nil, false); err != nil {
1743 t.Fatal(err)
1744 }
1745 fi, err := os.Stat(filepath.Join(root, "data", "relay"))
1746 if err != nil {
1747 t.Fatalf("bootstrap did not create /data/relay: %v", err)
1748 }
1749 want := fmt.Sprintf("d /data/relay %04o ", fi.Mode().Perm())
1750 if !strings.Contains(tmpfilesConf, want) {
1751 t.Errorf("tmpfilesConf must carry %q to match the mode bootstrap's dirs list creates:\n%s", want, tmpfilesConf)
1752 }
1753}
1754
1755// TestPlanCarriesArmedList pins the other half of discussion #118: the plan a
1756// build prints says which units to START, not just which to enable, so an
1757// applier running an OLDER binary arms what the new build wants rather than
1758// what its own systemctlPlan happens to know about.
1759func TestPlanCarriesArmedList(t *testing.T) {
1760 plan := currentBootstrapPlan()
1761 if len(plan.Armed) == 0 {
1762 t.Fatal("plan carries no armed list")
1763 }
1764 if !slices.Contains(plan.Armed, relayUnitName) {
1765 t.Errorf("armed list omits the relay: %v", plan.Armed)
1766 }
1767 if slices.Contains(plan.Armed, "carlos-edge.service") {
1768 t.Errorf("edge must stay start-by-hand: %v", plan.Armed)
1769 }
1770 for _, u := range plan.Armed {
1771 if !slices.Contains(plan.Enabled, u) {
1772 t.Errorf("armed unit %s is not in the enabled list", u)
1773 }
1774 }
1775 // The armed list and the local rule must agree for THIS build; they
1776 // diverge only across builds, which is the point of carrying it.
1777 if got, want := plan.Armed, armedUnits(enabledUnits); !slices.Equal(got, want) {
1778 t.Errorf("plan.Armed = %v, systemctlPlan would start %v", got, want)
1779 }
1780}
1781
1782// floorGateCall is how both system units ask whether carlos.prev may run
1783// against an armed floor: carlos.prev's own floor gate (root's trusted
1784// path, never a PATH lookup), with both files named in the unit.
1785const floorGateCall = "/opt/carlos/carlos.prev system floor-allows-prev /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed"
1786
1787// seedsFloorGateCall is the same gate asked about the seeds floor's two
1788// files (seeded-instances spec §3.3): the units' second clause.
1789const seedsFloorGateCall = "/opt/carlos/carlos.prev system floor-allows-prev /data/.moves/seeds-floor.min-release /opt/carlos/carlos.prev.seeds-allowed"
1790
1791// floorGateHelperEnv makes this test binary act as the floor gate for the
1792// unit stubs in TestSystemUnitsSelectPrevByTheFloor.
1793const floorGateHelperEnv = "CARLOS_TEST_FLOOR_GATE_HELPER"
1794
1795// TestFloorGateHelperProcess is not a test: run with floorGateHelperEnv
1796// set, it is `carlos system floor-allows-prev` with the test's own uid as
1797// the expected owner of both files (a test cannot make root's or the
1798// service account's files).
1799func TestFloorGateHelperProcess(t *testing.T) {
1800 if os.Getenv(floorGateHelperEnv) != "1" {
1801 return
1802 }
1803 args := os.Args
1804 for i, a := range args {
1805 if a == "--" {
1806 args = args[i+1:]
1807 break
1808 }
1809 }
1810 uid := []uint32{uint32(os.Getuid())}
1811 os.Exit(floorGateMain(args, uid, uid, os.Stderr))
1812}
1813
1814// TestSystemUnitsSelectPrevByTheFloor runs each system unit's ExecStart
1815// shell (moves spec §11, round-6 finding 4) against a fake /opt/carlos and
1816// /data, with stub binaries that record which one ran. Without a floor file
1817// the selection is main's exactly; with one, carlos.prev runs only when its
1818// verdict names the floor on disk, byte for byte, as carlos.prev's floor
1819// gate reads them (the stubs hand the gate verb to the real Go code).
1820func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) {
1821 t.Parallel()
1822 if runtime.GOOS != "linux" {
1823 t.Skip("the floor gate refuses off Linux")
1824 }
1825 self, err := os.Executable()
1826 if err != nil {
1827 t.Fatal(err)
1828 }
1829 for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} {
1830 t.Run(unit, func(t *testing.T) {
1831 b, err := unitsFS.ReadFile("units/" + unit)
1832 if err != nil {
1833 t.Fatal(err)
1834 }
1835 var line string
1836 for _, l := range strings.Split(string(b), "\n") {
1837 if strings.HasPrefix(l, "ExecStart=") {
1838 line = l
1839 }
1840 }
1841 // systemd expands %-specifiers and $VARIABLES in ExecStart before
1842 // the shell ever sees the line (systemd.service(5)).
1843 if strings.ContainsAny(line, "%$") {
1844 t.Fatalf("ExecStart carries a %% or a $, which systemd expands: %s", line)
1845 }
1846 for _, path := range []string{"/data/" + moves.FloorMinReleaseFile, "/opt/carlos/" + moves.PrevAllowedFile} {
1847 if !strings.Contains(line, path) {
1848 t.Fatalf("ExecStart does not read %s: %s", path, line)
1849 }
1850 }
1851 body, ok := strings.CutPrefix(line, "ExecStart=/bin/sh -c '")
1852 body, ok2 := strings.CutSuffix(body, "'")
1853 if !ok || !ok2 || strings.Contains(body, "'") {
1854 t.Fatalf("ExecStart is not one quoted sh -c body: %s", line)
1855 }
1856
1857 for _, tc := range []struct {
1858 name string
1859 journal bool
1860 floor, verdict string // "" means no file
1861 floorKind string // "" a regular file; or symlink, dangling, directory, fifo
1862 noPrev bool
1863 want string
1864 gate string // which binary ran the floor gate, "" for none
1865 prevNoVerb bool // carlos.prev predates the gate and rejects its verb
1866 }{
1867 {"no journal", false, "", "", "", false, "carlos", "", false},
1868 {"no floor", true, "", "", "", false, "carlos.prev", "", false},
1869 {"no floor, no prev", true, "", "", "", true, "carlos", "", false},
1870 {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", "", false, "carlos.prev", "carlos.prev", false},
1871 {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", "", false, "carlos", "carlos.prev", false},
1872 {"floor and no verdict", true, "v0.30.0\n", "", "", false, "carlos", "carlos.prev", false},
1873 {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", "", false, "carlos", "", false},
1874 {"armed, no prev", true, "v0.30.0\n", "v0.30.0\n", "", true, "carlos", "", false},
1875 // The agent owns /data: anything but a regular file at the
1876 // floor path is refused before the gate runs (#267), and the
1877 // gate refuses one swapped in after that test (it never
1878 // follows a symlink or opens a FIFO for a blocking read).
1879 // [ -s ] is false for a dangling symlink and a FIFO, so
1880 // without the shell's test both would read as "no floor".
1881 {"symlink floor with matching verdict", true, "v0.30.0\n", "v0.30.0\n", "symlink", false, "carlos", "", false},
1882 {"dangling symlink floor", true, "", "", "dangling", false, "carlos", "", false},
1883 {"directory floor", true, "", "", "directory", false, "carlos", "", false},
1884 {"fifo floor", true, "", "v0.30.0\n", "fifo", false, "carlos", "", false},
1885 // A carlos.prev older than the gate answers its verb with
1886 // an unknown-verb exit: a mismatch, so carlos runs.
1887 {"a carlos.prev without the gate verb", true, "v0.30.0\n", "v0.30.0\n", "", false, "carlos", "carlos.prev", true},
1888 } {
1889 t.Run(tc.name, func(t *testing.T) {
1890 root := t.TempDir()
1891 opt, data := filepath.Join(root, "opt"), filepath.Join(root, "data")
1892 ran, gate := filepath.Join(root, "ran"), filepath.Join(root, "gate")
1893 os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
1894 os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
1895 // Each stub answers the gate verb by handing it to this
1896 // test binary (TestFloorGateHelperProcess), noting which
1897 // stub was asked; anything else records the stub as run.
1898 stub := func(name string) {
1899 answer := " shift 2\n" +
1900 " exec " + self + " -test.run='^TestFloorGateHelperProcess$' -- \"$@\"\n"
1901 if name == "carlos.prev" && tc.prevNoVerb {
1902 answer = " echo 'system: unknown verb \"floor-allows-prev\"' >&2\n exit 2\n"
1903 }
1904 sh := "#!/bin/sh\n" +
1905 "if [ \"$1\" = system ] && [ \"$2\" = floor-allows-prev ]; then\n" +
1906 " echo " + name + " >> " + gate + "\n" +
1907 answer +
1908 "fi\n" +
1909 "echo " + name + " > " + ran + "\n"
1910 os.WriteFile(filepath.Join(opt, name), []byte(sh), 0o755)
1911 }
1912 stub("carlos")
1913 if !tc.noPrev {
1914 stub("carlos.prev")
1915 }
1916 if tc.journal {
1917 os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
1918 }
1919 floorPath := filepath.Join(data, moves.FloorMinReleaseFile)
1920 switch tc.floorKind {
1921 case "symlink", "dangling":
1922 target := filepath.Join(root, "floor-target")
1923 if tc.floorKind == "symlink" {
1924 if err := os.WriteFile(target, []byte(tc.floor), 0o644); err != nil {
1925 t.Fatal(err)
1926 }
1927 }
1928 if err := os.Symlink(target, floorPath); err != nil {
1929 t.Fatal(err)
1930 }
1931 case "directory":
1932 if err := os.Mkdir(floorPath, 0o755); err != nil {
1933 t.Fatal(err)
1934 }
1935 case "fifo":
1936 if out, err := exec.Command("mkfifo", floorPath).CombinedOutput(); err != nil {
1937 t.Fatalf("mkfifo: %v: %s", err, out)
1938 }
1939 default:
1940 if tc.floor != "" {
1941 if err := os.WriteFile(floorPath, []byte(tc.floor), 0o644); err != nil {
1942 t.Fatal(err)
1943 }
1944 }
1945 }
1946 if tc.verdict != "" {
1947 os.WriteFile(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.verdict), 0o644)
1948 }
1949 script := strings.ReplaceAll(body, "/data/", data+"/")
1950 script = strings.ReplaceAll(script, "/opt/carlos/", opt+"/")
1951 cmd := exec.Command("/bin/sh", "-c", script)
1952 cmd.Env = append(os.Environ(), floorGateHelperEnv+"=1")
1953 if out, err := cmd.CombinedOutput(); err != nil {
1954 t.Fatalf("%v: %s", err, out)
1955 }
1956 got, _ := os.ReadFile(ran)
1957 if strings.TrimSpace(string(got)) != tc.want {
1958 t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want)
1959 }
1960 asked, _ := os.ReadFile(gate)
1961 if strings.TrimSpace(string(asked)) != tc.gate {
1962 t.Fatalf("the floor gate ran from %q, want %q", strings.TrimSpace(string(asked)), tc.gate)
1963 }
1964 })
1965 }
1966 })
1967 }
1968}
1969