package host import ( "bytes" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ---- fixtures -------------------------------------------------------------- // passOps is a caps.Ops whose every OS contact is a temp dir or a recorder: // no systemd, no /etc, no useradd, no root. Query answers "yes" to // is-enabled/is-active so a converged box's pass has no drift to repair and // the "zero calls when nothing changed" assertion means what it says. type passRec struct { calls [][]string users []string logs []string // query is what systemd would answer for is-enabled/is-active. It // defaults to "yes to everything" — a converged, running box, where the // pass has no drift to repair — and a test swaps it to make systemd // disagree with the declaration. query func(args ...string) bool } func passOps(t *testing.T) (caps.Ops, *passRec) { t.Helper() rec := &passRec{query: func(...string) bool { return true }} return caps.Ops{ EtcDir: t.TempDir(), StateRoot: t.TempDir(), RunDir: t.TempDir(), Systemctl: func(args ...string) error { rec.calls = append(rec.calls, args) return nil }, Query: func(args ...string) bool { return rec.query(args...) }, EnsureUser: func(name string) error { rec.users = append(rec.users, name); return nil }, Logf: func(f string, a ...any) { rec.logs = append(rec.logs, strings.TrimSpace(fmt.Sprintf(f, a...))) }, }, rec } // stageVersion writes a staged binary plus its sibling manifest for ver, and // returns nothing: every caller then points the live symlink itself, because // WHICH version is live is the thing most of these tests are about. func stageVersion(t *testing.T, optDir, app, ver string, set *caps.Set) { t.Helper() m := release.Manifest{ Schema: 1, App: app, Version: ver, Kind: "binary", Artifacts: map[string]release.Artifact{ "linux-arm64": {File: app + "-" + ver, SHA256: strings.Repeat("a", 64), Size: 1}, }, Capabilities: set, } raw, err := json.MarshalIndent(m, "", " ") if err != nil { t.Fatal(err) } bin := release.VersionPath(optDir, release.OpsAccount, ver, app) if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(bin, []byte("#!/bin/true\n"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(release.StagedManifestPath(optDir, release.OpsAccount, ver, app), raw, 0o644); err != nil { t.Fatal(err) } } func openReg(t *testing.T, routes ...registry.Route) *registry.Registry { t.Helper() reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } for _, rt := range routes { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } t.Cleanup(func() { reg.Close() }) return reg } // unitRoute is the shape every capability route has: a ready, non-platform, // unit-backed instance of a known app. func capsUnitRoute(host, app string) registry.Route { return registry.Route{Host: host, Kind: "instance", App: app, Backing: registry.BackingUnit, Status: "ready", Socket: "/run/carlos/apps/" + host + ".sock"} } func readDropIns(t *testing.T, o caps.Ops, host string) (svc, sock string) { t.Helper() svcPath, sockPath := caps.DropInPaths(o.EtcDir, host) b, err := os.ReadFile(svcPath) if err == nil { svc = string(b) } b, err = os.ReadFile(sockPath) if err == nil { sock = string(b) } return svc, sock } // captureLog redirects the standard logger — where every advisory in this // package lands — for the duration of the returned func. func captureLog(t *testing.T) func() string { t.Helper() var buf bytes.Buffer old := log.Writer() flags := log.Flags() log.SetOutput(&buf) log.SetFlags(0) t.Cleanup(func() { log.SetOutput(old); log.SetFlags(flags) }) return buf.String } // ---- capabilityPass -------------------------------------------------------- // The pass converges from the staged manifest of the version that is LIVE, // never the newest version staged on the box. A box that has staged a build // it has not adopted (a bake window, a poisoned build, a rollback) must keep // running the unit shape of what it is actually running — the opposite would // bind a port for a binary that is not there. func TestCapabilityPassConvergesFromTheStagedManifestOfTheLiveVersion(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25, Name: "smtp"}}}) stageVersion(t, optDir, "keymail", "bbbbbbb", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 587}}}) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = "port:25,port:587" // both granted; only the live manifest declares 25 reg := openReg(t, rt) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatalf("capabilityPass: %v", err) } _, sock := readDropIns(t, o, "keymail.dev") if !strings.Contains(sock, "ListenStream=25") { t.Errorf("socket drop-in = %q, want the LIVE version's ListenStream=25", sock) } if strings.Contains(sock, "ListenStream=587") { t.Errorf("socket drop-in = %q, want nothing from the newer staged version", sock) } } // A grant added after the version went live is exactly what this pass exists // for: nothing about the release changed, so adoptHost's converge never runs // — this one has to bind the port. func TestCapabilityPassMaterializesAGrantAddedAfterTheVersionWentLive(t *testing.T) { optDir := t.TempDir() o, rec := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25}}}) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } rt := capsUnitRoute("keymail.dev", "keymail") reg := openReg(t, rt) // Ungranted first: nothing binds. if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if _, sock := readDropIns(t, o, "keymail.dev"); sock != "" { t.Fatalf("socket drop-in written without a grant: %q", sock) } // Then the operator grants it. if err := reg.SetCaps("keymail.dev", "", "port:25"); err != nil { t.Fatal(err) } if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if _, sock := readDropIns(t, o, "keymail.dev"); !strings.Contains(sock, "ListenStream=25") { t.Errorf("socket drop-in after the grant = %q, want ListenStream=25", sock) } // The listener has to be re-bound, not merely re-written: DeferRestart is // FALSE on this path, so the socket sequence runs here. if !recordedVerb(rec, "restart", caps.SocketUnit("keymail.dev")) && !recordedVerb(rec, "start", caps.SocketUnit("keymail.dev")) { t.Errorf("systemctl calls = %v, want the socket unit re-bound", rec.calls) } } func recordedVerb(rec *passRec, verb, unit string) bool { for _, c := range rec.calls { if len(c) == 2 && c[0] == verb && c[1] == unit { return true } } return false } // Two converges of the same shape in one hour — this pass, then adoptHost's // on a version change, or simply two passes — must be a no-op the second // time: byte-identical content, no write, no daemon-reload, no restart. An // hourly pass that cycled a mail server every hour would be worse than no // pass at all. func TestCapabilityPassIsQuietWhenNothingChanged(t *testing.T) { optDir := t.TempDir() o, rec := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, HostRole: true, Ports: []caps.Port{{Port: 25}}}) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = "host-role,port:25" reg := openReg(t, rt) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if len(rec.calls) == 0 { t.Fatal("first pass made no systemctl calls at all — it converged nothing") } rec.calls, rec.logs, rec.users = nil, nil, nil logged := captureLog(t) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if len(rec.calls) != 0 { t.Errorf("second pass systemctl calls = %v, want none", rec.calls) } if len(rec.logs) != 0 { t.Errorf("second pass converge log = %v, want silence", rec.logs) } if out := logged(); out != "" { t.Errorf("second pass advisories = %q, want silence on a converged box", out) } } // A sibling manifest that is absent (never staged, or a write that failed) is // the leave-alone state, NOT a removal: a bucket hiccup must not strip :25 // off a running mail server. The drop-ins on disk stay byte-identical and the // pass says so once. func TestCapabilityPassLeavesDropInsAloneWhenTheSiblingIsMissing(t *testing.T) { optDir := t.TempDir() o, rec := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Ports: []caps.Port{{Port: 25}}}) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = "port:25" reg := openReg(t, rt) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } svcBefore, sockBefore := readDropIns(t, o, "keymail.dev") if sockBefore == "" { t.Fatal("nothing converged, so this test would prove nothing") } // The sibling disappears — the exact state a failed StageManifest leaves. if err := os.Remove(release.StagedManifestPath(optDir, release.OpsAccount, "aaaaaaa", "keymail")); err != nil { t.Fatal(err) } rec.calls, rec.logs = nil, nil if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } svcAfter, sockAfter := readDropIns(t, o, "keymail.dev") if svcAfter != svcBefore || sockAfter != sockBefore { t.Errorf("drop-ins changed with no manifest:\n svc %q -> %q\n sock %q -> %q", svcBefore, svcAfter, sockBefore, sockAfter) } if len(rec.calls) != 0 { t.Errorf("systemctl calls = %v, want none — leave-alone changes no unit state", rec.calls) } if !strings.Contains(strings.Join(rec.logs, "\n"), "no capability manifest resolved") { t.Errorf("logs = %v, want the leave-alone line", rec.logs) } } // ops.FleetDBs learns a unit tenant's database from the app's own // declaration, instead of depending on an operator having typed -db at // `carlos add`. dbs[0] SORTED, under the route's own state dir. func TestCapabilityPassStampsDBPathFromDataDBs(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{ V: 1, Daemon: true, // Declared order, NOT sorted: an author writing console.db first // means console.db is the one that matters, and a lexicographic sort // would have replicated audit.db instead — the platform silently // picking a different database than the app named. Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db", "audit.db"}}, }) if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil { t.Fatal(err) } reg := openReg(t, capsUnitRoute("console.example.com", "console")) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } got, err := reg.Get("console.example.com") if err != nil { t.Fatal(err) } want := filepath.Join(o.StateRoot, "console.example.com", "console.db") if got.DBPath != want { t.Errorf("DBPath = %q, want %q (dbs[0] AS DECLARED, under the state dir)", got.DBPath, want) } } // The stamp writes only when the value would change: this pass runs hourly, // and a registry write per pass is a heartbeat nobody asked for. func TestCapabilityPassDoesNotRestampAnUnchangedDBPath(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{ V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}, }) if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil { t.Fatal(err) } rt := capsUnitRoute("console.example.com", "console") rt.DBPath = filepath.Join(o.StateRoot, "console.example.com", "console.db") reg := openReg(t, rt) logged := captureLog(t) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if strings.Contains(logged(), "db_path recorded") { t.Errorf("log = %q, want no stamp line for an unchanged db_path", logged()) } } // replicate:"app" means the app streams its own WAL. The platform never // clears an existing DBPath to match: two replicators on one WAL fight over // checkpointing and corrupt the replica, and deciding which one stops is an // operator's call, not an hourly timer's. It advises instead. func TestCapabilityPassNeverClearsDBPathForAppReplication(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{ V: 1, Daemon: true, Data: &caps.Data{Replicate: caps.DataReplicateApp}, }) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } rt := capsUnitRoute("keymail.dev", "keymail") rt.DBPath = "/data/keymail.dev.db" reg := openReg(t, rt) logged := captureLog(t) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } got, err := reg.Get("keymail.dev") if err != nil { t.Fatal(err) } if got.DBPath != "/data/keymail.dev.db" { t.Errorf("DBPath = %q, want it untouched", got.DBPath) } if !strings.Contains(logged(), "two replicators on one WAL") { t.Errorf("log = %q, want the two-replicators advisory", logged()) } } // registry.Open silently creates an empty database when handed a wrong path // or an unmounted /data. A pass against that must do nothing at all — the // same guard, for the same reason, as appconfig.Sync's. func TestCapabilityPassWithNoRoutesTouchesNothing(t *testing.T) { o, rec := passOps(t) reg := openReg(t) if err := capabilityPass(reg, t.TempDir(), o); err != nil { t.Fatal(err) } ents, err := os.ReadDir(o.EtcDir) if err != nil { t.Fatal(err) } if len(ents) != 0 { t.Errorf("EtcDir = %v, want empty", ents) } if len(rec.calls) != 0 { t.Errorf("systemctl calls = %v, want none", rec.calls) } } // ---- buildPlans (the pure half) -------------------------------------------- // loader turns a fixed host -> manifest map into buildPlans' seam. func loader(m map[string]*release.Manifest, errs map[string]error) func(registry.Route) (*release.Manifest, error) { return func(rt registry.Route) (*release.Manifest, error) { if err := errs[rt.Host]; err != nil { return nil, err } return m[rt.Host], nil } } func manifest(app string, set *caps.Set) *release.Manifest { return &release.Manifest{Schema: 1, App: app, Version: "aaaaaaa", Kind: "binary", Capabilities: set} } // The declared-but-ungranted advisory: one line per (route, operator-consented // capability), naming the consequence and a command an operator can paste. // The self-service capabilities never produce a line — there is nothing to // grant, so a line would be hourly noise. func TestBuildPlansAdvisesUngrantedDeclaredCapabilities(t *testing.T) { cases := []struct { name string set *caps.Set grants string want []string wantNot []string }{ { name: "ungranted port names the exact grant command", set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25, Name: "smtp"}}}, want: []string{"declares capability port:25", "nothing is bound on :25", "carlos route --host keymail.dev --grant port:25"}, }, { name: "granted port says nothing", set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}}, grants: "port:25", want: nil, }, { name: "ungranted host_role names the transfer of authority", set: &caps.Set{V: 1, HostRole: true}, want: []string{"declares capability host-role", "carlos route --host keymail.dev --grant host-role"}, }, { name: "ungranted doc_engine says the engine will not start, and names the fix", set: &caps.Set{V: 1, DocEngine: true}, want: []string{"declares capability doc-engine", "MemoryDenyWriteExecute", "OOM-killed", "carlos route --host keymail.dev --grant doc-engine"}, }, { name: "one line per capability, in port order", set: &caps.Set{V: 1, HostRole: true, Ports: []caps.Port{{Port: 465}, {Port: 25}}}, grants: "", want: []string{"--grant port:25", "--grant port:465", "--grant host-role"}, }, { name: "self-service capabilities are never advised", set: &caps.Set{V: 1, Daemon: true, Children: true, Command: "relay", RuntimeDir: "keymail"}, wantNot: []string{"has not granted"}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = c.grants _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", c.set)}, nil)) joined := strings.Join(advice, "\n") for _, w := range c.want { if !strings.Contains(joined, w) { t.Errorf("advice =\n%s\nwant a line containing %q", joined, w) } } for _, w := range c.wantNot { if strings.Contains(joined, w) { t.Errorf("advice =\n%s\nwant NOTHING containing %q", joined, w) } } if len(c.want) == 0 && len(c.wantNot) == 0 && len(advice) != 0 { t.Errorf("advice = %v, want none", advice) } }) } } // The mirror of the advisory above (2026-08-03 review, M5): a token this box // HAS granted that the live release does not declare. It materializes nothing, // so it is not a hole — it is a registry row that reads like a hole, which is // what an operator is looking at during an incident. Two ways in: a grant // staged for a release nobody shipped, and a release that DROPPED a capability // it used to declare (converge removes the drop-in; the grant stays behind). func TestBuildPlansAdvisesGrantedButUndeclaredCapabilities(t *testing.T) { cases := []struct { name string set *caps.Set grants string want []string wantNot []string }{ { name: "granted port the live release never declares", set: &caps.Set{V: 1, Daemon: true}, grants: "port:25", want: []string{"is granted port:25", "the live release does not declare it", "carlos route --host keymail.dev --revoke port:25", "ship a release that declares it"}, }, { name: "granted host-role the live release never declares", set: &caps.Set{V: 1, Daemon: true}, grants: "host-role", want: []string{"is granted host-role", "keeps DynamicUser", "carlos route --host keymail.dev --revoke host-role"}, }, { name: "granted doc-engine the live release never declares", set: &caps.Set{V: 1, Daemon: true}, grants: "doc-engine", want: []string{"is granted doc-engine", "keeps the default resource tier", "carlos route --host keymail.dev --revoke doc-engine"}, }, { name: "declared and granted says nothing", set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}, HostRole: true}, grants: "host-role,port:25", want: nil, }, { name: "one line per token, in port order, host-role last", set: &caps.Set{V: 1}, grants: "host-role,port:25,port:465", want: []string{"--revoke port:25", "--revoke port:465", "--revoke host-role"}, }, { name: "a declared-but-ungranted capability is the OTHER advisory, not this one", set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}}, want: []string{"has not granted it"}, // The two lines are opposites and must never both fire for one // token: "granted but not declared" about a token nobody granted // would be nonsense an operator would chase. wantNot: []string{"is granted port:25"}, }, { name: "a partly-declared grant advises only the undeclared half", set: &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}}, grants: "host-role,port:25", want: []string{"is granted host-role"}, // port:25 is declared AND granted — the healthy case, silent. wantNot: []string{"port:25 but", "is granted port:25"}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = c.grants _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", c.set)}, nil)) joined := strings.Join(advice, "\n") for _, w := range c.want { if !strings.Contains(joined, w) { t.Errorf("advice =\n%s\nwant a line containing %q", joined, w) } } for _, w := range c.wantNot { if strings.Contains(joined, w) { t.Errorf("advice =\n%s\nwant NOTHING containing %q", joined, w) } } if len(c.want) == 0 && len(c.wantNot) == 0 && len(advice) != 0 { t.Errorf("advice = %v, want none", advice) } }) } } // A route whose live manifest could not be read gets NO grant advisory at // all — in either direction. The leave-alone state means this pass does not // know what the release declares, and "granted but not declared" printed from // an absent manifest would send an operator to revoke a grant the running // release is using right now. func TestBuildPlansSaysNothingAboutGrantsWithNoManifest(t *testing.T) { rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = "host-role,port:25" plans, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{}, nil)) // (nil, nil): no live version if len(plans) != 1 || plans[0].Set != nil { t.Fatalf("plans = %+v, want one leave-alone plan with a nil Set", plans) } if len(advice) != 0 { t.Fatalf("advice = %v, want none — nothing is known about what the release declares", advice) } } // An exec-backed route whose release declares capabilities materializes // nothing (release.CapabilityRoute excludes it) and is told so — the guided // path through the §5 migration, and the line an operator sees during the // reversible dress rehearsal. It gets no plan, so nothing can be written for // it by accident. func TestBuildPlansAdvisesExecBackedCapabilities(t *testing.T) { execRT := registry.Route{Host: "console.example.com", Kind: "instance", App: "console", Status: "ready"} quietRT := registry.Route{Host: "blog.example.com", Kind: "instance", App: "blog", Status: "ready"} aliasRT := registry.Route{Host: "alias.example.com", Kind: "instance", App: "console", Platform: true, Status: "ready"} set := &caps.Set{V: 1, Daemon: true, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}} plans, advice := buildPlans( []registry.Route{execRT, quietRT, aliasRT}, loader(map[string]*release.Manifest{ "console.example.com": manifest("console", set), "blog.example.com": manifest("blog", nil), "alias.example.com": manifest("console", set), }, nil), ) if len(plans) != 0 { t.Errorf("plans = %v, want none — nothing may be materialized for an exec-backed or alias route", plans) } if len(advice) != 1 { t.Fatalf("advice = %v, want exactly one line (the exec-backed route only)", advice) } for _, w := range []string{"console.example.com", "exec-backed", "backing='unit'", "§5"} { if !strings.Contains(advice[0], w) { t.Errorf("advice = %q, want it to contain %q", advice[0], w) } } } // A caps column this binary cannot read refuses that route's converge and // says so. Defaulting to "no grants" would be a revoke nobody typed — and a // revoke of port:25 drops a mail server's listener. func TestBuildPlansRefusesAnUnparseableGrantsColumn(t *testing.T) { rt := capsUnitRoute("keymail.dev", "keymail") rt.Caps = "port:025,host-role" // a hand edit: PortToken never writes "025" plans, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", &caps.Set{V: 1, Ports: []caps.Port{{Port: 25}}})}, nil)) if len(plans) != 0 { t.Errorf("plans = %v, want none — an unreadable grants column converges nothing", plans) } if len(advice) != 1 || !strings.Contains(advice[0], "keymail.dev") || !strings.Contains(advice[0], "carlos route --host keymail.dev --grant/--revoke") { t.Errorf("advice = %v, want one line naming the row and the fix", advice) } } // The three states of a manifest read are not interchangeable (spec §4d): // unreadable -> no plan at all (leave-alone, plus a line naming the failure); // absent -> a plan with a NIL Set (converge's own leave-alone, logged there); // resolved-but-declaring-nothing -> a NON-NIL empty Set, which is what makes // converge REMOVE a drop-in for a capability the app has dropped. func TestBuildPlansDistinguishesTheThreeManifestStates(t *testing.T) { unreadable := capsUnitRoute("bad.example.com", "keymail") absent := capsUnitRoute("absent.example.com", "keymail") empty := capsUnitRoute("empty.example.com", "keymail") plans, advice := buildPlans( []registry.Route{unreadable, absent, empty}, loader( map[string]*release.Manifest{"empty.example.com": manifest("keymail", nil)}, map[string]error{"bad.example.com": os.ErrInvalid}, ), ) if len(plans) != 2 { t.Fatalf("plans = %v, want 2 (the unreadable route gets none)", plans) } if plans[0].Host != "absent.example.com" || plans[0].Set != nil { t.Errorf("plan[0] = %+v, want absent.example.com with a NIL Set (leave-alone)", plans[0]) } if plans[1].Host != "empty.example.com" || plans[1].Set == nil { t.Errorf("plan[1] = %+v, want empty.example.com with a NON-NIL Set (removal semantics)", plans[1]) } if len(advice) != 1 || !strings.Contains(advice[0], "bad.example.com") { t.Errorf("advice = %v, want one line naming the unreadable manifest", advice) } } // DeferRestart belongs to the version-change converge alone. Restarting the // live binary to pick up a new unit shape is the entire point of THIS pass, // and nothing here follows up with a caps.Restart — a deferred plan would // leave a daemon stopped until the next version change. func TestBuildPlansNeverDefersARestart(t *testing.T) { rt := capsUnitRoute("keymail.dev", "keymail") plans, _ := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"keymail.dev": manifest("keymail", &caps.Set{V: 1, Daemon: true})}, nil)) if len(plans) != 1 { t.Fatalf("plans = %v, want 1", plans) } if plans[0].DeferRestart { t.Error("DeferRestart is true — this pass has no follow-up restart, so a daemon would stay down") } } // data.dbs is a list and Route.DBPath is single-valued. The extras are named // rather than quietly dropped, so nobody believes a database is replicated // when it is not. func TestBuildPlansAdvisesUnreplicatedExtraDBs(t *testing.T) { rt := capsUnitRoute("console.example.com", "console") _, advice := buildPlans([]registry.Route{rt}, loader(map[string]*release.Manifest{"console.example.com": manifest("console", &caps.Set{ V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db", "sessions.db", "audit.db"}}, })}, nil)) joined := strings.Join(advice, "\n") // dbs[0] AS DECLARED is the replicated one, so the two the operator has // to hear about by name are the ones after it — in declaration order, // not lexicographic order. for _, w := range []string{`"sessions.db" is NOT replicated`, `"audit.db" is NOT replicated`} { if !strings.Contains(joined, w) { t.Errorf("advice =\n%s\nwant a line containing %q", joined, w) } } if strings.Contains(joined, `"console.db" is NOT replicated`) { t.Errorf("advice =\n%s\nwant the FIRST db AS DECLARED (console.db) treated as replicated", joined) } if len(advice) != 2 { t.Errorf("advice = %v, want exactly one line per unreplicated extra", advice) } } // A pass that writes no bytes still repairs UNIT STATE — and until the review // that found this, it did so in total silence. The scenario is an operator // who stopped carlos-app@keymail.dev by hand to debug it: an hour later the // pass starts it again, correctly, and the log said nothing at all about the // most surprising thing this pass does. The start must happen AND be // announced with the verb and the reason. func TestCapabilityPassAnnouncesARepairOnABytesUnchangedPass(t *testing.T) { optDir := t.TempDir() o, rec := passOps(t) stageVersion(t, optDir, "keymail", "aaaaaaa", &caps.Set{V: 1, Daemon: true, Children: true}) if err := release.PointLive(optDir, "keymail.dev", release.OpsAccount, "aaaaaaa", "keymail"); err != nil { t.Fatal(err) } reg := openReg(t, capsUnitRoute("keymail.dev", "keymail")) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } // Second pass: byte-identical drop-ins, but systemd says the service is // not running. rec.calls, rec.logs = nil, nil rec.query = func(args ...string) bool { return !(len(args) == 2 && args[0] == "is-active" && args[1] == caps.ServiceUnit("keymail.dev")) } if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } if !recordedVerb(rec, "start", caps.ServiceUnit("keymail.dev")) { t.Errorf("systemctl calls = %v, want the stopped daemon started", rec.calls) } joined := strings.Join(rec.logs, "\n") if !strings.Contains(joined, "was not running") || !strings.Contains(joined, "started it") { t.Errorf("logs = %v, want a line naming the verb and why it fired", rec.logs) } } // An operator's -db value is a path to where the data lives RIGHT NOW. On a // pre-migration route that is /data/.db, the file the box-wide // replicator is streaming today — repointing it at the post-migration path // before the §5 copy has happened would silently stop replicating a live // database. So it is advised and left alone. func TestCapabilityPassLeavesAnOperatorsDBPathAloneAndSaysWhy(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{ V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}, }) if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil { t.Fatal(err) } rt := capsUnitRoute("console.example.com", "console") rt.DBPath = "/data/console.example.com.db" reg := openReg(t, rt) logged := captureLog(t) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } got, err := reg.Get("console.example.com") if err != nil { t.Fatal(err) } if got.DBPath != "/data/console.example.com.db" { t.Errorf("DBPath = %q, want the operator's value untouched", got.DBPath) } out := logged() for _, w := range []string{"/data/console.example.com.db", filepath.Join(o.StateRoot, "console.example.com", "console.db"), "stamp follows the copy"} { if !strings.Contains(out, w) { t.Errorf("log = %q, want it to name %q", out, w) } } } // A value this pass itself wrote — under the box's own state dir for this // host — IS corrected when the manifest's first database changes, and the log // names the old value, because "db_path changed" with no `from` is // unreviewable in an hourly log. func TestCapabilityPassCorrectsItsOwnStaleDBPathNamingTheOldValue(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) stageVersion(t, optDir, "console", "aaaaaaa", &caps.Set{ V: 1, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}, }) if err := release.PointLive(optDir, "console.example.com", release.OpsAccount, "aaaaaaa", "console"); err != nil { t.Fatal(err) } stale := filepath.Join(o.StateRoot, "console.example.com", "old.db") rt := capsUnitRoute("console.example.com", "console") rt.DBPath = stale reg := openReg(t, rt) logged := captureLog(t) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } got, err := reg.Get("console.example.com") if err != nil { t.Fatal(err) } want := filepath.Join(o.StateRoot, "console.example.com", "console.db") if got.DBPath != want { t.Errorf("DBPath = %q, want %q — a value under this box's own state dir is this pass's to correct", got.DBPath, want) } if out := logged(); !strings.Contains(out, stale) || !strings.Contains(out, want) { t.Errorf("log = %q, want it to name both the old and the new value", out) } } // A route whose converge was REFUSED still gets its database replicated. The // refusal here is the box-wide port claim (two routes granted :25, the // lexicographically later one refused), and it says nothing about the app's // data: gating the stamp on converge success would stop backing up a live // database because of an unrelated capability conflict. func TestCapabilityPassStampsDBPathEvenWhenConvergeIsRefused(t *testing.T) { optDir := t.TempDir() o, _ := passOps(t) set := &caps.Set{ V: 1, Daemon: true, Ports: []caps.Port{{Port: 25}}, Data: &caps.Data{Replicate: caps.DataReplicatePlatform, DBs: []string{"console.db"}}, } stageVersion(t, optDir, "console", "aaaaaaa", set) for _, h := range []string{"a.example.com", "b.example.com"} { if err := release.PointLive(optDir, h, release.OpsAccount, "aaaaaaa", "console"); err != nil { t.Fatal(err) } } routes := make([]registry.Route, 0, 2) for _, h := range []string{"a.example.com", "b.example.com"} { rt := capsUnitRoute(h, "console") rt.Caps = "port:25" routes = append(routes, rt) } reg := openReg(t, routes...) if err := capabilityPass(reg, optDir, o); err != nil { t.Fatal(err) } // b sorts later, so CheckClaims refuses it and nothing is written for it. if _, sock := readDropIns(t, o, "b.example.com"); sock != "" { t.Fatalf("b.example.com socket drop-in = %q, want nothing written for a refused route", sock) } got, err := reg.Get("b.example.com") if err != nil { t.Fatal(err) } want := filepath.Join(o.StateRoot, "b.example.com", "console.db") if got.DBPath != want { t.Errorf("DBPath = %q, want %q — a capability refusal is not a reason to stop replicating data", got.DBPath, want) } }