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 "encoding/json"
6 "fmt"
7 "log"
8 "os"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/platform/internal/caps"
14 "github.com/carlosframework/platform/internal/registry"
15 "github.com/carlosframework/platform/internal/release"
16)
17
18// ---- fixtures --------------------------------------------------------------
19
20// passOps is a caps.Ops whose every OS contact is a temp dir or a recorder:
21// no systemd, no /etc, no useradd, no root. Query answers "yes" to
22// is-enabled/is-active so a converged box's pass has no drift to repair and
23// the "zero calls when nothing changed" assertion means what it says.
24type passRec struct {
25 calls [][]string
26 users []string
27 logs []string
28 // query is what systemd would answer for is-enabled/is-active. It
29 // defaults to "yes to everything" — a converged, running box, where the
30 // pass has no drift to repair — and a test swaps it to make systemd
31 // disagree with the declaration.
32 query func(args ...string) bool
33}
34
35func passOps(t *testing.T) (caps.Ops, *passRec) {
36 t.Helper()
37 rec := &passRec{query: func(...string) bool { return true }}
38 return caps.Ops{
39 EtcDir: t.TempDir(),
40 StateRoot: t.TempDir(),
41 RunDir: t.TempDir(),
42 Systemctl: func(args ...string) error {
43 rec.calls = append(rec.calls, args)
44 return nil
45 },
46 Query: func(args ...string) bool { return rec.query(args...) },
47 EnsureUser: func(name string) error { rec.users = append(rec.users, name); return nil },
48 Logf: func(f string, a ...any) { rec.logs = append(rec.logs, strings.TrimSpace(fmt.Sprintf(f, a...))) },
49 }, rec
50}
51
52// stageVersion writes a staged binary plus its sibling manifest for ver, and
53// returns nothing: every caller then points the live symlink itself, because
54// WHICH version is live is the thing most of these tests are about.
55func stageVersion(t *testing.T, optDir, app, ver string, set *caps.Set) {
56 t.Helper()
57 m := release.Manifest{
58 Schema: 1,
59 App: app,
60 Version: ver,
61 Kind: "binary",
62 Artifacts: map[string]release.Artifact{
63 "linux-arm64": {File: app + "-" + ver, SHA256: strings.Repeat("a", 64), Size: 1},
64 },
65 Capabilities: set,
66 }
67 raw, err := json.MarshalIndent(m, "", " ")
68 if err != nil {
69 t.Fatal(err)
70 }
71 bin := release.VersionPath(optDir, release.OpsAccount, ver, app)
72 if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
73 t.Fatal(err)
74 }
75 if err := os.WriteFile(bin, []byte("#!/bin/true\n"), 0o755); err != nil {
76 t.Fatal(err)
77 }
78 if err := os.WriteFile(release.StagedManifestPath(optDir, release.OpsAccount, ver, app), raw, 0o644); err != nil {
79 t.Fatal(err)
80 }
81}
82
83func openReg(t *testing.T, routes ...registry.Route) *registry.Registry {
84 t.Helper()
85 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
86 if err != nil {
87 t.Fatal(err)
88 }
89 for _, rt := range routes {
90 if err := reg.Upsert(rt); err != nil {
91 t.Fatal(err)
92 }
93 }
94 t.Cleanup(func() { reg.Close() })
95 return reg
96}
97
98// unitRoute is the shape every capability route has: a ready, non-platform,
99// unit-backed instance of a known app.
100func capsUnitRoute(host, app string) registry.Route {
101 return registry.Route{Host: host, Kind: "instance", App: app, Backing: registry.BackingUnit, Status: "ready", Socket: "/run/carlos/apps/" + host + ".sock"}
102}
103
104func readDropIns(t *testing.T, o caps.Ops, host string) (svc, sock string) {
105 t.Helper()
106 svcPath, sockPath := caps.DropInPaths(o.EtcDir, host)
107 b, err := os.ReadFile(svcPath)
108 if err == nil {
109 svc = string(b)
110 }
111 b, err = os.ReadFile(sockPath)
112 if err == nil {
113 sock = string(b)
114 }
115 return svc, sock
116}
117
118// captureLog redirects the standard logger — where every advisory in this
119// package lands — for the duration of the returned func.
120func captureLog(t *testing.T) func() string {
121 t.Helper()
122 var buf bytes.Buffer
123 old := log.Writer()
124 flags := log.Flags()
125 log.SetOutput(&buf)
126 log.SetFlags(0)
127 t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) })
128 return buf.String
129}
130
131// ---- capabilityPass --------------------------------------------------------
132
133// The pass converges from the staged manifest of the version that is LIVE,
134// never the newest version staged on the box. A box that has staged a build
135// it has not adopted (a bake window, a poisoned build, a rollback) must keep
136// running the unit shape of what it is actually running — the opposite would
137// bind a port for a binary that is not there.
138func TestCapabilityPassConvergesFromTheStagedManifestOfTheLiveVersion(t *testing.T) {
139 optDir := t.TempDir()
140 o, _ := passOps(t)
141 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25, Name: "smtp"}}})
142 stageVersion(t, optDir, "keymail", "bbbbbbb", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 587}}})
143 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
144 t.Fatal(err)
145 }
146 rt := capsUnitRoute("keymail.dev", "keymail")
147 rt.Caps = "port:25,port:587" // both granted; only the live manifest declares 25
148 reg := openReg(t, rt)
149
150 if err := capabilityPass(reg, optDir, o); err != nil {
151 t.Fatalf("capabilityPass: %v", err)
152 }
153 _, sock := readDropIns(t, o, "keymail.dev")
154 if !strings.Contains(sock, "ListenStream=25") {
155 t.Errorf("socket drop-in = %q, want the LIVE version's ListenStream=25", sock)
156 }
157 if strings.Contains(sock, "ListenStream=587") {
158 t.Errorf("socket drop-in = %q, want nothing from the newer staged version", sock)
159 }
160}
161
162// A grant added after the version went live is exactly what this pass exists
163// for: nothing about the release changed, so adoptHost's converge never runs
164// — this one has to bind the port.
165func TestCapabilityPassMaterializesAGrantAddedAfterTheVersionWentLive(t *testing.T) {
166 optDir := t.TempDir()
167 o, rec := passOps(t)
168 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25}}})
169 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
170 t.Fatal(err)
171 }
172 rt := capsUnitRoute("keymail.dev", "keymail")
173 reg := openReg(t, rt)
174
175 // Ungranted first: nothing binds.
176 if err := capabilityPass(reg, optDir, o); err != nil {
177 t.Fatal(err)
178 }
179 if _, sock := readDropIns(t, o, "keymail.dev"); sock != "" {
180 t.Fatalf("socket drop-in written without a grant: %q", sock)
181 }
182 // Then the operator grants it.
183 if err := reg.SetCaps("keymail.dev", "", "port:25"); err != nil {
184 t.Fatal(err)
185 }
186 if err := capabilityPass(reg, optDir, o); err != nil {
187 t.Fatal(err)
188 }
189 if _, sock := readDropIns(t, o, "keymail.dev"); !strings.Contains(sock, "ListenStream=25") {
190 t.Errorf("socket drop-in after the grant = %q, want ListenStream=25", sock)
191 }
192 // The listener has to be re-bound, not merely re-written: DeferRestart is
193 // FALSE on this path, so the socket sequence runs here.
194 if !recordedVerb(rec, "restart", caps.SocketUnit("keymail.dev")) && !recordedVerb(rec, "start", caps.SocketUnit("keymail.dev")) {
195 t.Errorf("systemctl calls = %v, want the socket unit re-bound", rec.calls)
196 }
197}
198
199func recordedVerb(rec *passRec, verb, unit string) bool {
200 for _, c := range rec.calls {
201 if len(c) == 2 && c[0] == verb && c[1] == unit {
202 return true
203 }
204 }
205 return false
206}
207
208// Two converges of the same shape in one hour — this pass, then adoptHost's
209// on a version change, or simply two passes — must be a no-op the second
210// time: byte-identical content, no write, no daemon-reload, no restart. An
211// hourly pass that cycled a mail server every hour would be worse than no
212// pass at all.
213func TestCapabilityPassIsQuietWhenNothingChanged(t *testing.T) {
214 optDir := t.TempDir()
215 o, rec := passOps(t)
216 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, HostRole: true, Ports: []caps.Port{{Port: 25}}})
217 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
218 t.Fatal(err)
219 }
220 rt := capsUnitRoute("keymail.dev", "keymail")
221 rt.Caps = "host-role,port:25"
222 reg := openReg(t, rt)
223
224 if err := capabilityPass(reg, optDir, o); err != nil {
225 t.Fatal(err)
226 }
227 if len(rec.calls) == 0 {
228 t.Fatal("first pass made no systemctl calls at all — it converged nothing")
229 }
230
231 rec.calls, rec.logs, rec.users = nil, nil, nil
232 logged := captureLog(t)
233 if err := capabilityPass(reg, optDir, o); err != nil {
234 t.Fatal(err)
235 }
236 if len(rec.calls) != 0 {
237 t.Errorf("second pass systemctl calls = %v, want none", rec.calls)
238 }
239 if len(rec.logs) != 0 {
240 t.Errorf("second pass converge log = %v, want silence", rec.logs)
241 }
242 if out := logged(); out != "" {
243 t.Errorf("second pass advisories = %q, want silence on a converged box", out)
244 }
245}
246
247// A sibling manifest that is absent (never staged, or a write that failed) is
248// the leave-alone state, NOT a removal: a bucket hiccup must not strip :25
249// off a running mail server. The drop-ins on disk stay byte-identical and the
250// pass says so once.
251func TestCapabilityPassLeavesDropInsAloneWhenTheSiblingIsMissing(t *testing.T) {
252 optDir := t.TempDir()
253 o, rec := passOps(t)
254 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25}}})
255 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
256 t.Fatal(err)
257 }
258 rt := capsUnitRoute("keymail.dev", "keymail")
259 rt.Caps = "port:25"
260 reg := openReg(t, rt)
261 if err := capabilityPass(reg, optDir, o); err != nil {
262 t.Fatal(err)
263 }
264 svcBefore, sockBefore := readDropIns(t, o, "keymail.dev")
265 if sockBefore == "" {
266 t.Fatal("nothing converged, so this test would prove nothing")
267 }
268
269 // The sibling disappears — the exact state a failed StageManifest leaves.
270 if err := os.Remove(release.StagedManifestPath(optDir, release.OpsAccount, "aaaaaaa", "keymail")); err != nil {
271 t.Fatal(err)
272 }
273 rec.calls, rec.logs = nil, nil
274 if err := capabilityPass(reg, optDir, o); err != nil {
275 t.Fatal(err)
276 }
277 svcAfter, sockAfter := readDropIns(t, o, "keymail.dev")
278 if svcAfter != svcBefore || sockAfter != sockBefore {
279 t.Errorf("drop-ins changed with no manifest:\n svc %q -> %q\n sock %q -> %q", svcBefore, svcAfter, sockBefore, sockAfter)
280 }
281 if len(rec.calls) != 0 {
282 t.Errorf("systemctl calls = %v, want none — leave-alone changes no unit state", rec.calls)
283 }
284 if !strings.Contains(strings.Join(rec.logs, "\n"), "no capability manifest resolved") {
285 t.Errorf("logs = %v, want the leave-alone line", rec.logs)
286 }
287}
288
289// ops.FleetDBs learns a unit tenant's database from the app's own
290// declaration, instead of depending on an operator having typed -db at
291// `carlos add`. dbs[0] SORTED, under the route's own state dir.
292func TestCapabilityPassStampsDBPathFromDataDBs(t *testing.T) {
293 optDir := t.TempDir()
294 o, _ := passOps(t)
295 stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{
296 V: 1, Daemon: true,
297 // Declared order, NOT sorted: an author writing console.db first
298 // means console.db is the one that matters, and a lexicographic sort
299 // would have replicated audit.db instead — the platform silently
300 // picking a different database than the app named.
301 Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db", "audit.db"}},
302 })
303 if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil {
304 t.Fatal(err)
305 }
306 reg := openReg(t, capsUnitRoute("console.example.com", "console"))
307 if err := capabilityPass(reg, optDir, o); err != nil {
308 t.Fatal(err)
309 }
310 got, err := reg.Get("console.example.com")
311 if err != nil {
312 t.Fatal(err)
313 }
314 want := filepath.Join(o.StateRoot, "console.example.com", "console.db")
315 if got.DBPath != want {
316 t.Errorf("DBPath = %q, want %q (dbs[0] AS DECLARED, under the state dir)", got.DBPath, want)
317 }
318}
319
320// The stamp writes only when the value would change: this pass runs hourly,
321// and a registry write per pass is a heartbeat nobody asked for.
322func TestCapabilityPassDoesNotRestampAnUnchangedDBPath(t *testing.T) {
323 optDir := t.TempDir()
324 o, _ := passOps(t)
325 stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{
326 V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}},
327 })
328 if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil {
329 t.Fatal(err)
330 }
331 rt := capsUnitRoute("console.example.com", "console")
332 rt.DBPath = filepath.Join(o.StateRoot, "console.example.com", "console.db")
333 reg := openReg(t, rt)
334
335 logged := captureLog(t)
336 if err := capabilityPass(reg, optDir, o); err != nil {
337 t.Fatal(err)
338 }
339 if strings.Contains(logged(), "db_path recorded") {
340 t.Errorf("log = %q, want no stamp line for an unchanged db_path", logged())
341 }
342}
343
344// replicate:"app" means the app streams its own WAL. The platform never
345// clears an existing DBPath to match: two replicators on one WAL fight over
346// checkpointing and corrupt the replica, and deciding which one stops is an
347// operator's call, not an hourly timer's. It advises instead.
348func TestCapabilityPassNeverClearsDBPathForAppReplication(t *testing.T) {
349 optDir := t.TempDir()
350 o, _ := passOps(t)
351 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{
352 V: 1, Daemon: true, Data: &caps.Data{Replicate: caps.DataReplicateApp},
353 })
354 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
355 t.Fatal(err)
356 }
357 rt := capsUnitRoute("keymail.dev", "keymail")
358 rt.DBPath = "/data/keymail.dev.db"
359 reg := openReg(t, rt)
360
361 logged := captureLog(t)
362 if err := capabilityPass(reg, optDir, o); err != nil {
363 t.Fatal(err)
364 }
365 got, err := reg.Get("keymail.dev")
366 if err != nil {
367 t.Fatal(err)
368 }
369 if got.DBPath != "/data/keymail.dev.db" {
370 t.Errorf("DBPath = %q, want it untouched", got.DBPath)
371 }
372 if !strings.Contains(logged(), "two replicators on one WAL") {
373 t.Errorf("log = %q, want the two-replicators advisory", logged())
374 }
375}
376
377// registry.Open silently creates an empty database when handed a wrong path
378// or an unmounted /data. A pass against that must do nothing at all — the
379// same guard, for the same reason, as appconfig.Sync's.
380func TestCapabilityPassWithNoRoutesTouchesNothing(t *testing.T) {
381 o, rec := passOps(t)
382 reg := openReg(t)
383 if err := capabilityPass(reg, t.TempDir(), o); err != nil {
384 t.Fatal(err)
385 }
386 ents, err := os.ReadDir(o.EtcDir)
387 if err != nil {
388 t.Fatal(err)
389 }
390 if len(ents) != 0 {
391 t.Errorf("EtcDir = %v, want empty", ents)
392 }
393 if len(rec.calls) != 0 {
394 t.Errorf("systemctl calls = %v, want none", rec.calls)
395 }
396}
397
398// ---- buildPlans (the pure half) --------------------------------------------
399
400// loader turns a fixed host -> manifest map into buildPlans' seam.
401func loader(m map[string]*release.Manifest, errs map[string]error) func(registry.Route) (*release.Manifest, error) {
402 return func(rt registry.Route) (*release.Manifest, error) {
403 if err := errs[rt.Host]; err != nil {
404 return nil, err
405 }
406 return m[rt.Host], nil
407 }
408}
409
410func manifest(app string, set *caps.Set) *release.Manifest {
411 return &release.Manifest{Schema: 1, App: app, Version: "aaaaaaa", Kind: "binary", Capabilities: set}
412}
413
414// The declared-but-ungranted advisory: one line per (route, operator-consented
415// capability), naming the consequence and a command an operator can paste.
416// The self-service capabilities never produce a line — there is nothing to
417// grant, so a line would be hourly noise.
418func TestBuildPlansAdvisesUngrantedDeclaredCapabilities(t *testing.T) {
419 cases := []struct {
420 name string
421 set *caps.Set
422 grants string
423 want []string
424 wantNot []string
425 }{
426 {
427 name: "ungranted port names the exact grant command",
428 set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25, Name: "smtp"}}},
429 want: []string{"declares capability port:25", "nothing is bound on :25", "carlos route --host keymail.dev --grant port:25"},
430 },
431 {
432 name: "granted port says nothing",
433 set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}},
434 grants: "port:25",
435 want: nil,
436 },
437 {
438 name: "ungranted host_role names the transfer of authority",
439 set: &caps.Set{V: 1, HostRole: true},
440 want: []string{"declares capability host-role", "carlos route --host keymail.dev --grant host-role"},
441 },
442 {
443 name: "ungranted doc_engine says the engine will not start, and names the fix",
444 set: &caps.Set{V: 1, DocEngine: true},
445 want: []string{"declares capability doc-engine", "MemoryDenyWriteExecute", "OOM-killed", "carlos route --host keymail.dev --grant doc-engine"},
446 },
447 {
448 name: "one line per capability, in port order",
449 set: &caps.Set{V: 1, HostRole: true, Ports: []caps.Port{{Port: 465}, {Port: 25}}},
450 grants: "",
451 want: []string{"--grant port:25", "--grant port:465", "--grant host-role"},
452 },
453 {
454 name: "self-service capabilities are never advised",
455 set: &caps.Set{V: 1, Daemon: true, Children: true, Command: "relay", RuntimeDir: "keymail"},
456 wantNot: []string{"has not granted"},
457 },
458 }
459 for _, c := range cases {
460 t.Run(c.name, func(t *testing.T) {
461 rt := capsUnitRoute("keymail.dev", "keymail")
462 rt.Caps = c.grants
463 _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", c.set)}, nil))
464 joined := strings.Join(advice, "\n")
465 for _, w := range c.want {
466 if !strings.Contains(joined, w) {
467 t.Errorf("advice =\n%s\nwant a line containing %q", joined, w)
468 }
469 }
470 for _, w := range c.wantNot {
471 if strings.Contains(joined, w) {
472 t.Errorf("advice =\n%s\nwant NOTHING containing %q", joined, w)
473 }
474 }
475 if len(c.want) == 0 && len(c.wantNot) == 0 && len(advice) != 0 {
476 t.Errorf("advice = %v, want none", advice)
477 }
478 })
479 }
480}
481
482// The mirror of the advisory above (2026-08-03 review, M5): a token this box
483// HAS granted that the live release does not declare. It materializes nothing,
484// so it is not a hole — it is a registry row that reads like a hole, which is
485// what an operator is looking at during an incident. Two ways in: a grant
486// staged for a release nobody shipped, and a release that DROPPED a capability
487// it used to declare (converge removes the drop-in; the grant stays behind).
488func TestBuildPlansAdvisesGrantedButUndeclaredCapabilities(t *testing.T) {
489 cases := []struct {
490 name string
491 set *caps.Set
492 grants string
493 want []string
494 wantNot []string
495 }{
496 {
497 name: "granted port the live release never declares",
498 set: &caps.Set{V: 1, Daemon: true},
499 grants: "port:25",
500 want: []string{"is granted port:25", "the live release does not declare it",
501 "carlos route --host keymail.dev --revoke port:25", "ship a release that declares it"},
502 },
503 {
504 name: "granted host-role the live release never declares",
505 set: &caps.Set{V: 1, Daemon: true},
506 grants: "host-role",
507 want: []string{"is granted host-role", "keeps DynamicUser",
508 "carlos route --host keymail.dev --revoke host-role"},
509 },
510 {
511 name: "granted doc-engine the live release never declares",
512 set: &caps.Set{V: 1, Daemon: true},
513 grants: "doc-engine",
514 want: []string{"is granted doc-engine", "keeps the default resource tier",
515 "carlos route --host keymail.dev --revoke doc-engine"},
516 },
517 {
518 name: "declared and granted says nothing",
519 set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}, HostRole: true},
520 grants: "host-role,port:25",
521 want: nil,
522 },
523 {
524 name: "one line per token, in port order, host-role last",
525 set: &caps.Set{V: 1},
526 grants: "host-role,port:25,port:465",
527 want: []string{"--revoke port:25", "--revoke port:465", "--revoke host-role"},
528 },
529 {
530 name: "a declared-but-ungranted capability is the OTHER advisory, not this one",
531 set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}},
532 want: []string{"has not granted it"},
533 // The two lines are opposites and must never both fire for one
534 // token: "granted but not declared" about a token nobody granted
535 // would be nonsense an operator would chase.
536 wantNot: []string{"is granted port:25"},
537 },
538 {
539 name: "a partly-declared grant advises only the undeclared half",
540 set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}},
541 grants: "host-role,port:25",
542 want: []string{"is granted host-role"},
543 // port:25 is declared AND granted — the healthy case, silent.
544 wantNot: []string{"port:25 but", "is granted port:25"},
545 },
546 }
547 for _, c := range cases {
548 t.Run(c.name, func(t *testing.T) {
549 rt := capsUnitRoute("keymail.dev", "keymail")
550 rt.Caps = c.grants
551 _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", c.set)}, nil))
552 joined := strings.Join(advice, "\n")
553 for _, w := range c.want {
554 if !strings.Contains(joined, w) {
555 t.Errorf("advice =\n%s\nwant a line containing %q", joined, w)
556 }
557 }
558 for _, w := range c.wantNot {
559 if strings.Contains(joined, w) {
560 t.Errorf("advice =\n%s\nwant NOTHING containing %q", joined, w)
561 }
562 }
563 if len(c.want) == 0 && len(c.wantNot) == 0 && len(advice) != 0 {
564 t.Errorf("advice = %v, want none", advice)
565 }
566 })
567 }
568}
569
570// A route whose live manifest could not be read gets NO grant advisory at
571// all — in either direction. The leave-alone state means this pass does not
572// know what the release declares, and "granted but not declared" printed from
573// an absent manifest would send an operator to revoke a grant the running
574// release is using right now.
575func TestBuildPlansSaysNothingAboutGrantsWithNoManifest(t *testing.T) {
576 rt := capsUnitRoute("keymail.dev", "keymail")
577 rt.Caps = "host-role,port:25"
578 plans, advice := buildPlans([]registry.Route{rt},
579 loader(map[string]*release.Manifest{}, nil)) // (nil, nil): no live version
580 if len(plans) != 1 || plans[0].Set != nil {
581 t.Fatalf("plans = %+v, want one leave-alone plan with a nil Set", plans)
582 }
583 if len(advice) != 0 {
584 t.Fatalf("advice = %v, want none — nothing is known about what the release declares", advice)
585 }
586}
587
588// An exec-backed route whose release declares capabilities materializes
589// nothing (release.CapabilityRoute excludes it) and is told so — the guided
590// path through the §5 migration, and the line an operator sees during the
591// reversible dress rehearsal. It gets no plan, so nothing can be written for
592// it by accident.
593func TestBuildPlansAdvisesExecBackedCapabilities(t *testing.T) {
594 execRT := registry.Route{Host: "console.example.com", Kind: "instance", App: "console", Status: "ready"}
595 quietRT := registry.Route{Host: "blog.example.com", Kind: "instance", App: "blog", Status: "ready"}
596 aliasRT := registry.Route{Host: "alias.example.com", Kind: "instance", App: "console", Platform: true, Status: "ready"}
597 set := &caps.Set{V: 1, Daemon: true, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}}
598 plans, advice := buildPlans(
599 []registry.Route{execRT, quietRT, aliasRT},
600 loader(map[string]*release.Manifest{
601 "console.example.com": manifest("console", set),
602 "blog.example.com": manifest("blog", nil),
603 "alias.example.com": manifest("console", set),
604 }, nil),
605 )
606 if len(plans) != 0 {
607 t.Errorf("plans = %v, want none — nothing may be materialized for an exec-backed or alias route", plans)
608 }
609 if len(advice) != 1 {
610 t.Fatalf("advice = %v, want exactly one line (the exec-backed route only)", advice)
611 }
612 for _, w := range []string{"console.example.com", "exec-backed", "backing='unit'", "§5"} {
613 if !strings.Contains(advice[0], w) {
614 t.Errorf("advice = %q, want it to contain %q", advice[0], w)
615 }
616 }
617}
618
619// A caps column this binary cannot read refuses that route's converge and
620// says so. Defaulting to "no grants" would be a revoke nobody typed — and a
621// revoke of port:25 drops a mail server's listener.
622func TestBuildPlansRefusesAnUnparseableGrantsColumn(t *testing.T) {
623 rt := capsUnitRoute("keymail.dev", "keymail")
624 rt.Caps = "port:025,host-role" // a hand edit: PortToken never writes "025"
625 plans, advice := buildPlans([]registry.Route{rt},
626 loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}})}, nil))
627 if len(plans) != 0 {
628 t.Errorf("plans = %v, want none — an unreadable grants column converges nothing", plans)
629 }
630 if len(advice) != 1 || !strings.Contains(advice[0], "keymail.dev") || !strings.Contains(advice[0], "carlos route --host keymail.dev --grant/--revoke") {
631 t.Errorf("advice = %v, want one line naming the row and the fix", advice)
632 }
633}
634
635// The three states of a manifest read are not interchangeable (spec §4d):
636// unreadable -> no plan at all (leave-alone, plus a line naming the failure);
637// absent -> a plan with a NIL Set (converge's own leave-alone, logged there);
638// resolved-but-declaring-nothing -> a NON-NIL empty Set, which is what makes
639// converge REMOVE a drop-in for a capability the app has dropped.
640func TestBuildPlansDistinguishesTheThreeManifestStates(t *testing.T) {
641 unreadable := capsUnitRoute("bad.example.com", "keymail")
642 absent := capsUnitRoute("absent.example.com", "keymail")
643 empty := capsUnitRoute("empty.example.com", "keymail")
644 plans, advice := buildPlans(
645 []registry.Route{unreadable, absent, empty},
646 loader(
647 map[string]*release.Manifest{"empty.example.com": manifest("keymail", nil)},
648 map[string]error{"bad.example.com": os.ErrInvalid},
649 ),
650 )
651 if len(plans) != 2 {
652 t.Fatalf("plans = %v, want 2 (the unreadable route gets none)", plans)
653 }
654 if plans[0].Host != "absent.example.com" || plans[0].Set != nil {
655 t.Errorf("plan[0] = %+v, want absent.example.com with a NIL Set (leave-alone)", plans[0])
656 }
657 if plans[1].Host != "empty.example.com" || plans[1].Set == nil {
658 t.Errorf("plan[1] = %+v, want empty.example.com with a NON-NIL Set (removal semantics)", plans[1])
659 }
660 if len(advice) != 1 || !strings.Contains(advice[0], "bad.example.com") {
661 t.Errorf("advice = %v, want one line naming the unreadable manifest", advice)
662 }
663}
664
665// DeferRestart belongs to the version-change converge alone. Restarting the
666// live binary to pick up a new unit shape is the entire point of THIS pass,
667// and nothing here follows up with a caps.Restart — a deferred plan would
668// leave a daemon stopped until the next version change.
669func TestBuildPlansNeverDefersARestart(t *testing.T) {
670 rt := capsUnitRoute("keymail.dev", "keymail")
671 plans, _ := buildPlans([]registry.Route{rt},
672 loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", &caps.Set{V: 1, Daemon: true})}, nil))
673 if len(plans) != 1 {
674 t.Fatalf("plans = %v, want 1", plans)
675 }
676 if plans[0].DeferRestart {
677 t.Error("DeferRestart is true — this pass has no follow-up restart, so a daemon would stay down")
678 }
679}
680
681// data.dbs is a list and Route.DBPath is single-valued. The extras are named
682// rather than quietly dropped, so nobody believes a database is replicated
683// when it is not.
684func TestBuildPlansAdvisesUnreplicatedExtraDBs(t *testing.T) {
685 rt := capsUnitRoute("console.example.com", "console")
686 _, advice := buildPlans([]registry.Route{rt},
687 loader(map[string]*release.Manifest{"console.example.com": manifest("console", &caps.Set{
688 V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db", "sessions.db", "audit.db"}},
689 })}, nil))
690 joined := strings.Join(advice, "\n")
691 // dbs[0] AS DECLARED is the replicated one, so the two the operator has
692 // to hear about by name are the ones after it — in declaration order,
693 // not lexicographic order.
694 for _, w := range []string{`"sessions.db" is NOT replicated`, `"audit.db" is NOT replicated`} {
695 if !strings.Contains(joined, w) {
696 t.Errorf("advice =\n%s\nwant a line containing %q", joined, w)
697 }
698 }
699 if strings.Contains(joined, `"console.db" is NOT replicated`) {
700 t.Errorf("advice =\n%s\nwant the FIRST db AS DECLARED (console.db) treated as replicated", joined)
701 }
702 if len(advice) != 2 {
703 t.Errorf("advice = %v, want exactly one line per unreplicated extra", advice)
704 }
705}
706
707// A pass that writes no bytes still repairs UNIT STATE — and until the review
708// that found this, it did so in total silence. The scenario is an operator
709// who stopped carlos-app@keymail.dev by hand to debug it: an hour later the
710// pass starts it again, correctly, and the log said nothing at all about the
711// most surprising thing this pass does. The start must happen AND be
712// announced with the verb and the reason.
713func TestCapabilityPassAnnouncesARepairOnABytesUnchangedPass(t *testing.T) {
714 optDir := t.TempDir()
715 o, rec := passOps(t)
716 stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Children: true})
717 if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil {
718 t.Fatal(err)
719 }
720 reg := openReg(t, capsUnitRoute("keymail.dev", "keymail"))
721 if err := capabilityPass(reg, optDir, o); err != nil {
722 t.Fatal(err)
723 }
724
725 // Second pass: byte-identical drop-ins, but systemd says the service is
726 // not running.
727 rec.calls, rec.logs = nil, nil
728 rec.query = func(args ...string) bool {
729 return !(len(args) == 2 && args[0] == "is-active" && args[1] == caps.ServiceUnit("keymail.dev"))
730 }
731 if err := capabilityPass(reg, optDir, o); err != nil {
732 t.Fatal(err)
733 }
734 if !recordedVerb(rec, "start", caps.ServiceUnit("keymail.dev")) {
735 t.Errorf("systemctl calls = %v, want the stopped daemon started", rec.calls)
736 }
737 joined := strings.Join(rec.logs, "\n")
738 if !strings.Contains(joined, "was not running") || !strings.Contains(joined, "started it") {
739 t.Errorf("logs = %v, want a line naming the verb and why it fired", rec.logs)
740 }
741}
742
743// An operator's -db value is a path to where the data lives RIGHT NOW. On a
744// pre-migration route that is /data/<host>.db, the file the box-wide
745// replicator is streaming today — repointing it at the post-migration path
746// before the §5 copy has happened would silently stop replicating a live
747// database. So it is advised and left alone.
748func TestCapabilityPassLeavesAnOperatorsDBPathAloneAndSaysWhy(t *testing.T) {
749 optDir := t.TempDir()
750 o, _ := passOps(t)
751 stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{
752 V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}},
753 })
754 if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil {
755 t.Fatal(err)
756 }
757 rt := capsUnitRoute("console.example.com", "console")
758 rt.DBPath = "/data/console.example.com.db"
759 reg := openReg(t, rt)
760
761 logged := captureLog(t)
762 if err := capabilityPass(reg, optDir, o); err != nil {
763 t.Fatal(err)
764 }
765 got, err := reg.Get("console.example.com")
766 if err != nil {
767 t.Fatal(err)
768 }
769 if got.DBPath != "/data/console.example.com.db" {
770 t.Errorf("DBPath = %q, want the operator's value untouched", got.DBPath)
771 }
772 out := logged()
773 for _, w := range []string{"/data/console.example.com.db", filepath.Join(o.StateRoot, "console.example.com", "console.db"), "stamp follows the copy"} {
774 if !strings.Contains(out, w) {
775 t.Errorf("log = %q, want it to name %q", out, w)
776 }
777 }
778}
779
780// A value this pass itself wrote — under the box's own state dir for this
781// host — IS corrected when the manifest's first database changes, and the log
782// names the old value, because "db_path changed" with no `from` is
783// unreviewable in an hourly log.
784func TestCapabilityPassCorrectsItsOwnStaleDBPathNamingTheOldValue(t *testing.T) {
785 optDir := t.TempDir()
786 o, _ := passOps(t)
787 stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{
788 V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}},
789 })
790 if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil {
791 t.Fatal(err)
792 }
793 stale := filepath.Join(o.StateRoot, "console.example.com", "old.db")
794 rt := capsUnitRoute("console.example.com", "console")
795 rt.DBPath = stale
796 reg := openReg(t, rt)
797
798 logged := captureLog(t)
799 if err := capabilityPass(reg, optDir, o); err != nil {
800 t.Fatal(err)
801 }
802 got, err := reg.Get("console.example.com")
803 if err != nil {
804 t.Fatal(err)
805 }
806 want := filepath.Join(o.StateRoot, "console.example.com", "console.db")
807 if got.DBPath != want {
808 t.Errorf("DBPath = %q, want %q — a value under this box's own state dir is this pass's to correct", got.DBPath, want)
809 }
810 if out := logged(); !strings.Contains(out, stale) || !strings.Contains(out, want) {
811 t.Errorf("log = %q, want it to name both the old and the new value", out)
812 }
813}
814
815// A route whose converge was REFUSED still gets its database replicated. The
816// refusal here is the box-wide port claim (two routes granted :25, the
817// lexicographically later one refused), and it says nothing about the app's
818// data: gating the stamp on converge success would stop backing up a live
819// database because of an unrelated capability conflict.
820func TestCapabilityPassStampsDBPathEvenWhenConvergeIsRefused(t *testing.T) {
821 optDir := t.TempDir()
822 o, _ := passOps(t)
823 set := &caps.Set{
824 V: 1, Daemon: true,
825 Ports: []caps.Port{{Port: 25}},
826 Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}},
827 }
828 stageVersion(t, optDir, "console", "aaaaaaa", set)
829 for _, h := range []string{"a.example.com", "b.example.com"} {
830 if err := release.PointLive(optDir, h, release.OpsAccount, "aaaaaaa", "console"); err != nil {
831 t.Fatal(err)
832 }
833 }
834 routes := make([]registry.Route, 0, 2)
835 for _, h := range []string{"a.example.com", "b.example.com"} {
836 rt := capsUnitRoute(h, "console")
837 rt.Caps = "port:25"
838 routes = append(routes, rt)
839 }
840 reg := openReg(t, routes...)
841
842 if err := capabilityPass(reg, optDir, o); err != nil {
843 t.Fatal(err)
844 }
845 // b sorts later, so CheckClaims refuses it and nothing is written for it.
846 if _, sock := readDropIns(t, o, "b.example.com"); sock != "" {
847 t.Fatalf("b.example.com socket drop-in = %q, want nothing written for a refused route", sock)
848 }
849 got, err := reg.Get("b.example.com")
850 if err != nil {
851 t.Fatal(err)
852 }
853 want := filepath.Join(o.StateRoot, "b.example.com", "console.db")
854 if got.DBPath != want {
855 t.Errorf("DBPath = %q, want %q — a capability refusal is not a reason to stop replicating data", got.DBPath, want)
856 }
857}
858