carlos / platform Public

caps: the converge pass — three-state, marker-safe, socket-ordered, operator-refusing

Converge is the only thing in this repo that writes root-owned systemd unit
text, so every rail lives here rather than in its callers.

- Three states, appconfig.Sync's rule for appconfig.Sync's reason: a nil Set
  (absent or unreadable staged manifest) leaves every drop-in exactly as it
  is; a Set that resolved and renders bytes writes them if they differ; a Set
  that resolved and renders nil REMOVES the generated file. A bucket hiccup
  must not strip :25 off a running mail server.
- The staged manifest is re-validated here, before anything else touches the
  box: ensureStaged short-circuits on an already-staged artifact, so a
  platform-binary ROLLBACK can meet a capability block this binary does not
  speak. Same leave-alone posture, plus an error so a version-change caller
  refuses the version instead of pointing live at it.
- A file at a generated path without the marker is never overwritten and
  never deleted — reported once per pass, that half skipped, the other half
  still converged. Restore is marker-checked in the same direction.
- Deterministic rendering means compare-before-write: an unchanged pass
  writes no bytes, reloads nothing and restarts nothing. Drift repair
  (an unenabled socket unit, a stopped daemon) is gated on a separate query
  seam so both properties hold at once.
- Socket ordering, which the spec does not spell out: stop service ->
  restart socket -> start service, because a socket restart under a running
  service races into EADDRINUSE. The start is attempted even when the socket
  verb failed — the service is never left stopped — and the error is still
  returned. A REMOVED socket drop-in goes through the same sequence, or
  revoking port:25 would leave :25 bound.
- Refused in place, nothing written: daemon+hibernate (a daemon that sleeps
  is a contradiction, and for these apps it is an MX that sleeps), a
  host_role flip across the DynamicUser/static state-dir shapes, a port or
  runtime_dir already claimed on this box, a runtime_dir whose /run path
  already exists and is not this route's own claim, a host that is not
  filepath.Base of itself, and a carlos-<app> user that could not be created.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 042c6a7579de330a2d305913b207c7e89f524fc9 parent cc1aefa
2 files changed, +1814 −0
  • internal/caps/converge.go +796 −0
  • internal/caps/converge_test.go +1018 −0
diff --git a/internal/caps/converge.go b/internal/caps/converge.go
new file mode 100644
index 000000000..7b7a5f07d
--- /dev/null
+++ b/internal/caps/converge.go
@@ -0,0 +1,796 @@
+// Package caps: the converge pass (spec §3c, §4b, §4d, §4e).
+//
+// Converge is the only thing in this repo that writes root-owned systemd
+// unit text, so every rail it carries is here rather than spread across its
+// callers: the three-state rule, the marker check, the box-wide claims, the
+// DynamicUser-flip refusal, and the socket restart ordering. Its only
+// contact with the outside world is through Ops, so its tests touch no
+// systemd, no /etc, no /run and no /var/lib.
+package caps
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+const (
+ // DefaultEtcDir is where systemd reads unit configuration; DefaultStateRoot
+ // is the parent of the template's StateDirectory=carlos-app/%i; DefaultRunDir
+ // is the parent of RuntimeDirectory=<name>. All three are overridable ONLY
+ // so tests can point them at a t.TempDir() — nothing in a manifest or a
+ // registry row ever reaches them.
+ DefaultEtcDir = "/etc/systemd/system"
+ DefaultStateRoot = "/var/lib/carlos-app"
+ DefaultRunDir = "/run"
+
+ // DropInName is the one generated file per unit. `10-` sorts before an
+ // operator's `90-operator.conf`, which this package never writes and never
+ // reads (spec §3c).
+ DropInName = "10-carlos-capabilities.conf"
+)
+
+// Ops is every piece of the outside world this package touches, injected so
+// the converge tests need no systemd, no root, and no /etc — the same seam
+// shape activator.UnitBackend.Systemctl uses.
+type Ops struct {
+ EtcDir string // "" = DefaultEtcDir
+ StateRoot string // "" = DefaultStateRoot
+ RunDir string // "" = DefaultRunDir
+ Systemctl func(args ...string) error // nil = the real systemctl binary
+ Query func(args ...string) bool // nil = the real systemctl; exit 0 = yes
+ EnsureUser func(name string) error // nil = the real, idempotent useradd
+ Logf func(format string, a ...any) // nil = fmt.Printf
+}
+
+// Plan is one route's converge input.
+type Plan struct {
+ Host string
+ App string
+ Set *Set // NIL means the manifest did not resolve — LEAVE THE DROP-INS ALONE
+ Grants Grants
+ Hibernate bool
+}
+
+// Snapshot is what was on disk before Converge wrote, enough to put it back.
+//
+// A half whose file exists WITHOUT the marker is recorded as "not had": this
+// pass never wrote it, so putting it back is not this pass's business — and
+// Restore is marker-checked too, so a "not had" half can never turn into the
+// deletion of somebody's hand-written drop-in.
+type Snapshot struct {
+ Host string
+ Service, Socket []byte
+ ServiceHad, SocketHad bool
+}
+
+// Result is what Converge did.
+type Result struct {
+ Prev Snapshot
+ Changed bool // any byte written or removed; the caller need not daemon-reload, Converge already did
+}
+
+// ServiceUnit and SocketUnit are the two per-instance units the template
+// family instantiates for a route host.
+func ServiceUnit(host string) string { return "carlos-app@" + host + ".service" }
+func SocketUnit(host string) string { return "carlos-app@" + host + ".socket" }
+
+// DropInPaths returns the two generated file paths for a host. etcDir "" is
+// DefaultEtcDir. The host is NOT validated here — every caller that joins
+// these paths validates it first with validHost; this function is the naming
+// rule alone, so tests and advisories can name a path without re-deriving it.
+func DropInPaths(etcDir, host string) (service, socket string) {
+ if etcDir == "" {
+ etcDir = DefaultEtcDir
+ }
+ return filepath.Join(etcDir, ServiceUnit(host)+".d", DropInName),
+ filepath.Join(etcDir, SocketUnit(host)+".d", DropInName)
+}
+
+// Converge materializes p and returns what it replaced.
+//
+// The three states are NOT interchangeable, and this is the same rule
+// appconfig.Sync uses for exactly the same reason: a bucket hiccup must not
+// strip :25 off a running mail server.
+// - p.Set == nil (absent or unreadable staged manifest): leave every drop-in
+// exactly as it is, log one line, return Changed=false, nil error.
+// - p.Set non-nil but INVALID for this binary (a capability name or a "v"
+// it does not know — reachable via a platform-binary rollback onto an
+// already-staged version, since ensureStaged short-circuits on a staged
+// artifact and never re-reads its manifest): same leave-alone posture,
+// plus an error, so a version-change caller refuses the version instead
+// of pointing live at something it cannot materialize.
+// - p.Set non-nil, renders bytes: write them if they differ.
+// - p.Set non-nil, renders nil: REMOVE the generated file (marker-checked).
+//
+// Every refusal returns an error with Changed=false and nothing written,
+// having already logged one line naming the operator's fix. Callers that
+// merely survey the box (the hourly grant-drift pass) can log-and-continue;
+// callers on the version-change path let it refuse the version.
+//
+// Concurrency, stated rather than solved: two converges in one process (the
+// hourly RunAdopt and the version-change converge inside Pass) are
+// sequential. `carlos adopt` run by hand while carlos-adopt.timer fires is
+// two processes, and there the atomic rename makes the FILE safe — a reader
+// sees old or new bytes, never a torn file — but NOT the systemctl sequence:
+// two passes could interleave a stop and a start. The repo already accepts
+// exactly this for appconfig.Sync, and the failure mode is a unit that ends
+// the minute running the right unit text, so it is accepted here too.
+func Converge(o Ops, p Plan) (Result, error) {
+ var res Result
+ res.Prev.Host = p.Host
+
+ // The host becomes a filename under /etc/systemd/system, written as
+ // root. Same guard, same reason, same trust level as appconfig.syncHost's
+ // at :293 — a registry row is trusted, but not to place a root-owned file
+ // wherever it likes.
+ if err := validHost(p.Host); err != nil {
+ o.logf(" %q: %v", p.Host, err)
+ return res, err
+ }
+ svcPath, sockPath := DropInPaths(o.etcDir(), p.Host)
+
+ if p.Set == nil {
+ // State two of three. NOT a removal: a capability is only ever
+ // removed because a manifest that ACTUALLY RESOLVED no longer
+ // declares it, or because a grant was revoked.
+ o.logf(" %s: no capability manifest resolved this pass — leaving its drop-ins exactly as they are", p.Host)
+ return res, nil
+ }
+
+ // The staged manifest is re-validated here even though ship and adopt
+ // already gate it: ensureStaged returns early when the artifact is
+ // already on disk, so a ROLLED-BACK platform binary can meet a staged
+ // version whose capability block it does not understand. This is the
+ // backstop, and it falls the same way as an unreadable manifest.
+ if err := ValidCapabilities(p.App, p.Set); err != nil {
+ o.logf(" %s: %v; leaving its drop-ins exactly as they are", p.Host, err)
+ return res, err
+ }
+
+ // A daemon the platform is asked to keep running and to stop when idle is
+ // a contradiction, and hibernation for these apps means an MX that sleeps.
+ if p.Set.Daemon && p.Hibernate {
+ err := fmt.Errorf("capability daemon and hibernation contradict")
+ o.logf(" %s: %v — nothing written and no unit state changed; fix it with: carlos route -host %s -hibernate=false", p.Host, err, p.Host)
+ return res, err
+ }
+
+ hostRole := p.Set.HostRole && p.Grants.HostRole
+
+ // Read both halves BEFORE any refusal that needs to know what this route
+ // already claims, and before anything is written. Nothing here changes
+ // state, so an early read costs a refusal nothing.
+ curSvc, svcExists, svcGenerated, err := readDropIn(svcPath)
+ if err != nil {
+ o.logf(" %s: read %s: %v", p.Host, svcPath, err)
+ return res, err
+ }
+ curSock, sockExists, sockGenerated, err := readDropIn(sockPath)
+ if err != nil {
+ o.logf(" %s: read %s: %v", p.Host, sockPath, err)
+ return res, err
+ }
+ if svcExists && svcGenerated {
+ res.Prev.Service, res.Prev.ServiceHad = curSvc, true
+ }
+ if sockExists && sockGenerated {
+ res.Prev.Socket, res.Prev.SocketHad = curSock, true
+ }
+
+ if err := o.checkStateDirShape(p, hostRole, curSvc, svcExists && svcGenerated); err != nil {
+ return res, err
+ }
+ if err := o.checkClaimsOnDisk(p, curSvc, svcExists && svcGenerated); err != nil {
+ return res, err
+ }
+
+ // The user must exist before a User= line naming it does: the unit would
+ // fail to start and Restart=always would spin it.
+ if hostRole {
+ user := AppUser(p.App)
+ if err := o.ensureUser(user); err != nil {
+ o.logf(" %s: create system user %s: %v — refusing the whole plan rather than write a User= line for a user that does not exist", p.Host, user, err)
+ return res, err
+ }
+ }
+
+ svcBody, err := RenderService(p.App, p.Set, p.Grants)
+ if err != nil {
+ o.logf(" %s: render service drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err)
+ return res, err
+ }
+ sockBody, err := RenderSocket(p.Set, p.Grants)
+ if err != nil {
+ o.logf(" %s: render socket drop-in: %v; leaving its drop-ins exactly as they are", p.Host, err)
+ return res, err
+ }
+
+ svcChanged, err := o.materialize(p.Host, svcPath, svcBody, curSvc, svcExists, svcGenerated)
+ if err != nil {
+ return res, err
+ }
+ sockChanged, err := o.materialize(p.Host, sockPath, sockBody, curSock, sockExists, sockGenerated)
+ if err != nil {
+ // The service half may already have been written; report it so the
+ // caller's Changed is honest about what is on disk.
+ res.Changed = svcChanged
+ if svcChanged {
+ _ = o.systemctl("daemon-reload")
+ }
+ return res, err
+ }
+ res.Changed = svcChanged || sockChanged
+
+ // systemd reads nothing from disk until it is told to. One reload, and
+ // only when something actually changed — this is what makes an unchanged
+ // pass free.
+ if res.Changed {
+ if err := o.systemctl("daemon-reload"); err != nil {
+ o.logf(" %s: daemon-reload: %v", p.Host, err)
+ return res, err
+ }
+ }
+
+ sockLive := sockBody != nil
+ restarted, err := o.applyUnitState(p, sockLive, svcChanged, sockChanged)
+ if err != nil {
+ return res, err
+ }
+ if res.Changed {
+ what := "capability drop-ins converged"
+ if restarted {
+ what += " and the unit restarted"
+ }
+ o.logf(" %s: %s", p.Host, what)
+ }
+ return res, nil
+}
+
+// applyUnitState is steps 7-9: the socket restart ordering, the enable/start
+// a daemon route's drift repair needs, and the service restart. It reports
+// whether the service was restarted.
+func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) (bool, error) {
+ svcUnit, sockUnit := ServiceUnit(p.Host), SocketUnit(p.Host)
+ var firstErr error
+ note := func(err error) {
+ if err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+
+ restartedService := false
+
+ // Step 7. A running service holds the bound fd, so restarting the socket
+ // unit under it races into EADDRINUSE and the MX comes back unbound.
+ //
+ // This runs when the socket drop-in was REMOVED as well as when it was
+ // rewritten: systemd is still holding the listener the removed file asked
+ // for, and "a grant is not a promise" (spec §7) means revoking port:25
+ // has to actually drop :25 — a rewrite nobody reloads into a running
+ // socket unit is a revoke that did nothing.
+ if sockChanged {
+ err := o.socketSequence(p, "restart")
+ note(err)
+ restartedService = true
+ }
+
+ // Step 8. `systemctl enable` writes the boot-time .wants symlink and
+ // bootstrap does not enable tenant socket units — without this the
+ // ListenStream binds nothing after a reboot. Both verbs are gated on a
+ // query rather than run unconditionally, so a converged box's hourly pass
+ // changes no state at all (which is what makes "an unchanged pass does
+ // nothing" true for a daemon route, not just for a quiet one).
+ if sockLive {
+ if !o.query("is-enabled", sockUnit) {
+ note(o.systemctl("enable", sockUnit))
+ }
+ if !o.query("is-active", sockUnit) && !restartedService {
+ // Bringing an inactive socket up goes through the same ordering:
+ // the service may be holding the port itself today (keymail binds
+ // :25 until its LISTEN_FDS change lands), and a bare socket start
+ // under it is the EADDRINUSE race again.
+ note(o.socketSequence(p, "start"))
+ restartedService = true
+ }
+ }
+ if p.Set.Daemon {
+ if !o.query("is-enabled", svcUnit) {
+ note(o.systemctl("enable", svcUnit))
+ }
+ if !o.query("is-active", svcUnit) {
+ // enable+start, not try-restart: a daemon that crashed into a
+ // stopped state comes back rather than being left down by a
+ // try-restart no-op (spec §4d).
+ note(o.systemctl("start", svcUnit))
+ restartedService = true
+ }
+ }
+
+ // Step 9. A non-daemon route's restart is not this function's business:
+ // the caller's existing try-restart owns it, byte-identically to today.
+ if svcChanged && p.Set.Daemon && !restartedService {
+ note(o.systemctl("restart", svcUnit))
+ restartedService = true
+ }
+ if firstErr != nil {
+ o.logf(" %s: unit state: %v", p.Host, firstErr)
+ }
+ return restartedService, firstErr
+}
+
+// socketSequence is the pinned ordering: stop the service, restart (or
+// start) the socket, start the service. The listener is down only for the
+// socket's own restart, during which inbound connections queue in the kernel
+// backlog — which is what makes spec §4c's "an MX restart never refuses a
+// connection" true rather than aspirational.
+//
+// The service start is ATTEMPTED even when the socket verb failed. Returning
+// early there would leave the service stopped — trading a socket that did not
+// rebind for an app that is not running at all — so the error is remembered
+// and returned, and the start happens anyway.
+//
+// A route that is neither a daemon nor currently running is neither stopped
+// nor started: a hibernating tenant's drop-ins are unit text, not a wake.
+func (o Ops) socketSequence(p Plan, verb string) error {
+ svcUnit, sockUnit := ServiceUnit(p.Host), SocketUnit(p.Host)
+ shouldRun := p.Set.Daemon || o.query("is-active", svcUnit)
+
+ var errs []error
+ if shouldRun {
+ if err := o.systemctl("stop", svcUnit); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if err := o.systemctl(verb, sockUnit); err != nil {
+ errs = append(errs, err)
+ }
+ if shouldRun {
+ if err := o.systemctl("start", svcUnit); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// materialize writes body (or removes the file when body is nil) and reports
+// whether any byte moved. A file at a generated path WITHOUT the marker on
+// its first line is never overwritten and never deleted: it is reported once
+// per pass and this half is skipped entirely, while the other half still
+// converges.
+func (o Ops) materialize(host, path string, body, cur []byte, exists, generated bool) (bool, error) {
+ if exists && !generated {
+ o.logf(" %s: %s exists but does not carry the %q marker — hand-edited or written by something else; never overwritten, never removed, skipping this unit's drop-in", host, path, Marker)
+ return false, nil
+ }
+ if body == nil {
+ if !exists {
+ return false, nil
+ }
+ if err := os.Remove(path); err != nil {
+ o.logf(" %s: remove %s: %v", host, path, err)
+ return false, err
+ }
+ o.logf(" %s: no capability materializes here any more — removed %s", host, path)
+ return true, nil
+ }
+ if exists && bytes.Equal(cur, body) {
+ return false, nil // up to date: no write, no reload, no restart
+ }
+ if err := writeFileAtomic(path, body); err != nil {
+ o.logf(" %s: write %s: %v", host, path, err)
+ return false, err
+ }
+ return true, nil
+}
+
+// writeFileAtomic creates the .d directory and renames a temp file into
+// place, so a concurrent reader (systemd, or a second adopt process) sees
+// either the old bytes or the new ones and never a torn file. Root-owned
+// 0644, set explicitly rather than left to the process umask, because these
+// bytes are read by systemd and their mode is part of the contract.
+func writeFileAtomic(path string, body []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, body, 0o644); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmp, 0o644); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return nil
+}
+
+// readDropIn returns a generated file's bytes, whether it exists at all, and
+// whether it carries the marker.
+func readDropIn(path string) (b []byte, exists, generated bool, err error) {
+ b, err = os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, false, false, nil
+ }
+ return nil, false, false, err
+ }
+ return b, true, bytes.HasPrefix(b, []byte(Marker+"\n")), nil
+}
+
+// checkStateDirShape is spec §4b's pinned sharp edge. With DynamicUser=yes
+// systemd materialises StateDirectory under /var/lib/private/ with a SYMLINK
+// at /var/lib/carlos-app/<host>; with a static User= it expects a real
+// directory. Flipping between the two under an existing state dir is refused
+// in place — the operator does the stop → move → start, because moving a
+// live app's data is not a thing an hourly timer should do unattended.
+//
+// The OFF direction is deliberately narrow: it refuses only when THIS
+// package's own current drop-in is what put the unit on a static user. A
+// pre-existing real directory that no drop-in of ours created (a legacy
+// hand-provisioned console, for example) is not a flip and must not freeze an
+// unrelated route's capabilities forever.
+func (o Ops) checkStateDirShape(p Plan, hostRole bool, curSvc []byte, generated bool) error {
+ dir := filepath.Join(o.stateRoot(), p.Host)
+ fi, err := os.Lstat(dir)
+ if err != nil {
+ return nil // absent is fine in both directions
+ }
+ wasStatic := generated && dropInHas(curSvc, "DynamicUser=no")
+ switch {
+ case hostRole && fi.Mode()&os.ModeSymlink != 0:
+ e := fmt.Errorf("host_role wants a static user but %s is a symlink (the DynamicUser shape, real data under /var/lib/private)", dir)
+ o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s && mv /var/lib/private/carlos-app/%s %s && rm the symlink && chown -R %s %s && systemctl start %s",
+ p.Host, e, ServiceUnit(p.Host), p.Host, dir, AppUser(p.App), dir, ServiceUnit(p.Host))
+ return e
+ case !hostRole && wasStatic && fi.IsDir():
+ e := fmt.Errorf("dropping host_role returns the unit to DynamicUser, which cannot use the existing real directory %s", dir)
+ o.logf(" %s: %v — refused in place, nothing written; the operator's move is: systemctl stop %s, move the data under /var/lib/private/carlos-app/%s, then start it",
+ p.Host, e, ServiceUnit(p.Host), p.Host)
+ return e
+ }
+ return nil
+}
+
+// checkClaimsOnDisk resolves the two box-wide exclusivities against what is
+// ALREADY WRITTEN on this box, before anything is written here. CheckClaims
+// covers a whole pass's plans at once; this covers the single-route
+// version-change path, which never sees the other routes — and it is the only
+// one of the two that notices a drop-in left behind by a route that has since
+// been removed from the registry.
+//
+// The /run existence check is the second guard on runtime_dir. The static
+// reservedRuntimeDir list is necessarily finite — a box-specific
+// /run/tailscale is not on it and never will be — so a name that already
+// exists under /run and is not this route's own prior claim is refused,
+// whatever the list says.
+func (o Ops) checkClaimsOnDisk(p Plan, curSvc []byte, generated bool) error {
+ ports, dirs := scanClaims(o.etcDir(), p.Host)
+
+ for _, decl := range p.Set.Ports {
+ if !p.Grants.Ports[decl.Port] {
+ continue // an ungranted port materializes nothing and claims nothing
+ }
+ if other, taken := ports[decl.Port]; taken {
+ e := fmt.Errorf("port %d is already claimed by %s's drop-in on this box", decl.Port, other)
+ o.logf(" %s: %v — refusing before anything is written; :%d can be held by exactly one process on one machine", p.Host, e, decl.Port)
+ return e
+ }
+ }
+
+ if p.Set.RuntimeDir == "" {
+ return nil
+ }
+ if other, taken := dirs[p.Set.RuntimeDir]; taken {
+ e := fmt.Errorf("runtime_dir %q is already claimed by %s's drop-in on this box", p.Set.RuntimeDir, other)
+ o.logf(" %s: %v — refusing before anything is written; a shared /run directory is another app's sockets", p.Host, e)
+ return e
+ }
+ ownClaim := generated && dropInHas(curSvc, "RuntimeDirectory="+p.Set.RuntimeDir)
+ if ownClaim {
+ return nil
+ }
+ runPath := filepath.Join(o.runDir(), p.Set.RuntimeDir)
+ if _, err := os.Lstat(runPath); err == nil {
+ e := fmt.Errorf("runtime_dir %q: %s already exists and is not this route's own claim", p.Set.RuntimeDir, runPath)
+ o.logf(" %s: %v — refusing before anything is written; systemd would hand this app a directory something else on the box already owns", p.Host, e)
+ return e
+ }
+ return nil
+}
+
+// scanClaims reads every OTHER host's drop-in at the generated path on this
+// box and returns which privileged ports and which runtime directories they
+// claim, mapped to the host that claims them.
+//
+// A file at that path WITHOUT the marker counts too, and deliberately: it is
+// not ours to rewrite, so systemd will apply it, so the claim it makes is
+// real. Refusing is the fail-closed direction — the alternative is two units
+// racing for :25, which is the exact failure this check exists to prevent.
+// (Only the 10- path is read; an operator's 90-operator.conf is theirs, and
+// this package neither reads nor reasons about it.)
+func scanClaims(etcDir, exceptHost string) (map[int]string, map[string]string) {
+ ports := map[int]string{}
+ dirs := map[string]string{}
+ ents, err := os.ReadDir(etcDir)
+ if err != nil {
+ return ports, dirs
+ }
+ for _, e := range ents {
+ if !e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ host, kind, ok := parseUnitDir(name)
+ if !ok || host == exceptHost {
+ continue
+ }
+ b, exists, _, err := readDropIn(filepath.Join(etcDir, name, DropInName))
+ if err != nil || !exists {
+ continue
+ }
+ for _, line := range strings.Split(string(b), "\n") {
+ switch kind {
+ case "socket":
+ if v, ok := strings.CutPrefix(line, "ListenStream="); ok {
+ if n, err := strconv.Atoi(v); err == nil {
+ ports[n] = host
+ }
+ }
+ case "service":
+ if v, ok := strings.CutPrefix(line, "RuntimeDirectory="); ok && v != "" {
+ dirs[v] = host
+ }
+ }
+ }
+ }
+ return ports, dirs
+}
+
+// parseUnitDir splits "carlos-app@<host>.service.d" into its host and kind.
+func parseUnitDir(name string) (host, kind string, ok bool) {
+ rest, ok := strings.CutPrefix(name, "carlos-app@")
+ if !ok {
+ return "", "", false
+ }
+ for _, k := range []string{"service", "socket"} {
+ if h, ok := strings.CutSuffix(rest, "."+k+".d"); ok && h != "" {
+ return h, k, true
+ }
+ }
+ return "", "", false
+}
+
+// dropInHas reports whether body carries the exact directive line.
+func dropInHas(body []byte, line string) bool {
+ for _, l := range strings.Split(string(body), "\n") {
+ if l == line {
+ return true
+ }
+ }
+ return false
+}
+
+// CheckClaims resolves box-wide exclusivity BEFORE anything is written,
+// across every plan on this box at once: a privileged port claimed by two
+// routes, and a runtime_dir claimed by two routes. Returns host -> refusal; a
+// host absent from the map cleared.
+//
+// Deterministic on ties: the route whose Host sorts first keeps the claim and
+// the other is refused, so a conflict does not flap between passes — two
+// routes taking turns owning :25 would restart a mail server every hour
+// forever.
+//
+// A plan whose Set is nil claims nothing: it is the leave-alone state, and
+// leaving a drop-in alone is not a new claim. An ungranted port likewise
+// claims nothing, because nothing materializes for it.
+func CheckClaims(plans []Plan) map[string]error {
+ sorted := make([]Plan, len(plans))
+ copy(sorted, plans)
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].Host < sorted[j].Host })
+
+ refused := map[string]error{}
+ ports := map[int]string{}
+ dirs := map[string]string{}
+ for _, p := range sorted {
+ if p.Set == nil || refused[p.Host] != nil {
+ continue
+ }
+ for _, decl := range p.Set.Ports {
+ if !p.Grants.Ports[decl.Port] {
+ continue
+ }
+ if other, taken := ports[decl.Port]; taken {
+ refused[p.Host] = fmt.Errorf("port %d is already claimed on this box by %s — :%d can be held by exactly one process on one machine; revoke it from one of the two (carlos route -host %s -revoke %s)",
+ decl.Port, other, decl.Port, p.Host, PortToken(decl.Port))
+ break
+ }
+ ports[decl.Port] = p.Host
+ }
+ if refused[p.Host] != nil {
+ continue
+ }
+ if p.Set.RuntimeDir == "" {
+ continue
+ }
+ if other, taken := dirs[p.Set.RuntimeDir]; taken {
+ refused[p.Host] = fmt.Errorf("runtime_dir %q is already claimed on this box by %s — a shared /run directory is another app's sockets; one of the two must declare a different name",
+ p.Set.RuntimeDir, other)
+ continue
+ }
+ dirs[p.Set.RuntimeDir] = p.Host
+ }
+ return refused
+}
+
+// Restore puts prev back and daemon-reloads — the drop-in half of adoptHost's
+// restart-failure rollback (the symlink half is release.PointLive's).
+//
+// It restarts nothing: the caller that rolled the symlink back owns the
+// restart, and Restore's job is to make the unit text on disk match the
+// version the symlink now names again.
+//
+// Marker-checked in both directions, exactly as Converge is: a half that was
+// not ours when the snapshot was taken is not ours to write over or delete
+// now either.
+func Restore(o Ops, prev Snapshot) error {
+ if err := validHost(prev.Host); err != nil {
+ o.logf(" %q: %v", prev.Host, err)
+ return err
+ }
+ svcPath, sockPath := DropInPaths(o.etcDir(), prev.Host)
+
+ var errs []error
+ changed := false
+ for _, half := range []struct {
+ path string
+ body []byte
+ had bool
+ }{
+ {svcPath, prev.Service, prev.ServiceHad},
+ {sockPath, prev.Socket, prev.SocketHad},
+ } {
+ want := half.body
+ if !half.had {
+ want = nil
+ }
+ cur, exists, generated, err := readDropIn(half.path)
+ if err != nil {
+ o.logf(" %s: read %s: %v", prev.Host, half.path, err)
+ errs = append(errs, err)
+ continue
+ }
+ did, err := o.materialize(prev.Host, half.path, want, cur, exists, generated)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ changed = changed || did
+ }
+ if changed {
+ if err := o.systemctl("daemon-reload"); err != nil {
+ o.logf(" %s: daemon-reload: %v", prev.Host, err)
+ errs = append(errs, err)
+ }
+ o.logf(" %s: capability drop-ins rolled back to the previous version's", prev.Host)
+ }
+ return errors.Join(errs...)
+}
+
+// Restart is the daemon route's restart: `systemctl restart`, and its error
+// is REAL — it is what makes adoptHost's rollback fire. Non-daemon routes are
+// not this function's business: restartRoute's try-restart keeps owning them,
+// byte-identically.
+func Restart(o Ops, p Plan) error {
+ if err := validHost(p.Host); err != nil {
+ o.logf(" %q: %v", p.Host, err)
+ return err
+ }
+ return o.systemctl("restart", ServiceUnit(p.Host))
+}
+
+// validHost refuses anything that is not a bare hostname before it is joined
+// into a root-written /etc/systemd/system path — the same guard, in the same
+// direction, as appconfig.syncHost's.
+func validHost(host string) error {
+ if host == "" || host == "." || host == ".." || host != filepath.Base(host) {
+ return fmt.Errorf("not a bare hostname, refusing to derive a /etc/systemd/system path from it")
+ }
+ return nil
+}
+
+func (o Ops) etcDir() string {
+ if o.EtcDir == "" {
+ return DefaultEtcDir
+ }
+ return o.EtcDir
+}
+
+func (o Ops) stateRoot() string {
+ if o.StateRoot == "" {
+ return DefaultStateRoot
+ }
+ return o.StateRoot
+}
+
+func (o Ops) runDir() string {
+ if o.RunDir == "" {
+ return DefaultRunDir
+ }
+ return o.RunDir
+}
+
+func (o Ops) logf(format string, a ...any) {
+ if o.Logf != nil {
+ o.Logf(format, a...)
+ return
+ }
+ fmt.Printf(format+"\n", a...)
+}
+
+// systemctl runs one state-CHANGING verb. Every call here is something a
+// converged box does not need: an unchanged pass makes none of them, which is
+// why the tests can assert "zero calls" and mean it.
+func (o Ops) systemctl(args ...string) error {
+ if o.Systemctl != nil {
+ return o.Systemctl(args...)
+ }
+ out, err := exec.Command("systemctl", args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+// query runs a systemctl QUERY verb (`is-active`, `is-enabled`) and reports
+// whether it answered yes. It is a separate seam from Systemctl on purpose: a
+// query changes nothing, and the properties this package promises — an
+// unchanged pass writes no bytes, reloads nothing and restarts nothing — are
+// about state CHANGES. Folding the two together would force a choice between
+// repairing drift (an unenabled socket unit, a daemon that is not running,
+// both named in spec §4d as what this pass fixes) and being quiet when there
+// is no drift; with the seams split, both hold.
+//
+// A systemctl that cannot run at all answers "no", which errs toward
+// attempting an idempotent enable/start rather than silently skipping the
+// repair.
+func (o Ops) query(args ...string) bool {
+ if o.Query != nil {
+ return o.Query(args...)
+ }
+ return exec.Command("systemctl", args...).Run() == nil
+}
+
+// ensureUser creates the locked, home-less, nologin system user a host_role
+// app runs as, idempotently — exactly as host.ensureServiceUser creates
+// `carlos`, and with the name DERIVED by AppUser, never taken from a manifest.
+func (o Ops) ensureUser(name string) error {
+ if o.EnsureUser != nil {
+ return o.EnsureUser(name)
+ }
+ if _, err := user.Lookup(name); err == nil {
+ return nil
+ }
+ out, err := exec.Command("useradd", "--system", "--user-group",
+ "--no-create-home", "--shell", "/usr/sbin/nologin", name).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("create user %s: %v: %s", name, err, strings.TrimSpace(string(out)))
+ }
+ // useradd is not the only way this can fail: a NSS backend that accepted
+ // the create but does not yet resolve the name would leave a User= line
+ // pointing at nothing. Prove the account is resolvable before saying yes.
+ if _, err := user.Lookup(name); err != nil {
+ return fmt.Errorf("created user %s but it does not resolve: %v", name, err)
+ }
+ return nil
+}
diff --git a/internal/caps/converge_test.go b/internal/caps/converge_test.go
new file mode 100644
index 000000000..c6fc6fa4b
--- /dev/null
+++ b/internal/caps/converge_test.go
@@ -0,0 +1,1018 @@
+package caps
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// fakeSystemd is a tiny in-memory systemd: it RECORDS every state-changing
+// verb and answers the query seam from the state those verbs left behind. The
+// state tracking is what lets "converge twice, the second pass changes
+// nothing" be asserted against a DAEMON route — the interesting case — rather
+// than only against a route with no unit state to repair.
+type fakeSystemd struct {
+ calls []string
+ active map[string]bool
+ enabled map[string]bool
+ fail map[string]error // "verb unit" -> error
+}
+
+func newFakeSystemd() *fakeSystemd {
+ return &fakeSystemd{active: map[string]bool{}, enabled: map[string]bool{}, fail: map[string]error{}}
+}
+
+func (f *fakeSystemd) run(args ...string) error {
+ call := strings.Join(args, " ")
+ f.calls = append(f.calls, call)
+ if err := f.fail[call]; err != nil {
+ return err
+ }
+ if len(args) < 2 {
+ return nil
+ }
+ switch args[0] {
+ case "start", "restart":
+ f.active[args[1]] = true
+ case "stop":
+ f.active[args[1]] = false
+ case "enable":
+ f.enabled[args[1]] = true
+ case "disable":
+ f.enabled[args[1]] = false
+ }
+ return nil
+}
+
+func (f *fakeSystemd) query(args ...string) bool {
+ if len(args) < 2 {
+ return false
+ }
+ switch args[0] {
+ case "is-active":
+ return f.active[args[1]]
+ case "is-enabled":
+ return f.enabled[args[1]]
+ }
+ return false
+}
+
+func (f *fakeSystemd) reset() { f.calls = nil }
+
+// testOps wires every seam at a temp dir so nothing here touches systemd,
+// /etc, /run or /var/lib.
+func testOps(t *testing.T) (Ops, *fakeSystemd, *[]string, *[]string) {
+ t.Helper()
+ f := newFakeSystemd()
+ var logs []string
+ var users []string
+ o := Ops{
+ EtcDir: t.TempDir(),
+ StateRoot: t.TempDir(),
+ RunDir: t.TempDir(),
+ Systemctl: f.run,
+ Query: f.query,
+ EnsureUser: func(name string) error {
+ users = append(users, name)
+ return nil
+ },
+ Logf: func(format string, a ...any) { logs = append(logs, fmt.Sprintf(format, a...)) },
+ }
+ return o, f, &logs, &users
+}
+
+func keymailPlan(t *testing.T, g Grants) Plan {
+ t.Helper()
+ return Plan{Host: "keymail.dev", App: "keymail", Set: keymailSet(t), Grants: g}
+}
+
+func readFile(t *testing.T, path string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read %s: %v", path, err)
+ }
+ return b
+}
+
+func mustNotExist(t *testing.T, path string) {
+ t.Helper()
+ if _, err := os.Lstat(path); !os.IsNotExist(err) {
+ t.Fatalf("%s: want absent, got err=%v", path, err)
+ }
+}
+
+func hasLog(logs []string, substr string) bool {
+ for _, l := range logs {
+ if strings.Contains(l, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+// indexOf returns the position of the first call equal to want, or -1.
+func indexOf(calls []string, want string) int {
+ for i, c := range calls {
+ if c == want {
+ return i
+ }
+ }
+ return -1
+}
+
+// TestGrantAddedWritesTheDropInAndRestarts pins the whole happy path: both
+// drop-ins are the golden bytes, exactly one daemon-reload, the socket
+// sequence in its pinned order (a socket restart under a running service
+// races into EADDRINUSE), and `enable` for BOTH units — bootstrap enables no
+// tenant socket unit, so without it the ListenStream binds nothing after a
+// reboot.
+func TestGrantAddedWritesTheDropInAndRestarts(t *testing.T) {
+ o, f, _, users := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge: %v", err)
+ }
+ if !res.Changed {
+ t.Fatal("Changed = false, want true on a first converge")
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ if got, want := readFile(t, svcPath), readGolden(t, "keymail.service.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("service drop-in:\n got: %q\n want: %q", got, want)
+ }
+ if got, want := readFile(t, sockPath), readGolden(t, "keymail.socket.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("socket drop-in:\n got: %q\n want: %q", got, want)
+ }
+ if len(*users) != 1 || (*users)[0] != "carlos-keymail" {
+ t.Fatalf("EnsureUser calls = %v, want [carlos-keymail]", *users)
+ }
+
+ reloads := 0
+ for _, c := range f.calls {
+ if c == "daemon-reload" {
+ reloads++
+ }
+ }
+ if reloads != 1 {
+ t.Fatalf("daemon-reload x%d, want exactly 1 (calls: %v)", reloads, f.calls)
+ }
+
+ stop := indexOf(f.calls, "stop carlos-app@keymail.dev.service")
+ sock := indexOf(f.calls, "restart carlos-app@keymail.dev.socket")
+ start := indexOf(f.calls, "start carlos-app@keymail.dev.service")
+ if stop < 0 || sock < 0 || start < 0 || !(stop < sock && sock < start) {
+ t.Fatalf("socket ordering: stop=%d socket=%d start=%d, want stop < socket restart < start (calls: %v)", stop, sock, start, f.calls)
+ }
+ if indexOf(f.calls, "enable carlos-app@keymail.dev.service") < 0 {
+ t.Fatalf("no enable for the service unit (calls: %v)", f.calls)
+ }
+ if indexOf(f.calls, "enable carlos-app@keymail.dev.socket") < 0 {
+ t.Fatalf("no enable for the socket unit — the ListenStream would bind nothing after a reboot (calls: %v)", f.calls)
+ }
+}
+
+// TestGrantRevokedRewritesTheDropIn: a grant is not a promise. Revoking
+// port:25 and host-role removes the socket file entirely and rewrites the
+// service file without its User= lines — and the removal alone is enough to
+// count as Changed.
+func TestGrantRevokedRewritesTheDropIn(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (granted): %v", err)
+ }
+ f.reset()
+
+ p.Grants = Grants{}
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (revoked): %v", err)
+ }
+ if !res.Changed {
+ t.Fatal("Changed = false after a revoke, want true")
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, sockPath)
+ svc := string(readFile(t, svcPath))
+ for _, gone := range []string{"DynamicUser=no", "User=carlos-keymail", "Group=carlos-keymail"} {
+ if strings.Contains(svc, gone) {
+ t.Fatalf("service drop-in still holds %q after the revoke:\n%s", gone, svc)
+ }
+ }
+ for _, kept := range []string{"ExecStart=/opt/carlos/live/%i relay", "RuntimeDirectory=keymail", "TasksMax=1024"} {
+ if !strings.Contains(svc, kept) {
+ t.Fatalf("service drop-in lost the self-service %q:\n%s", kept, svc)
+ }
+ }
+ reloads := 0
+ for _, c := range f.calls {
+ if c == "daemon-reload" {
+ reloads++
+ }
+ }
+ if reloads != 1 {
+ t.Fatalf("daemon-reload x%d, want exactly 1 (calls: %v)", reloads, f.calls)
+ }
+ if res.Prev.Socket == nil || !res.Prev.SocketHad {
+ t.Fatal("Prev did not capture the socket drop-in that was removed")
+ }
+ // A grant is not a promise (spec §7): the socket unit must be restarted
+ // so systemd actually drops :25. Removing the file and reloading is not
+ // enough — the running socket unit is still holding the listener.
+ stop := indexOf(f.calls, "stop carlos-app@keymail.dev.service")
+ sock := indexOf(f.calls, "restart carlos-app@keymail.dev.socket")
+ start := indexOf(f.calls, "start carlos-app@keymail.dev.service")
+ if stop < 0 || sock < 0 || start < 0 || !(stop < sock && sock < start) {
+ t.Fatalf("a revoked port did not go through the socket sequence: %v", f.calls)
+ }
+}
+
+// TestUnreadableManifestLeavesDropInsAlone is the three-state rule, and the
+// reason it exists: a bucket hiccup must not strip :25 off a running mail
+// server. Nil Set = leave-alone, not removal.
+func TestUnreadableManifestLeavesDropInsAlone(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (staged): %v", err)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ svcBefore, sockBefore := readFile(t, svcPath), readFile(t, sockPath)
+ f.reset()
+ *logs = nil
+
+ p.Set = nil
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (nil Set) returned an error: %v — leave-alone is not a failure", err)
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on a nil Set")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a nil Set: %v", f.calls)
+ }
+ if !bytes.Equal(readFile(t, svcPath), svcBefore) || !bytes.Equal(readFile(t, sockPath), sockBefore) {
+ t.Fatal("a nil Set changed the drop-ins")
+ }
+ if len(*logs) != 1 || !strings.Contains((*logs)[0], "leaving its drop-ins exactly as they are") {
+ t.Fatalf("logs = %v, want exactly one leave-alone line", *logs)
+ }
+}
+
+// TestInvalidStagedManifestRefusesTheRouteAndLeavesDropInsAlone: a
+// platform-binary rollback can meet an already-staged version whose
+// capability block this binary does not understand (ensureStaged short-
+// circuits on a staged artifact and never re-reads its manifest). That falls
+// the same way as an unreadable manifest — nothing removed — but it also
+// returns an error, so a version-change caller refuses the version rather
+// than pointing live at something it cannot materialize.
+func TestInvalidStagedManifestRefusesTheRouteAndLeavesDropInsAlone(t *testing.T) {
+ o, f, logs, users := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (valid): %v", err)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ svcBefore, sockBefore := readFile(t, svcPath), readFile(t, sockPath)
+ f.reset()
+ *logs = nil
+
+ // A Set from a manifest a NEWER binary wrote: v2 is outside 1..Version.
+ // It declares host_role, which is granted — so if the re-validation is
+ // not the FIRST thing after the leave-alone branch, this refusal happens
+ // only at render time, after the pass has already minted a system account
+ // for a version it cannot materialize.
+ p.Set = &Set{V: Version + 1, Command: "relay", Daemon: true, HostRole: true}
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Fatal("Converge accepted a capability block this binary does not speak")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on a refused route")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refused route: %v", f.calls)
+ }
+ if !bytes.Equal(readFile(t, svcPath), svcBefore) || !bytes.Equal(readFile(t, sockPath), sockBefore) {
+ t.Fatal("a refused route's drop-ins were modified")
+ }
+ if len(*users) != 1 { // the first, valid converge — and nothing since
+ t.Fatalf("EnsureUser calls = %v, want only the one from the valid pass", *users)
+ }
+ if !hasLog(*logs, "update the platform binary") {
+ t.Fatalf("logs = %v, want the update-the-platform-binary line", *logs)
+ }
+}
+
+// TestHandWrittenDropInIsNeverOverwritten: a file at a generated path without
+// the marker is never overwritten and never deleted — reported and skipped —
+// while the OTHER unit's half still converges.
+func TestHandWrittenDropInIsNeverOverwritten(t *testing.T) {
+ o, _, logs, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ hand := []byte("[Service]\nEnvironment=HAND=written\n")
+ if err := os.MkdirAll(filepath.Dir(svcPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(svcPath, hand, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge: %v", err)
+ }
+ if got := readFile(t, svcPath); !bytes.Equal(got, hand) {
+ t.Fatalf("hand-written drop-in was modified:\n got: %q\nwant: %q", got, hand)
+ }
+ if !hasLog(*logs, "never overwritten, never removed") {
+ t.Fatalf("logs = %v, want the hand-edited report", *logs)
+ }
+ if got, want := readFile(t, sockPath), readGolden(t, "keymail.socket.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("the other unit's half did not converge:\n got: %q\nwant: %q", got, want)
+ }
+
+ // And it is not restorable either: this pass never wrote it, so it is not
+ // this pass's to put back — or to delete on a rollback.
+ res, _ := Converge(o, p)
+ if res.Prev.ServiceHad {
+ t.Fatal("Snapshot claimed a hand-written file as its own")
+ }
+ if err := Restore(o, res.Prev); err != nil {
+ t.Fatalf("Restore: %v", err)
+ }
+ if got := readFile(t, svcPath); !bytes.Equal(got, hand) {
+ t.Fatal("Restore deleted a hand-written drop-in")
+ }
+}
+
+// TestUnchangedPassWritesNothing is the determinism promise, asserted against
+// a DAEMON route with a socket — the case with the most unit state to
+// repair. Once the box is converged, the hourly pass changes nothing at all.
+func TestUnchangedPassWritesNothing(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ svcInfo, err := os.Stat(svcPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.reset()
+ *logs = nil
+
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (second): %v", err)
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on an unchanged pass")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("an unchanged pass made systemctl calls: %v", f.calls)
+ }
+ if len(*logs) != 0 {
+ t.Fatalf("an unchanged pass logged: %v", *logs)
+ }
+ // Not even a rewrite with identical bytes: the mtime is untouched.
+ after, err := os.Stat(svcPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after.ModTime().Equal(svcInfo.ModTime()) {
+ t.Fatal("the service drop-in was rewritten with identical bytes")
+ }
+ if got, want := readFile(t, sockPath), readGolden(t, "keymail.socket.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("socket drop-in drifted on a no-op pass:\n got: %q\nwant: %q", got, want)
+ }
+ // The mode is part of the contract systemd reads.
+ if perm := svcInfo.Mode().Perm(); perm != 0o644 {
+ t.Fatalf("service drop-in mode = %o, want 0644", perm)
+ }
+}
+
+// TestDaemonPlusHibernateIsRefused: a daemon the platform is asked to keep
+// running and to stop when idle is a contradiction, and for these apps
+// hibernation means an MX that sleeps. Refused authoritatively here, with the
+// operator's exact fix in the line.
+func TestDaemonPlusHibernateIsRefused(t *testing.T) {
+ o, f, logs, users := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ p.Hibernate = true
+
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Fatal("Converge accepted daemon + hibernate")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on a refusal")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refusal: %v", f.calls)
+ }
+ if len(*users) != 0 {
+ t.Fatalf("a refused plan created users: %v", *users)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
+ if !hasLog(*logs, "carlos route -host keymail.dev -hibernate=false") {
+ t.Fatalf("logs = %v, want the -hibernate=false fix", *logs)
+ }
+}
+
+// TestHostRoleFlipRefusedWhenTheStateDirIsTheOtherShape is spec §4b's pinned
+// sharp edge: with DynamicUser=yes the state dir is a SYMLINK into
+// /var/lib/private, and a static User= needs a real directory. The platform
+// refuses in place; the operator does the stop → move → start.
+func TestHostRoleFlipRefusedWhenTheStateDirIsTheOtherShape(t *testing.T) {
+ o, f, logs, users := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if err := os.Symlink(filepath.Join(o.StateRoot, "private", "carlos-app", p.Host), filepath.Join(o.StateRoot, p.Host)); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Fatal("Converge flipped host_role on over a DynamicUser state dir")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on a refusal")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refusal: %v", f.calls)
+ }
+ if len(*users) != 0 {
+ t.Fatalf("a refused plan created users: %v", *users)
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ if !hasLog(*logs, "refused in place") {
+ t.Fatalf("logs = %v, want a refused-in-place line naming the operator's move", *logs)
+ }
+}
+
+// The OFF direction of the same flip: once THIS package's drop-in has put the
+// unit on a static user, dropping host_role would hand the template's
+// DynamicUser=yes an existing real directory it cannot use.
+func TestHostRoleFlipOffRefusedWhenTheStateDirIsAlreadyStatic(t *testing.T) {
+ o, _, logs, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (host_role on): %v", err)
+ }
+ if err := os.Mkdir(filepath.Join(o.StateRoot, p.Host), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ before := readFile(t, svcPath)
+ *logs = nil
+
+ p.Grants = Grants{Ports: map[int]bool{25: true}} // host-role revoked
+ if _, err := Converge(o, p); err == nil {
+ t.Fatal("Converge flipped host_role off over a static state dir")
+ }
+ if got := readFile(t, svcPath); !bytes.Equal(got, before) {
+ t.Fatal("a refused flip rewrote the service drop-in")
+ }
+ if !hasLog(*logs, "refused in place") {
+ t.Fatalf("logs = %v, want a refused-in-place line", *logs)
+ }
+}
+
+// A real state directory that no drop-in of OURS created is not a flip — it
+// is a legacy hand-provisioned app — and must not freeze an unrelated route's
+// capabilities forever.
+func TestPreExistingStateDirDoesNotBlockARouteThatNeverHadHostRole(t *testing.T) {
+ o, _, _, _ := testOps(t)
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"relay","daemon":true}`)); err != nil {
+ t.Fatal(err)
+ }
+ p := Plan{Host: "legacy.example.com", App: "legacy", Set: &s}
+ if err := os.Mkdir(filepath.Join(o.StateRoot, p.Host), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge refused a route over a state dir it never created: %v", err)
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ if !strings.Contains(string(readFile(t, svcPath)), "ExecStart=/opt/carlos/live/%i relay") {
+ t.Fatal("the route did not converge")
+ }
+}
+
+// TestRestorePutsThePreviousDropInsBack is the drop-in half of adoptHost's
+// restart-failure rollback (the symlink half is release.PointLive's).
+func TestRestorePutsThePreviousDropInsBack(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge A: %v", err)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ svcA, sockA := readFile(t, svcPath), readFile(t, sockPath)
+
+ // Version B: the app dropped `ports` and `children` from its manifest.
+ var b Set
+ if err := b.UnmarshalJSON([]byte(`{"v":1,"command":"relay","daemon":true,"runtime_dir":"keymail","host_role":true}`)); err != nil {
+ t.Fatal(err)
+ }
+ p.Set = &b
+ resB, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge B: %v", err)
+ }
+ if !resB.Changed {
+ t.Fatal("B did not change anything")
+ }
+ mustNotExist(t, sockPath)
+ f.reset()
+
+ if err := Restore(o, resB.Prev); err != nil {
+ t.Fatalf("Restore: %v", err)
+ }
+ if got := readFile(t, svcPath); !bytes.Equal(got, svcA) {
+ t.Fatalf("service drop-in after Restore:\n got: %q\n want: %q", got, svcA)
+ }
+ if got := readFile(t, sockPath); !bytes.Equal(got, sockA) {
+ t.Fatalf("socket drop-in after Restore:\n got: %q\n want: %q", got, sockA)
+ }
+ if indexOf(f.calls, "daemon-reload") < 0 {
+ t.Fatalf("Restore did not daemon-reload (calls: %v)", f.calls)
+ }
+ // Restore restarts nothing: the caller that rolled the symlink back owns
+ // the restart.
+ for _, c := range f.calls {
+ if strings.HasPrefix(c, "restart ") || strings.HasPrefix(c, "start ") {
+ t.Fatalf("Restore changed unit state: %v", f.calls)
+ }
+ }
+}
+
+// TestCheckClaimsRefusesAPortHeldByTwoRoutesAndARuntimeDirCollision: box-wide
+// exclusivity, resolved before anything is written, and deterministic on ties
+// so a conflict does not flap between passes.
+func TestCheckClaimsRefusesAPortHeldByTwoRoutesAndARuntimeDirCollision(t *testing.T) {
+ mx := func(host string) Plan {
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"ports":[{"port":25,"name":"mx"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ return Plan{Host: host, App: "keymail", Set: &s, Grants: Grants{Ports: map[int]bool{25: true}}}
+ }
+ rd := func(host string) Plan {
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"runtime_dir":"keymail"}`)); err != nil {
+ t.Fatal(err)
+ }
+ return Plan{Host: host, App: "keymail", Set: &s}
+ }
+
+ for i := 0; i < 5; i++ { // deterministic across repeated runs, both input orders
+ plans := []Plan{mx("b.example.com"), mx("a.example.com"), rd("d.example.com"), rd("c.example.com")}
+ if i%2 == 1 {
+ plans = []Plan{plans[1], plans[0], plans[3], plans[2]}
+ }
+ refused := CheckClaims(plans)
+ if len(refused) != 2 {
+ t.Fatalf("refused %d routes, want 2: %v", len(refused), refused)
+ }
+ if refused["a.example.com"] != nil || refused["c.example.com"] != nil {
+ t.Fatalf("the lexicographically-first host lost its claim: %v", refused)
+ }
+ if refused["b.example.com"] == nil || !strings.Contains(refused["b.example.com"].Error(), "a.example.com") {
+ t.Fatalf("port conflict not refused against the holder: %v", refused["b.example.com"])
+ }
+ if refused["d.example.com"] == nil || !strings.Contains(refused["d.example.com"].Error(), "c.example.com") {
+ t.Fatalf("runtime_dir conflict not refused against the holder: %v", refused["d.example.com"])
+ }
+ }
+}
+
+// An UNGRANTED port claims nothing: nothing materializes for it, so it cannot
+// lock another route out of the port it was actually granted.
+func TestCheckClaimsIgnoresUngrantedPortsAndUnresolvedManifests(t *testing.T) {
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"ports":[{"port":25,"name":"mx"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ plans := []Plan{
+ {Host: "a.example.com", App: "keymail", Set: &s}, // declared, not granted
+ {Host: "b.example.com", App: "keymail", Set: &s, Grants: Grants{Ports: map[int]bool{25: true}}}, // granted
+ {Host: "c.example.com", App: "keymail", Set: nil}, // leave-alone claims nothing
+ }
+ if refused := CheckClaims(plans); len(refused) != 0 {
+ t.Fatalf("refused %v, want none", refused)
+ }
+}
+
+// TestPortClaimedByAnotherRoutesDropInIsRefused covers the single-route
+// version-change path, which never sees the other plans: the claim is read
+// off what is already written on the box, so a stale drop-in for a route that
+// has left the registry still holds its port.
+func TestPortClaimedByAnotherRoutesDropInIsRefused(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ first := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, first); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ f.reset()
+ *logs = nil
+
+ // A second MX-shaped route with NO runtime_dir, so the port claim is the
+ // only thing that can refuse it.
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"relay","daemon":true,"ports":[{"port":25,"name":"mx"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ second := Plan{Host: "mx2.example.com", App: "keymail", Set: &s, Grants: Grants{Ports: map[int]bool{25: true}}}
+ res, err := Converge(o, second)
+ if err == nil {
+ t.Fatal("two routes were allowed to claim :25 on one box")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true on a refusal")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refusal: %v", f.calls)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, second.Host)
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
+ if !hasLog(*logs, "port 25 is already claimed by keymail.dev") {
+ t.Fatalf("logs = %v, want the port-conflict line naming the port and the holder", *logs)
+ }
+
+ // The same route reconverging keeps its own claim: it is not in conflict
+ // with itself.
+ if _, err := Converge(o, first); err != nil {
+ t.Fatalf("the holder refused its own claim: %v", err)
+ }
+}
+
+// The same disk-read claim check for runtime_dir: the second app to claim
+// `keymail` is refused rather than handed a directory another app's sockets
+// live in (spec §7).
+func TestRuntimeDirClaimedByAnotherRoutesDropInIsRefused(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ if _, err := Converge(o, keymailPlan(t, keymailGrants())); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ f.reset()
+ *logs = nil
+
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"serve","runtime_dir":"keymail"}`)); err != nil {
+ t.Fatal(err)
+ }
+ second := Plan{Host: "other.example.com", App: "other", Set: &s}
+ if _, err := Converge(o, second); err == nil {
+ t.Fatal("two routes were allowed to claim /run/keymail on one box")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refusal: %v", f.calls)
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, second.Host)
+ mustNotExist(t, svcPath)
+ if !hasLog(*logs, `runtime_dir "keymail" is already claimed by keymail.dev`) {
+ t.Fatalf("logs = %v, want the runtime_dir-conflict line naming the holder", *logs)
+ }
+}
+
+// A hand-written drop-in at another host's generated path still holds its
+// port: this pass will never rewrite it, so systemd applies it, so the claim
+// is real. Fail closed — the alternative is two units racing for :25.
+func TestAHandWrittenDropInStillHoldsItsPortAgainstAnotherRoute(t *testing.T) {
+ o, _, logs, _ := testOps(t)
+ svcPath, _ := DropInPaths(o.EtcDir, "hand.example.com")
+ if err := os.MkdirAll(filepath.Dir(svcPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ sockPath := filepath.Join(o.EtcDir, SocketUnit("hand.example.com")+".d", DropInName)
+ if err := os.MkdirAll(filepath.Dir(sockPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(sockPath, []byte("[Socket]\nListenStream=25\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := Converge(o, keymailPlan(t, keymailGrants())); err == nil {
+ t.Fatal("a route was granted a port a hand-written drop-in already binds")
+ }
+ if !hasLog(*logs, "port 25 is already claimed by hand.example.com") {
+ t.Fatalf("logs = %v, want the port-conflict line", *logs)
+ }
+}
+
+// Changed is true when only a REMOVAL happened: the caller's rollback and the
+// pass's own daemon-reload both hang off it, and "the box changed" is as true
+// of a deletion as of a write.
+func TestChangedIsTrueWhenOnlyARemovalHappened(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ f.reset()
+
+ // A new version declaring only self-service, directive-free capabilities:
+ // both halves render nil, so both generated files go away.
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"daemon":true,"data":{"replicate":"platform","dbs":["registry.db"]}}`)); err != nil {
+ t.Fatal(err)
+ }
+ p.Set = &s
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (removal): %v", err)
+ }
+ if !res.Changed {
+ t.Fatal("Changed = false when both drop-ins were removed")
+ }
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
+ if indexOf(f.calls, "daemon-reload") < 0 {
+ t.Fatalf("a removal did not daemon-reload: %v", f.calls)
+ }
+}
+
+// The /run existence check is the SECOND runtime_dir guard: the static
+// reserved list is finite, and a box can have anything under /run that no
+// list will ever enumerate.
+func TestRuntimeDirRefusedWhenRunPathAlreadyExists(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if err := os.Mkdir(filepath.Join(o.RunDir, "keymail"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := Converge(o, p); err == nil {
+ t.Fatal("Converge accepted a runtime_dir whose /run path already exists")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls on a refusal: %v", f.calls)
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ if !hasLog(*logs, "is not this route's own claim") {
+ t.Fatalf("logs = %v, want the /run existence refusal", *logs)
+ }
+}
+
+// ...and it must not refuse a route its OWN previous pass created the
+// directory for, or a converged box would refuse itself every hour.
+func TestRuntimeDirAcceptsThisRoutesOwnPriorClaim(t *testing.T) {
+ o, _, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ // systemd creates /run/keymail once the unit starts.
+ if err := os.Mkdir(filepath.Join(o.RunDir, "keymail"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge refused the route its own RuntimeDirectory belongs to: %v", err)
+ }
+}
+
+// TestHostWithASeparatorIsRefused: a registry row is trusted at the same
+// level as live/<host>, but this one becomes a filename under
+// /etc/systemd/system, written as root (appconfig.syncHost's guard, same
+// direction).
+func TestHostWithASeparatorIsRefused(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ for _, host := range []string{"", ".", "..", "../../etc/passwd", "a/b", "sub/../keymail.dev"} {
+ p := keymailPlan(t, keymailGrants())
+ p.Host = host
+ if _, err := Converge(o, p); err == nil {
+ t.Fatalf("Converge accepted host %q", host)
+ }
+ if err := Restore(o, Snapshot{Host: host}); err == nil {
+ t.Fatalf("Restore accepted host %q", host)
+ }
+ if err := Restart(o, p); err == nil {
+ t.Fatalf("Restart accepted host %q", host)
+ }
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("a refused host reached systemctl: %v", f.calls)
+ }
+ ents, err := os.ReadDir(o.EtcDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ents) != 0 {
+ t.Fatalf("a refused host wrote into EtcDir: %v", ents)
+ }
+}
+
+// TestSocketRestartFailureStillStartsTheService: the service must never be
+// left stopped because the socket failed to come back — and the error must
+// still be returned, so the caller's rollback fires.
+func TestSocketRestartFailureStillStartsTheService(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ f.fail["restart carlos-app@keymail.dev.socket"] = errors.New("Address already in use")
+
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Fatal("Converge swallowed a failed socket restart")
+ }
+ if !strings.Contains(err.Error(), "Address already in use") {
+ t.Fatalf("error = %v, want the socket failure", err)
+ }
+ if !res.Changed {
+ t.Fatal("Changed = false, but the drop-ins were written before the restart failed")
+ }
+ stop := indexOf(f.calls, "stop carlos-app@keymail.dev.service")
+ start := indexOf(f.calls, "start carlos-app@keymail.dev.service")
+ if stop < 0 || start < 0 || start < stop {
+ t.Fatalf("the service was left stopped after a failed socket restart: %v", f.calls)
+ }
+ if !f.active[ServiceUnit(p.Host)] {
+ t.Fatalf("the service is not running after the sequence: %v", f.calls)
+ }
+
+ // The same failure on a NON-daemon route, where no enable/start repair
+ // exists to paper over it: the start can only have come from the sequence
+ // itself. This is the assertion that pins "the start is attempted even
+ // when the socket verb failed" rather than merely observing a start.
+ o2, f2, _, _ := testOps(t)
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"relay","ports":[{"port":25,"name":"mx"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ q := Plan{Host: "mx.example.com", App: "keymail", Set: &s, Grants: Grants{Ports: map[int]bool{25: true}}}
+ f2.active[ServiceUnit(q.Host)] = true // running, so the sequence stops it
+ f2.fail["restart carlos-app@mx.example.com.socket"] = errors.New("Address already in use")
+
+ if _, err := Converge(o2, q); err == nil {
+ t.Fatal("Converge swallowed a failed socket restart on a non-daemon route")
+ }
+ stop = indexOf(f2.calls, "stop carlos-app@mx.example.com.service")
+ start = indexOf(f2.calls, "start carlos-app@mx.example.com.service")
+ if stop < 0 || start < 0 || start < stop {
+ t.Fatalf("a non-daemon service was left stopped after a failed socket restart: %v", f2.calls)
+ }
+ if !f2.active[ServiceUnit(q.Host)] {
+ t.Fatalf("the service is not running after the sequence: %v", f2.calls)
+ }
+}
+
+// EnsureUser failing refuses the WHOLE plan: writing User=carlos-keymail for
+// a user that does not exist gives systemd a unit that fails to start, which
+// Restart=always then spins.
+func TestEnsureUserFailureRefusesThePlan(t *testing.T) {
+ o, f, logs, _ := testOps(t)
+ o.EnsureUser = func(string) error { return errors.New("useradd: no free uid") }
+ p := keymailPlan(t, keymailGrants())
+
+ if _, err := Converge(o, p); err == nil {
+ t.Fatal("Converge wrote a User= line for a user it could not create")
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
+ if len(f.calls) != 0 {
+ t.Fatalf("systemctl calls after a user-creation failure: %v", f.calls)
+ }
+ if !hasLog(*logs, "refusing the whole plan") {
+ t.Fatalf("logs = %v, want the refusal", *logs)
+ }
+}
+
+// The user is created only when host_role is BOTH declared and granted — an
+// ungranted declaration must not mint a system account.
+func TestUngrantedHostRoleCreatesNoUser(t *testing.T) {
+ o, _, _, users := testOps(t)
+ p := keymailPlan(t, Grants{})
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge: %v", err)
+ }
+ if len(*users) != 0 {
+ t.Fatalf("EnsureUser called for an ungranted host_role: %v", *users)
+ }
+}
+
+// A .d path that is a FILE rather than a directory is an honest error, not a
+// panic and not a silent skip.
+func TestDropInDirectoryThatIsAFileIsAnError(t *testing.T) {
+ o, _, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ if err := os.WriteFile(filepath.Dir(svcPath), []byte("not a directory"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Converge(o, p); err == nil {
+ t.Fatal("Converge succeeded with a file where the .d directory belongs")
+ }
+}
+
+// A non-daemon route's restart stays the caller's try-restart, byte-
+// identically to today: converge writes the unit text and starts nothing.
+func TestNonDaemonRouteIsNeitherEnabledNorStarted(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"serve","children":true}`)); err != nil {
+ t.Fatal(err)
+ }
+ p := Plan{Host: "app.example.com", App: "app", Set: &s, Hibernate: true}
+
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge: %v", err)
+ }
+ for _, c := range f.calls {
+ if c != "daemon-reload" {
+ t.Fatalf("a hibernating non-daemon route had its unit state changed: %v", f.calls)
+ }
+ }
+ svcPath, _ := DropInPaths(o.EtcDir, p.Host)
+ if !strings.Contains(string(readFile(t, svcPath)), "ExecStart=/opt/carlos/live/%i serve") {
+ t.Fatal("the drop-in was not written for a hibernating route")
+ }
+}
+
+// Drift repair, the other half of the determinism promise: a converged box
+// whose daemon has been stopped and whose units have been disabled by hand is
+// put back — this is what the hourly pass is for (spec §4d).
+func TestStoppedDaemonAndDisabledUnitsAreRepaired(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ f.active[ServiceUnit(p.Host)] = false
+ f.enabled[ServiceUnit(p.Host)] = false
+ f.enabled[SocketUnit(p.Host)] = false
+ f.reset()
+
+ res, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (repair): %v", err)
+ }
+ if res.Changed {
+ t.Fatal("Changed = true, but no byte moved — Changed is about the FILES")
+ }
+ if indexOf(f.calls, "daemon-reload") >= 0 {
+ t.Fatalf("a unit-state repair reloaded the daemon: %v", f.calls)
+ }
+ for _, want := range []string{
+ "enable carlos-app@keymail.dev.socket",
+ "enable carlos-app@keymail.dev.service",
+ "start carlos-app@keymail.dev.service",
+ } {
+ if indexOf(f.calls, want) < 0 {
+ t.Fatalf("drift not repaired: %q missing from %v", want, f.calls)
+ }
+ }
+}
+
+// Restart's error is REAL — it is what makes adoptHost's rollback fire.
+func TestRestartReportsItsFailure(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ f.fail["restart carlos-app@keymail.dev.service"] = errors.New("job failed")
+ if err := Restart(o, p); err == nil {
+ t.Fatal("Restart swallowed a failed restart")
+ }
+ if f.calls[len(f.calls)-1] != "restart carlos-app@keymail.dev.service" {
+ t.Fatalf("Restart ran %v", f.calls)
+ }
+}
+
+// Two plans, one box, no conflict: the second route's drop-ins are written
+// and the first route's are untouched.
+func TestASecondRouteOnTheBoxConvergesIndependently(t *testing.T) {
+ o, _, _, _ := testOps(t)
+ first := keymailPlan(t, keymailGrants())
+ if _, err := Converge(o, first); err != nil {
+ t.Fatalf("Converge (first): %v", err)
+ }
+ svcA, sockA := DropInPaths(o.EtcDir, first.Host)
+ beforeSvc, beforeSock := readFile(t, svcA), readFile(t, sockA)
+
+ second := Plan{Host: "platform.carlosframework.com", App: "console", Set: consoleSet(t), Grants: consoleGrants()}
+ if _, err := Converge(o, second); err != nil {
+ t.Fatalf("Converge (second): %v", err)
+ }
+ svcB, sockB := DropInPaths(o.EtcDir, second.Host)
+ if got, want := readFile(t, svcB), readGolden(t, "console.service.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("console service drop-in:\n got: %q\n want: %q", got, want)
+ }
+ mustNotExist(t, sockB) // console declares no ports
+ if !bytes.Equal(readFile(t, svcA), beforeSvc) || !bytes.Equal(readFile(t, sockA), beforeSock) {
+ t.Fatal("converging one route changed another's drop-ins")
+ }
+}