carlos / platform Public

caps: bytes are not a binding — the converge pass repairs what systemd did, not what it wrote

Review found one root cause behind five findings: converge treated the FILE
BYTES as the whole state, so any divergence between the bytes and systemd's
running state was permanent — the bytes read "converged", the next pass saw
no diff, and nothing ever repaired it.

- Restore re-runs the socket sequence when the socket half's bytes move, in
  either direction. A restored ListenStream systemd never re-binds restores
  nothing (the MX stays off :25 after a rollback), and a rolled-back ADDED
  port stays publicly bound to a binary that never asked for it and keeps
  blocking the next route's claim. Snapshot carries Daemon because the
  sequence has to know whether to bring the service back — a rollback happens
  precisely when the restart failed, so the service is usually down.
- A failed write, daemon-reload or socket sequence now rolls the bytes back
  to what the pass found, so bytes-differ makes the next pass retry the whole
  change. Failures AFTER a successful reload (enable, start, a daemon's
  restart) deliberately do not: systemd has the right configuration and the
  failure is the app's, which the version-change caller already owns.
- daemon-reload precedes every unit verb in the pass, pinned by assertion: a
  restart against configuration systemd has not re-read is a pass that looks
  like it worked and changed nothing.
- A granted port on a hibernating route is advised once per pass — enabling
  the socket unit there is correct (or the ListenStream binds nothing), but
  the port is then a wake path that bypasses the activator's health gate.
  Recorded in spec §4c, whose paragraph contemplated daemons only.
- The claim scan reads address-form ListenStreams (0.0.0.0:25, [::]:25), so a
  hand-written drop-in cannot dodge the port check by spelling :25 differently.
- Contract comment now separates refusals (nothing written) from mid-pass IO
  failures (rolled back), and CheckClaims names its one known limit: when the
  later-sorting host is the one already on disk, the plan tie-break and the
  on-disk check disagree into a stable both-refused stall until an operator
  revokes. socketSequence no longer overclaims the backlog guarantee — during
  the socket unit's own restart connections are refused, not queued.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c53773d00e13203643b7518ccc95ce5e61e2e536 parent 042c6a7
3 files changed, +500 −83
  • docs/superpowers/specs/2026-08-03-app-capabilities-design.md +12 −0
  • internal/caps/converge.go +242 −74
  • internal/caps/converge_test.go +246 −9
diff --git a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
index 2c11bf76b..38afcb3c4 100644
--- a/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
+++ b/docs/superpowers/specs/2026-08-03-app-capabilities-design.md
@@ -393,6 +393,18 @@ queues in the backlog) — strictly better than today, where a relay restart
drops `:25` entirely; and a connection to `:25` can start a stopped unit,
though `daemon` means it should never be stopped.
+**Implementation note (2026-08-03):** that second consequence is not confined
+to daemons. The converge pass enables and starts the socket unit whenever a
+port materializes, including on a **hibernating** route — without it the
+`ListenStream` binds nothing — so on such a route a connection to the granted
+port starts the unit *directly*, bypassing the activator's health-gated wake.
+The edge's clean 503 on a broken boot does not cover that path; the connection
+parks in the backlog instead. The combination is legitimate (socket activation
+is the wake-on-connect half of hibernation) and is therefore advised, not
+refused: the converge pass logs one line per pass naming the port for as long
+as a granted port and `hibernate` coexist. `daemon` + `hibernate` remains
+refused outright (§4e).
+
**App-side contract**: the app enumerates `LISTEN_FDS` (from fd 3) and
identifies each listener by its own local address — a `*net.TCPAddr` on port
25 is the MX, a `*net.UnixAddr` is the HTTP upstream. Deliberately **not**
diff --git a/internal/caps/converge.go b/internal/caps/converge.go
index 7b7a5f07d..fed039d8e 100644
--- a/internal/caps/converge.go
+++ b/internal/caps/converge.go
@@ -65,10 +65,17 @@ type Plan struct {
// 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.
+//
+// Daemon travels with the snapshot because putting the BYTES back is only
+// half of putting the box back: a restored ListenStream that systemd never
+// re-binds restores nothing, and re-binding it needs the same stop → restart
+// → start ordering Converge uses, which needs to know whether the service is
+// supposed to be running. See Restore.
type Snapshot struct {
Host string
Service, Socket []byte
ServiceHad, SocketHad bool
+ Daemon bool
}
// Result is what Converge did.
@@ -110,10 +117,27 @@ func DropInPaths(etcDir, host string) (service, socket string) {
// - 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.
+// Two different kinds of error come back, and they are not the same claim:
+//
+// - a REFUSAL (every check above, plus the claims and state-dir guards) is
+// Changed=false with 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.
+// - a mid-pass IO or systemd FAILURE (a write, a daemon-reload, the socket
+// sequence) rolls the bytes back to Prev so that "the bytes on disk say
+// converged" stays a sound invariant — if systemd never loaded them, they
+// must not be on disk claiming it did, or the next pass would find no
+// diff and never retry. Changed is then false, because the disk is back
+// where it started. It is true only when even the roll-back failed, which
+// is the honest answer: bytes moved and this pass could not undo it.
+//
+// Failures AFTER a successful reload — an `enable`, a `start`, a daemon's
+// `restart` — deliberately do NOT roll the bytes back. systemd has the new
+// configuration and it is correct; the failure is the app's. Rolling back
+// there would flap the unit text between two shapes on every pass, and the
+// version-change caller already owns that case (it rolls back the symlink and
+// calls Restore).
//
// Concurrency, stated rather than solved: two converges in one process (the
// hourly RunAdopt and the version-change converge inside Pass) are
@@ -164,6 +188,20 @@ func Converge(o Ops, p Plan) (Result, error) {
}
hostRole := p.Set.HostRole && p.Grants.HostRole
+ res.Prev.Daemon = p.Set.Daemon
+
+ // Not a refusal — a granted port on a hibernating route is a legitimate
+ // shape (socket activation is the wake-on-connect half of hibernation) —
+ // but it is a wake path that does NOT go through the activator, so the
+ // edge's health gate does not cover it: a broken boot answers a connection
+ // on :25 by parking it in the backlog rather than by returning a clean
+ // 503. One line per pass, for as long as the combination exists, because
+ // the operator who granted the port did not necessarily choose this.
+ if p.Hibernate {
+ for _, tok := range grantedPortTokens(p) {
+ o.logf(" %s: %s is granted on a hibernating route — a connection to that port starts the unit directly, bypassing the activator's health-gated wake; the edge's clean 503 on a broken boot does not cover this path", p.Host, tok)
+ }
+ }
// Read both halves BEFORE any refusal that needs to know what this route
// already claims, and before anything is written. Nothing here changes
@@ -213,40 +251,50 @@ func Converge(o Ops, p Plan) (Result, error) {
return res, err
}
- svcChanged, err := o.materialize(p.Host, svcPath, svcBody, curSvc, svcExists, svcGenerated)
- if err != nil {
- return res, err
+ svcChanged, svcErr := o.materialize(p.Host, svcPath, svcBody, curSvc, svcExists, svcGenerated)
+ var sockChanged bool
+ var sockErr error
+ if svcErr == nil {
+ sockChanged, sockErr = o.materialize(p.Host, sockPath, sockBody, curSock, sockExists, sockGenerated)
}
- 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")
- }
+ res.Changed = svcChanged || sockChanged
+ if err := errors.Join(svcErr, sockErr); err != nil {
+ // A half-written pass is the divergence this whole rollback exists to
+ // prevent: one unit converged, the other not, and the bytes on disk
+ // claiming both are done. Put them back so the next pass sees a diff
+ // and retries the WHOLE change.
+ res.Changed = o.rollbackBytes(res.Prev, res.Changed)
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.
+ // pass free. It happens BEFORE any restart or start below, without
+ // exception: a restart against configuration systemd has not re-read is a
+ // pass that looks like it worked and changed nothing.
if res.Changed {
if err := o.systemctl("daemon-reload"); err != nil {
o.logf(" %s: daemon-reload: %v", p.Host, err)
+ res.Changed = o.rollbackBytes(res.Prev, res.Changed)
return res, err
}
}
sockLive := sockBody != nil
- restarted, err := o.applyUnitState(p, sockLive, svcChanged, sockChanged)
- if err != nil {
- return res, err
+ st := o.applyUnitState(p, sockLive, svcChanged, sockChanged)
+ if st.err != nil {
+ if st.diverged {
+ // The socket sequence is the one place where systemd's running
+ // state can end up disagreeing with bytes that read "correct".
+ // Roll them back so the next pass retries rather than concluding
+ // there is nothing to do.
+ res.Changed = o.rollbackBytes(res.Prev, res.Changed)
+ }
+ return res, st.err
}
if res.Changed {
what := "capability drop-ins converged"
- if restarted {
+ if st.restarted {
what += " and the unit restarted"
}
o.logf(" %s: %s", p.Host, what)
@@ -254,15 +302,97 @@ func Converge(o Ops, p Plan) (Result, error) {
return res, nil
}
+// rollbackBytes puts both halves back to what this pass found, WITHOUT
+// touching unit state — the caller is already in a failure path and the point
+// is only to stop the disk from claiming a change systemd never took. It
+// reports whether any byte is still moved afterwards, which is what Converge
+// returns as Changed: false when the disk is back where it started, true when
+// even the roll-back failed.
+func (o Ops) rollbackBytes(prev Snapshot, changed bool) bool {
+ if !changed {
+ return false
+ }
+ svcChanged, sockChanged, errs := o.applyHalves(prev)
+ if len(errs) > 0 {
+ o.logf(" %s: could not roll the capability drop-ins back after a failed pass (%v) — the next pass will find them mid-change", prev.Host, errors.Join(errs...))
+ return true
+ }
+ if svcChanged || sockChanged {
+ if err := o.systemctl("daemon-reload"); err != nil {
+ o.logf(" %s: daemon-reload after roll-back: %v", prev.Host, err)
+ }
+ o.logf(" %s: rolled the capability drop-ins back to what this pass found, so the next pass retries the whole change", prev.Host)
+ }
+ return false
+}
+
+// applyHalves writes a Snapshot's bytes over whatever is on disk now — the
+// shared body of Restore and rollbackBytes. 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 (o Ops) applyHalves(want Snapshot) (svcChanged, sockChanged bool, errs []error) {
+ svcPath, sockPath := DropInPaths(o.etcDir(), want.Host)
+ halves := []struct {
+ path string
+ body []byte
+ had bool
+ out *bool
+ }{
+ {svcPath, want.Service, want.ServiceHad, &svcChanged},
+ {sockPath, want.Socket, want.SocketHad, &sockChanged},
+ }
+ for _, half := range halves {
+ body := half.body
+ if !half.had {
+ body = nil
+ }
+ cur, exists, generated, err := readDropIn(half.path)
+ if err != nil {
+ o.logf(" %s: read %s: %v", want.Host, half.path, err)
+ errs = append(errs, err)
+ continue
+ }
+ did, err := o.materialize(want.Host, half.path, body, cur, exists, generated)
+ if err != nil {
+ errs = append(errs, err)
+ }
+ *half.out = did
+ }
+ return svcChanged, sockChanged, errs
+}
+
+// grantedPortTokens is the declared-AND-granted port set, as canonical grant
+// tokens, sorted — the ports that actually materialize.
+func grantedPortTokens(p Plan) []string {
+ var toks []string
+ for _, decl := range p.Set.Ports {
+ if p.Grants.Ports[decl.Port] {
+ toks = append(toks, PortToken(decl.Port))
+ }
+ }
+ sort.Strings(toks)
+ return toks
+}
+
+// unitState is what applyUnitState did and how badly it failed. diverged is
+// the distinction that matters: a failed SOCKET SEQUENCE means systemd's
+// running listener no longer matches the bytes on disk, and only the caller
+// rolling those bytes back makes the next pass retry.
+type unitState struct {
+ restarted bool
+ diverged bool
+ err error
+}
+
// 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) {
+// a daemon route's drift repair needs, and the service restart. Every verb
+// here runs AFTER Converge's daemon-reload, never before it.
+func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) unitState {
svcUnit, sockUnit := ServiceUnit(p.Host), SocketUnit(p.Host)
- var firstErr error
+ var st unitState
note := func(err error) {
- if err != nil && firstErr == nil {
- firstErr = err
+ if err != nil && st.err == nil {
+ st.err = err
}
}
@@ -277,8 +407,11 @@ func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) (boo
// 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")
+ err := o.socketSequence(p.Host, p.Set.Daemon, "restart")
note(err)
+ if err != nil {
+ st.diverged = true
+ }
restartedService = true
}
@@ -297,7 +430,7 @@ func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) (boo
// 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"))
+ note(o.socketSequence(p.Host, p.Set.Daemon, "start"))
restartedService = true
}
}
@@ -320,28 +453,43 @@ func (o Ops) applyUnitState(p Plan, sockLive, svcChanged, sockChanged bool) (boo
note(o.systemctl("restart", svcUnit))
restartedService = true
}
- if firstErr != nil {
- o.logf(" %s: unit state: %v", p.Host, firstErr)
+ st.restarted = restartedService
+ if st.err != nil {
+ o.logf(" %s: unit state: %v", p.Host, st.err)
}
- return restartedService, firstErr
+ return st
}
// 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.
+// start) the socket, start the service.
+//
+// What the ordering buys, stated precisely so nobody reads more into it than
+// it gives: while the SERVICE is down and the socket unit is up, inbound
+// connections queue in the kernel backlog and are answered when the service
+// comes back — that is the phase spec §4c's "an MX restart never refuses a
+// connection" is about, and it is strictly better than today, where a relay
+// restart drops :25 entirely. During the socket unit's OWN restart the
+// listener does not exist, and connections in that window are refused, not
+// queued. The window is one systemctl verb wide and only opens when the
+// rendered ListenStream set actually changed.
//
// 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.
//
+// Accepted, and theoretical until keymail's LISTEN_FDS change lands: between
+// the stop and the socket verb the socket unit may still be up, so a
+// connection can socket-ACTIVATE the service back before this function starts
+// it — after which the start is systemd's no-op. That is a correct end state
+// (the service is running against the reloaded configuration), just not one
+// this function drove; it is not worth an isolate/mask dance to prevent.
+//
// 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)
+func (o Ops) socketSequence(host string, daemon bool, verb string) error {
+ svcUnit, sockUnit := ServiceUnit(host), SocketUnit(host)
+ shouldRun := daemon || o.query("is-active", svcUnit)
var errs []error
if shouldRun {
@@ -544,7 +692,7 @@ func scanClaims(etcDir, exceptHost string) (map[int]string, map[string]string) {
switch kind {
case "socket":
if v, ok := strings.CutPrefix(line, "ListenStream="); ok {
- if n, err := strconv.Atoi(v); err == nil {
+ if n, ok := listenStreamPort(v); ok {
ports[n] = host
}
}
@@ -558,6 +706,30 @@ func scanClaims(etcDir, exceptHost string) (map[int]string, map[string]string) {
return ports, dirs
}
+// listenStreamPort reads the port out of a ListenStream= value. This package
+// only ever WRITES the bare-number form, but the claim scan also reads files
+// this package did not write, and systemd accepts several spellings of the
+// same bind: "25", "0.0.0.0:25", "[::]:25", "1.2.3.4:25". Taking the segment
+// after the last colon covers all of them, so an address form cannot dodge
+// the box-wide port claim — the check would be worth little if it could.
+//
+// A value containing a path separator is a unix socket (the template's own
+// ListenStream=/run/carlos/apps/%i.sock) and claims no TCP port at all.
+func listenStreamPort(v string) (int, bool) {
+ v = strings.TrimSpace(v)
+ if v == "" || strings.Contains(v, "/") {
+ return 0, false
+ }
+ if i := strings.LastIndex(v, ":"); i >= 0 {
+ v = v[i+1:]
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 1 || n > 65535 {
+ return 0, false
+ }
+ return n, true
+}
+
// 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@")
@@ -595,6 +767,18 @@ func dropInHas(body []byte, line string) bool {
// 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.
+//
+// Known limit, named rather than papered over: this tie-break is decided
+// against the PLANS, and Converge's own check is decided against what is
+// already on DISK. When the lexicographically-LATER host is the one already
+// materialized, the two disagree — CheckClaims refuses the later host, and
+// Converge then refuses the earlier host too, because the later host's
+// drop-in really is holding the port. The result is a stable both-refused
+// stall, not a flap: neither route materializes and both log every pass until
+// an operator revokes one grant. That is the fail-closed direction (nobody
+// silently takes :25 off a running mail server), and the alternative —
+// letting the winner evict the incumbent's drop-in — is a pass that stops
+// somebody else's listener to satisfy a sort order.
func CheckClaims(plans []Plan) map[string]error {
sorted := make([]Plan, len(plans))
copy(sorted, plans)
@@ -634,12 +818,16 @@ func CheckClaims(plans []Plan) map[string]error {
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).
+// Restore puts prev back — 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.
+// The SERVICE half's restart is the caller's: it rolled the symlink back and
+// it owns bringing the app up on the old version. The SOCKET half's is NOT.
+// Bytes are not a binding: a restored ListenStream that systemd never re-binds
+// restores nothing — the MX stays off :25 after a rollback — and a rolled-back
+// ADDED port stays publicly bound to a binary that never asked for it. So when
+// the socket half's bytes move, Restore runs the same stop → restart → start
+// sequence Converge does, which is why Snapshot carries Daemon.
//
// 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
@@ -649,41 +837,21 @@ func Restore(o Ops, prev Snapshot) error {
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 {
+ svcChanged, sockChanged, errs := o.applyHalves(prev)
+ if svcChanged || sockChanged {
+ // Before any unit verb, without exception.
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)
}
+ if sockChanged {
+ if err := o.socketSequence(prev.Host, prev.Daemon, "restart"); err != nil {
+ o.logf(" %s: rebinding the previous version's listeners: %v", prev.Host, err)
+ errs = append(errs, err)
+ }
+ }
return errors.Join(errs...)
}
diff --git a/internal/caps/converge_test.go b/internal/caps/converge_test.go
index c6fc6fa4b..266b1dbf6 100644
--- a/internal/caps/converge_test.go
+++ b/internal/caps/converge_test.go
@@ -175,6 +175,180 @@ func TestGrantAddedWritesTheDropInAndRestarts(t *testing.T) {
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)
}
+ // The reload precedes EVERY unit verb: a restart against configuration
+ // systemd has not re-read is a pass that looks like it worked and changed
+ // nothing.
+ reload := indexOf(f.calls, "daemon-reload")
+ for i, c := range f.calls {
+ verb, _, _ := strings.Cut(c, " ")
+ switch verb {
+ case "restart", "start", "stop", "enable":
+ if i < reload {
+ t.Fatalf("%q ran before the daemon-reload: %v", c, f.calls)
+ }
+ }
+ }
+}
+
+// A failed daemon-reload must not leave the new bytes on disk: the next pass
+// would see no diff, conclude there is nothing to do, and the box would run
+// the old configuration behind a drop-in that reads "converged" forever.
+func TestAFailedReloadRollsTheBytesBackSoTheNextPassRetries(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ p := keymailPlan(t, keymailGrants())
+ f.fail["daemon-reload"] = errors.New("systemd is not running")
+
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Fatal("Converge swallowed a failed daemon-reload")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true after the pass was rolled back")
+ }
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
+ for _, c := range f.calls {
+ verb, _, _ := strings.Cut(c, " ")
+ if verb == "restart" || verb == "start" || verb == "stop" {
+ t.Fatalf("unit state changed after a failed reload: %v", f.calls)
+ }
+ }
+
+ // Pass N+1, with a working systemd: the whole change is retried.
+ delete(f.fail, "daemon-reload")
+ f.reset()
+ res, err = Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge (retry): %v", err)
+ }
+ if !res.Changed {
+ t.Fatal("the retry pass found nothing to do — the rollback did not restore the diff")
+ }
+ if got, want := readFile(t, svcPath), readGolden(t, "keymail.service.conf"); !bytes.Equal(got, want) {
+ t.Fatalf("retry did not converge:\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("retry did not converge the socket half:\n got: %q\n want: %q", got, want)
+ }
+}
+
+// A half-written pass is the same divergence in miniature: one unit
+// converged, the other not, and the disk claiming both are done.
+func TestAPartialWriteRollsBothHalvesBackToPrev(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)
+ svcA, sockA := readFile(t, svcPath), readFile(t, sockPath)
+ f.reset()
+
+ // Version B changes both halves, but the socket half cannot be written:
+ // its temp file lands in a directory that has been replaced by... itself,
+ // made unwritable.
+ if err := os.Chmod(filepath.Dir(sockPath), 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(filepath.Dir(sockPath), 0o755) })
+ var b Set
+ if err := b.UnmarshalJSON([]byte(`{"v":1,"command":"forward","daemon":true,"runtime_dir":"keymail","host_role":true,"ports":[{"port":587,"name":"submission"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ p.Set = &b
+ p.Grants = Grants{Ports: map[int]bool{587: true}, HostRole: true}
+
+ res, err := Converge(o, p)
+ if err == nil {
+ t.Skip("this environment allows writing into a 0500 directory (running as root?)")
+ }
+ if res.Changed {
+ t.Fatal("Changed = true after both halves were rolled back")
+ }
+ if got := readFile(t, svcPath); !bytes.Equal(got, svcA) {
+ t.Fatalf("the service half was left mid-change:\n got: %q\n want: %q", got, svcA)
+ }
+ if got := readFile(t, sockPath); !bytes.Equal(got, sockA) {
+ t.Fatalf("the socket half moved:\n got: %q\n want: %q", got, sockA)
+ }
+ for _, c := range f.calls {
+ verb, _, _ := strings.Cut(c, " ")
+ if verb == "restart" || verb == "start" || verb == "stop" {
+ t.Fatalf("unit state changed on a half-written pass: %v", f.calls)
+ }
+ }
+}
+
+// Deviation #4's consequence, made visible: the socket unit is enabled for
+// hibernating routes too (or the ListenStream binds nothing), and a granted
+// public port is then a wake path that does not go through the activator's
+// health gate.
+func TestAGrantedPortOnAHibernatingRouteIsAdvised(t *testing.T) {
+ o, _, logs, _ := testOps(t)
+ var s Set
+ if err := s.UnmarshalJSON([]byte(`{"v":1,"command":"relay","ports":[{"port":25,"name":"mx"}]}`)); err != nil {
+ t.Fatal(err)
+ }
+ p := Plan{Host: "mx.example.com", App: "keymail", Set: &s,
+ Grants: Grants{Ports: map[int]bool{25: true}}, Hibernate: true}
+
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge: %v", err)
+ }
+ if !hasLog(*logs, "port:25 is granted on a hibernating route") {
+ t.Fatalf("logs = %v, want the hibernating-port advisory", *logs)
+ }
+ if !hasLog(*logs, "bypassing the activator's health-gated wake") {
+ t.Fatalf("logs = %v, want the advisory to say what the risk is", *logs)
+ }
+
+ // Not advised when the port is merely declared: nothing binds, so there is
+ // no wake path to warn about. Advice that fires on a healthy fleet is
+ // advice nobody reads.
+ o2, _, logs2, _ := testOps(t)
+ q := p
+ q.Host = "mx2.example.com"
+ q.Grants = Grants{}
+ if _, err := Converge(o2, q); err != nil {
+ t.Fatalf("Converge (ungranted): %v", err)
+ }
+ if hasLog(*logs2, "hibernating route") {
+ t.Fatalf("logs = %v, want no advisory for an ungranted port", *logs2)
+ }
+}
+
+// An address-form ListenStream is the same claim: a hand-written drop-in
+// cannot dodge the box-wide port check by spelling :25 differently.
+func TestPortClaimsSeeAddressFormListenStreams(t *testing.T) {
+ for _, form := range []string{"25", "0.0.0.0:25", "[::]:25", "10.0.0.4:25"} {
+ o, _, logs, _ := testOps(t)
+ 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="+form+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Converge(o, keymailPlan(t, keymailGrants())); err == nil {
+ t.Fatalf("ListenStream=%s did not count as a claim on :25", form)
+ }
+ if !hasLog(*logs, "port 25 is already claimed by hand.example.com") {
+ t.Fatalf("ListenStream=%s: logs = %v", form, *logs)
+ }
+ }
+ // The template's own unix listener claims no TCP port at all.
+ o, _, _, _ := testOps(t)
+ 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=/run/carlos/apps/hand.example.com.sock\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Converge(o, keymailPlan(t, keymailGrants())); err != nil {
+ t.Fatalf("a unix ListenStream was read as a TCP claim: %v", err)
+ }
}
// TestGrantRevokedRewritesTheDropIn: a grant is not a promise. Revoking
@@ -547,15 +721,72 @@ func TestRestorePutsThePreviousDropInsBack(t *testing.T) {
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 {
+ reload := indexOf(f.calls, "daemon-reload")
+ if 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)
- }
+ // Bytes are not a binding. A restored ListenStream systemd never re-binds
+ // restores nothing — the production MX would stay off :25 after a rollback
+ // — so the socket half goes back through the pinned sequence, after the
+ // reload.
+ 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 || !(reload < stop && stop < sock && sock < start) {
+ t.Fatalf("Restore did not re-bind the restored listener in order: %v", f.calls)
+ }
+}
+
+// The mirror case, and the one a bytes-only Restore gets exactly as wrong:
+// version A had NO socket drop-in, B added one and bound :25, the restart
+// failed, and the rollback must both delete the file and give the port back.
+// Left bound, it is a public listener held by a binary that never asked for
+// it and a claim that blocks the next route.
+func TestRestoreReleasesAPortThatTheRolledBackVersionAdded(t *testing.T) {
+ o, f, _, _ := testOps(t)
+ var a Set
+ if err := a.UnmarshalJSON([]byte(`{"v":1,"command":"relay","daemon":true}`)); err != nil {
+ t.Fatal(err)
+ }
+ p := Plan{Host: "keymail.dev", App: "keymail", Set: &a, Grants: keymailGrants()}
+ if _, err := Converge(o, p); err != nil {
+ t.Fatalf("Converge A: %v", err)
+ }
+ _, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, sockPath)
+
+ p.Set = keymailSet(t) // B declares ports, and :25 is granted
+ resB, err := Converge(o, p)
+ if err != nil {
+ t.Fatalf("Converge B: %v", err)
+ }
+ if resB.Prev.SocketHad {
+ t.Fatal("the snapshot claims a socket drop-in that never existed")
+ }
+ readFile(t, sockPath) // B really did bind it
+ f.reset()
+ // The state a rollback actually happens in: the restart that triggered it
+ // failed, so the service is DOWN. Whether it is supposed to be running is
+ // not something the disk can answer, which is why the snapshot carries it
+ // — without that, the sequence would skip the stop/start pair and leave a
+ // daemon stopped on the version it was just rolled back to.
+ f.active[ServiceUnit(p.Host)] = false
+
+ if err := Restore(o, resB.Prev); err != nil {
+ t.Fatalf("Restore: %v", err)
+ }
+ mustNotExist(t, sockPath)
+ reload := indexOf(f.calls, "daemon-reload")
+ sock := indexOf(f.calls, "restart carlos-app@keymail.dev.socket")
+ if reload < 0 || sock < 0 || reload > sock {
+ t.Fatalf("Restore removed the drop-in but never released the port: %v", f.calls)
+ }
+ if indexOf(f.calls, "start carlos-app@keymail.dev.service") < 0 || !f.active[ServiceUnit(p.Host)] {
+ t.Fatalf("Restore left the daemon stopped: %v", f.calls)
+ }
+ // And the box no longer counts it as a claim, so the next route can have it.
+ if ports, _ := scanClaims(o.EtcDir, "other.example.com"); ports[25] != "" {
+ t.Fatalf("port 25 is still claimed by %q after the rollback", ports[25])
}
}
@@ -833,9 +1064,15 @@ func TestSocketRestartFailureStillStartsTheService(t *testing.T) {
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")
+ // systemd's listener no longer matches the bytes, so the bytes go back:
+ // otherwise the next pass finds no diff and never retries, and the box
+ // sits on a drop-in that reads "converged" forever.
+ if res.Changed {
+ t.Fatal("Changed = true, but the failed pass was rolled back")
}
+ svcPath, sockPath := DropInPaths(o.EtcDir, p.Host)
+ mustNotExist(t, svcPath)
+ mustNotExist(t, sockPath)
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 {