| 1 | package host |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | type 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 | |
| 35 | func 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. |
| 55 | func 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 | |
| 83 | func 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. |
| 100 | func 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 | |
| 104 | func 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. |
| 120 | func 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. |
| 138 | func 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. |
| 165 | func 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 | |
| 199 | func 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. |
| 213 | func 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. |
| 251 | func 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. |
| 292 | func 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. |
| 322 | func 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. |
| 348 | func 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. |
| 380 | func 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. |
| 401 | func 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 | |
| 410 | func 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. |
| 418 | func 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). |
| 488 | func 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. |
| 575 | func 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. |
| 593 | func 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. |
| 622 | func 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. |
| 640 | func 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. |
| 669 | func 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. |
| 684 | func 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. |
| 713 | func 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. |
| 748 | func 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. |
| 784 | func 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. |
| 820 | func 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 | |