carlos / platform Public

release: converge capabilities before PointLive, and roll both back together

adoptHost's version-change path becomes spec §4d's order: ensureStaged
(binary + manifest) -> converge the NEW version's capabilities -> PointLive
-> restart. A restart failure rolls the symlink back AND calls caps.Restore
with the pre-converge snapshot, then poisons the version for the rest of the
pass exactly as before. A converge REFUSAL refuses the version outright —
nothing pointed live, the box keeps serving what it has — which is where an
ensureStaged failure already falls.

Two shapes this needed:

ensureStaged now returns (*Manifest, error), and the converge plan is built
from that manifest, never from a re-read of the staged sibling. The sibling
write is best-effort, so a failed write reads back as (nil, nil) — "leave
the drop-ins alone" — and a new binary would go live under the previous
version's unit shape without a word. The one path with no manifest to give
(a staged binary whose manifest neither disk nor bucket answers for) still
returns (nil, nil), and an armed pass turns that into a refusal; every other
caller keeps today's contract exactly.

A manifest that RESOLVED and declares nothing carries &caps.Set{V: Version},
not m.Capabilities verbatim. The pointer is nil for both a legacy manifest
and a fetch that never happened, and passing it through would mean a
capability DROPPED from a manifest is never removed — the opposite of the
design.

WithCapabilities is wired into RunAdopt's release.Pass ONLY. adoptLoop and
canaryAdoptLoop run inside carlos-edge.service (ProtectSystem=strict,
ReadWritePaths=/data /run/carlos) and cannot write /etc/systemd/system at
all; arming it there would log a permission denial every 2-3 seconds and
converge nothing. Both call sites carry the loud never-add-this comment.
The accepted consequence, ruled on in review: a freshly promoted canary runs
its new binary against the previous version's unit shape for up to an hour.
adoptHost prints one line per such repoint so the window is visible — the
signal is "the new manifest declares capabilities at all", the cheapest
honest one available without the grants and a render of both sides.

capabilityRoute is adoptable() plus Backing == "unit", so a platform alias
and every exec-backed route materialize nothing. Every route in every
existing test in this package is exec-backed, which is why no existing test
can reach the converge path and no test can write to a real /etc — the
reason this lands safely on its own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c847b0b92eb509225da013365cca424d052dd1c1 parent c53773d
3 files changed, +993 −28
  • internal/host/agent.go +29 −1
  • internal/release/adopt.go +246 −23
  • internal/release/adopt_test.go +718 −4
diff --git a/internal/host/agent.go b/internal/host/agent.go
index d73c6c32f..13cf0c62d 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -81,6 +81,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/lease"
@@ -215,6 +216,14 @@ func adoptLoop(reg *registry.Registry) {
// hourly callers both nagging would double the log line every hour;
// worse, if this loop's cadence ever changes, a nag wired here changes
// frequency with it silently. One caller, one nag.
+ //
+ // DO NOT add release.WithCapabilities(...) to this call either, and
+ // for a harder reason than the nag's: that option WRITES
+ // /etc/systemd/system, and this loop runs inside carlos-edge.service,
+ // which is ProtectSystem=strict with ReadWritePaths=/data /run/carlos.
+ // It could not converge a drop-in if it tried; all it would produce is
+ // a permission-denied line every hour. The capability converge belongs
+ // to RunAdopt (carlos-adopt.service, root) alone — see below.
if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
@@ -299,6 +308,17 @@ func canaryAdoptLoop(reg *registry.Registry) {
// still would nag on every single tick — a journal-flooding poll storm
// out of what is supposed to be an hourly, easy-to-skim log line. The
// nag belongs to RunAdopt (carlos-adopt.service) alone.
+ //
+ // DO NOT add release.WithCapabilities(...) to this call, ever. Same
+ // sandbox as adoptLoop above (ProtectSystem=strict,
+ // ReadWritePaths=/data /run/carlos), and this one ticks every 2-3
+ // SECONDS: arming the converge here would write nothing and log a
+ // permission denial forever. The accepted consequence, ruled on in
+ // the 2026-08-03 review of the capability design: a freshly promoted
+ // canary runs its new binary against the unit shape the previous
+ // version left until the hourly root pass converges it — up to an
+ // hour. release.adoptHost prints one line per such repoint so the
+ // window is visible in the journal rather than silent.
if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil {
log.Printf("agent: canary adopt pass: %v", err)
}
@@ -780,7 +800,15 @@ func RunAdopt(args []string) {
// package call release.Pass/PassFilter with no options at all, so an
// expired canary nags once an hour from here, never every 2-3 seconds
// from the in-process fast loop.
- if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout)); err != nil {
+ //
+ // WithCapabilities(caps.Ops{}) is likewise set HERE ONLY, and the zero Ops
+ // is the production one (real /etc/systemd/system, real systemctl, real
+ // useradd). This verb runs as root out of carlos-adopt.service; the
+ // in-process loops in this same file run inside carlos-edge.service's
+ // ProtectSystem=strict sandbox and cannot write unit configuration at all,
+ // which is why they call Pass/PassFilter with no options and why both
+ // carry a comment saying never to add this one.
+ if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout), release.WithCapabilities(caps.Ops{})); err != nil {
log.Fatalf("adopt: %v", err)
}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 68acc5534..0abefec13 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -15,6 +15,7 @@ import (
"runtime"
"time"
+ "github.com/carlosframework/platform/internal/caps"
"github.com/carlosframework/platform/internal/registry"
)
@@ -23,10 +24,9 @@ import (
var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH
// PassOption configures one call to Pass or PassFilter beyond their required
-// arguments. Currently the only option is NagExpiredCanaries; the type
-// exists so a call site opts in explicitly and a future option composes
-// the same way, rather than every option becoming its own trailing
-// parameter.
+// arguments — NagExpiredCanaries and WithCapabilities today. The type exists
+// so a call site opts in explicitly and a future option composes the same
+// way, rather than every option becoming its own trailing parameter.
type PassOption func(*passConfig)
type passConfig struct {
@@ -34,6 +34,12 @@ type passConfig struct {
// default — every call site that does not pass NagExpiredCanaries)
// means the nag never fires at all, not even to a discarded writer.
nag io.Writer
+
+ // capsOps arms the capability converge inside adoptHost; nil (the
+ // default — every call site that does not pass WithCapabilities, and
+ // every existing test) means converge never runs and this package's
+ // behaviour is byte-identical to before the capability design landed.
+ capsOps *caps.Ops
}
// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
@@ -59,6 +65,39 @@ func NagExpiredCanaries(w io.Writer) PassOption {
return func(c *passConfig) { c.nag = w }
}
+// WithCapabilities arms the capability converge inside adoptHost (spec §4d):
+// on a version change, a unit-backed route's drop-ins are materialized for
+// the NEW version before its live symlink moves, and restored with the
+// symlink when the restart fails. Unset — every existing caller and every
+// existing test — means converge never runs and this package behaves exactly
+// as it did before.
+//
+// The zero Ops is the production one: real /etc/systemd/system, real
+// /var/lib/carlos-app, real systemctl, real useradd. A test injects temp
+// dirs and recorders through the same value.
+//
+// DO NOT pass this to a fast/frequent adoption loop, and DO NOT pass it from
+// anything running inside carlos-edge.service. It belongs to the HOURLY ROOT
+// adopt pass ALONE — carlos-adopt.service's RunAdopt (internal/host/agent.go),
+// the only caller that sets it — for a harder reason than the nag's: this
+// option WRITES /etc/systemd/system, and carlos-edge.service is
+// ProtectSystem=strict with ReadWritePaths=/data /run/carlos, so the
+// in-process adoptLoop and canaryAdoptLoop physically cannot. Arming it
+// there would not converge anything; it would log a permission denial every
+// 2-3 seconds forever. Enforced by convention (which call sites pass the
+// option), not by the type system — see agent.go's adoptLoop/canaryAdoptLoop
+// for the loud "never add this" comments at the two places it would be
+// easiest to add by accident.
+//
+// The consequence, ruled on and accepted (2026-08-03): a freshly promoted
+// canary picked up by the fast loop runs the new binary against the unit
+// shape the PREVIOUS version left, until the hourly root pass converges it.
+// adoptHost prints one line per such repoint so the window is visible —
+// see the capsOps == nil branch there.
+func WithCapabilities(o caps.Ops) PassOption {
+ return func(c *passConfig) { c.capsOps = &o }
+}
+
// Pass is one full adoption sweep: group instance routes by (App, Channel),
// resolve each channel pointer once, verify signatures and artifact sha256s,
// download/stage missing versions, and repoint live/<host> symlinks.
@@ -215,12 +254,14 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
continue
}
ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
- adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg.nag)
+ adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now, cfg)
}
for _, rt := range hibernating {
ptr := pointers[groupKey{RouteAccount(rt), rt.App, routeChannel(rt)}]
// restart=nil: hibernating hosts get the pure repoint — never woken.
- adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg.nag)
+ // The capability converge still runs for them (a drop-in is unit
+ // text, not a wake); see adoptHost.
+ adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now, cfg)
}
return nil
}
@@ -242,6 +283,24 @@ func adoptable(rt registry.Route) bool {
(rt.Status == "" || rt.Status == "ready")
}
+// capabilityRoute reports whether this route may materialize capabilities:
+// a lifecycle-managed instance of a known app, unit-backed, non-platform,
+// ready (spec §4e). It is adoptable() plus the unit backing, deliberately —
+// a route this pass would not adopt is not one whose unit shape this pass
+// has any business rewriting.
+//
+// A platform ALIAS never materialises anything: an alias is a second name
+// for one process, and a second carlos-app@ unit claiming :25 would break
+// the real one. adoptable() already excludes them and this depends on that.
+//
+// An EXEC-backed route (Backing == "") whose release declares capabilities
+// materialises nothing here and is told so once per pass by RunAdopt's
+// advisory, not by this function — that is the guided path through the §5
+// migration.
+func capabilityRoute(rt registry.Route) bool {
+ return adoptable(rt) && rt.Backing == registry.BackingUnit
+}
+
// routeChannel is the channel a route follows; "" means stable.
func routeChannel(rt registry.Route) string {
if rt.Channel == "" {
@@ -270,13 +329,15 @@ func RouteAccount(rt registry.Route) string {
// pointer didn't resolve; skip quietly, the group phase already logged).
// restart==nil is the symlink-only path (hibernating hosts, or a caller with
// no proc manager): repoint and record, never wake, never health-gate.
-// nag is passConfig.nag from the call's PassOptions — nil (the ordinary
+// cfg is the call's PassOptions, already applied: cfg.nag nil (the ordinary
// case) means the canary-TTL nag below never fires, never even builds its
-// string; see NagExpiredCanaries for who sets it and why.
-func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, nag io.Writer) {
+// string, and cfg.capsOps nil means the capability converge never runs at
+// all. See NagExpiredCanaries and WithCapabilities for who sets each and why.
+func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time, cfg passConfig) {
if ptr == nil {
return
}
+ nag := cfg.nag
// The nag (spec §5 "Canaries expire"): visibility only, and deliberately
// BEFORE the up-to-date short-circuit below — an expired canary that
// isn't moving (the common case: the branch shipped once and nobody
@@ -329,23 +390,112 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver)
return
}
- if err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host); err != nil {
+ m, err := ensureStaged(ctx, s, optDir, RouteAccount(rt), rt.App, ver, rt.Host)
+ if err != nil {
fmt.Printf(" %s: %v\n", rt.Host, err)
return
}
+
+ // The capability converge for the NEW version, BEFORE PointLive (spec
+ // §4d). m is the manifest ensureStaged has in hand — never a re-read of
+ // the staged sibling, whose write is best-effort: a sibling that failed
+ // to write reads back as (nil, nil), which converge would take as
+ // "leave the drop-ins alone", and the new version would go live against
+ // the previous version's unit shape silently. Here a manifest this pass
+ // could not obtain REFUSES the version instead (capabilityPlan).
+ //
+ // A hibernating route reaches this with restart == nil and still
+ // converges: a drop-in is unit text, not a wake, and nothing below
+ // starts it.
+ var (
+ prev caps.Snapshot
+ plan caps.Plan
+ converged bool
+ )
+ if cfg.capsOps != nil && capabilityRoute(rt) {
+ plan, err = capabilityPlan(rt, m)
+ if err == nil {
+ var res caps.Result
+ res, err = caps.Converge(*cfg.capsOps, plan)
+ prev = res.Prev
+ }
+ if err != nil {
+ // A converge REFUSAL refuses the VERSION on this box, in the same
+ // direction and for the same reason as an ensureStaged failure:
+ // nothing is pointed live, so the box keeps serving what it has
+ // rather than run a new binary against a unit shape the platform
+ // declined to write. Converge itself has already written nothing
+ // and logged the operator's fix.
+ //
+ // The poison is deliberately pass-wide even though some refusals
+ // are host-shaped (a port another route's drop-in already claims,
+ // a state-directory flip): the refusal this is most likely to be
+ // is version-shaped — a capability block this binary does not
+ // understand — and one skipped pass is the cheap direction.
+ poisoned[ver] = true
+ fmt.Printf(" %s: capabilities for %s were refused: %v — not pointed live\n", rt.Host, ver, err)
+ return
+ }
+ converged = true
+ }
+
if err := PointLive(optDir, rt.Host, RouteAccount(rt), ver, rt.App); err != nil {
fmt.Printf(" %s: repoint failed: %v\n", rt.Host, err)
return
}
+
+ // The accepted window, made visible (2026-08-03 ruling). A pass with no
+ // WithCapabilities — the in-process adoptLoop, and above all the 2-3
+ // second canaryAdoptLoop, neither of which can write /etc from inside
+ // carlos-edge.service — has just moved a unit-backed route onto a version
+ // that declares capabilities, without converging its unit shape.
+ //
+ // The signal is "the new manifest declares anything at all", not "the new
+ // set differs from what is materialized": the honest difference test needs
+ // the route's grants and a render of both sides, and this line exists to
+ // mark a window, not to diff it. It costs nothing — m is already in hand —
+ // and it errs toward one extra line, once per version change per host,
+ // never per tick.
+ if cfg.capsOps == nil && capabilityRoute(rt) && m != nil && m.Capabilities != nil {
+ fmt.Printf(" %s: %s declares capabilities and this pass does not write unit configuration — it runs against the drop-ins the previous version left until the root adopt pass (carlos-adopt.service, hourly) converges them\n", rt.Host, ver)
+ }
+
if restart != nil {
- if err := restart(rt.Host); err != nil {
+ // A converged DAEMON's restart is caps.Restart, not the caller's
+ // callback: this is the only call site that knows the plan, and it is
+ // the only restart whose error must be real. restartRoute's
+ // try-restart (and its swallow-and-continue) keeps owning every other
+ // route, byte-identically to before.
+ //
+ // plan.Set is non-nil whenever converged is true (capabilityPlan
+ // guarantees it); the nil check is here anyway, because a nil deref
+ // on this path would panic the root adopt pass for the whole box.
+ var rerr error
+ if converged && plan.Set != nil && plan.Set.Daemon {
+ rerr = caps.Restart(*cfg.capsOps, plan)
+ } else {
+ rerr = restart(rt.Host)
+ }
+ if rerr != nil {
// Poison the build for the rest of the pass and repoint back —
- // the host keeps running what it ran.
+ // the host keeps running what it ran. The symlink goes back
+ // FIRST: caps.Restore may start the service, and it must start
+ // on the old binary.
poisoned[ver] = true
if cur != "" {
_ = PointLive(optDir, rt.Host, RouteAccount(rt), cur, rt.App)
}
- fmt.Printf(" %s: update to %s failed: %v — rolled the symlink back\n", rt.Host, ver, err)
+ rolled := ""
+ if converged {
+ // Bytes are not a binding: Restore re-runs the socket
+ // sequence itself when the socket half moves, or the MX
+ // stays off :25 after a rollback.
+ if err := caps.Restore(*cfg.capsOps, prev); err != nil {
+ fmt.Printf(" %s: restoring the previous version's capability drop-ins: %v\n", rt.Host, err)
+ }
+ rolled = " and restored the previous version's capability drop-ins"
+ }
+ fmt.Printf(" %s: update to %s failed: %v — rolled the symlink back%s\n", rt.Host, ver, rerr, rolled)
return
}
}
@@ -360,6 +510,54 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
}
}
+// capabilityPlan turns a route plus the manifest of the version being
+// adopted into one converge plan, or refuses.
+//
+// Two rulings live here, and both are the difference between this feature
+// working and this feature quietly doing the opposite of what it says:
+//
+// - A manifest this pass could not obtain (m == nil: the binary is staged,
+// so ensureStaged succeeded, but neither the sibling nor the bucket
+// could answer for its manifest) REFUSES the version. caps.Plan's nil
+// Set means "leave the drop-ins alone", which is the right posture for a
+// pass that is merely surveying the box — and exactly the wrong one on a
+// version change, where it would point a new binary at the previous
+// version's unit shape and say nothing.
+//
+// - A manifest that RESOLVED and declares NOTHING is not the same thing.
+// Manifest.Capabilities is a nil pointer for both a legacy manifest and
+// a fetch that never happened, so it is never passed through verbatim:
+// a resolved manifest always carries a non-nil Set, empty when it
+// declares nothing, which is what makes converge REMOVE stale drop-ins.
+// Set: m.Capabilities verbatim would mean a capability dropped from a
+// manifest is never removed — the exact opposite of spec §4d's removal
+// semantics.
+//
+// An unparseable grants column refuses too, rather than defaulting to no
+// grants: "no grants" is a real instruction that revokes :25 and drops a
+// mail server's listener, and a column this binary cannot read is not
+// evidence that an operator asked for that.
+func capabilityPlan(rt registry.Route, m *Manifest) (caps.Plan, error) {
+ if m == nil {
+ return caps.Plan{}, fmt.Errorf("the manifest for this version could not be read or fetched, so its unit shape is unknown")
+ }
+ g, err := caps.ParseGrants(rt.Caps)
+ if err != nil {
+ return caps.Plan{}, fmt.Errorf("capability grants %q: %v", rt.Caps, err)
+ }
+ set := m.Capabilities
+ if set == nil {
+ set = &caps.Set{V: caps.Version}
+ }
+ return caps.Plan{
+ Host: rt.Host,
+ App: rt.App,
+ Set: set,
+ Grants: g,
+ Hibernate: rt.Hibernate,
+ }, nil
+}
+
// ensureStaged makes ver's build of app available in the version store,
// downloading and sha256-verifying it if needed. Idempotent across hosts in
// one pass. The manifest supplies the sha; the bytes are refused unless they
@@ -385,12 +583,37 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// review) is what makes leaving this non-fatal safe: every subsequent pass
// gets another chance at the live version's sibling, so a transient failure
// here heals on its own instead of being permanently missed.
-func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host string) error {
+//
+// It RETURNS the manifest, because adoptHost's capability converge builds its
+// plan from it (spec §4d) and must never re-read the sibling to get one: the
+// sibling write is best-effort, and a failed write reads back as (nil, nil) —
+// "leave the drop-ins alone" — which on a version change would point a new
+// binary at the previous version's unit shape without a word. The one path
+// that has no manifest of its own to give (a staged binary whose manifest
+// neither disk nor bucket would answer for) returns (nil, nil) DELIBERATELY,
+// and adoptHost turns that into a refusal when — and only when — capabilities
+// are armed for the route. Every other caller keeps today's contract exactly:
+// an error means "the binary is not on disk", nil means it is.
+func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host string) (*Manifest, error) {
staged := false
if _, err := os.Stat(VersionPath(optDir, acct, ver, app)); err == nil {
staged = true
if _, err := os.Stat(StagedManifestPath(optDir, acct, ver, app)); err == nil {
- return nil // binary and sibling both present — nothing to do
+ // Binary and sibling both present: the sibling IS this function's
+ // manifest in hand on this path — read once, locally, with no
+ // bucket round-trip, which is the whole point of staging it. This
+ // is not the re-read the doc comment above forbids: it happens
+ // only where the file is known to exist, and a read that fails or
+ // refuses (a rolled-back platform binary meeting a capability
+ // block a newer one wrote) falls through to the fetch below
+ // rather than answering "no capabilities".
+ m, err := ReadStagedManifest(optDir, acct, ver, app)
+ if err == nil && m != nil {
+ return m, nil
+ }
+ if err != nil {
+ fmt.Printf(" %s: staged manifest for %s: %v — re-fetching it\n", host, ver, err)
+ }
}
// Binary staged, sibling missing: fall through to fetch the manifest
// and write ONLY the sibling. This is the one path where an
@@ -404,9 +627,9 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host str
// fetch failure here is only ever worth a log line; see the
// doc comment above and backfillManifest's per-pass retry.
fmt.Printf(" %s: back-fill manifest for %s: %v\n", host, ver, err)
- return nil
+ return nil, nil
}
- return fmt.Errorf("manifest for %s/%s: %w", app, ver, err)
+ return nil, fmt.Errorf("manifest for %s/%s: %w", app, ver, err)
}
if !staged {
art, ok := m.Artifacts[adoptPlatform]
@@ -414,17 +637,17 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host str
// A box facing a release with no build for its platform: skip, never
// install the wrong arch. The next release ships it and the box
// catches up.
- return fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform)
+ return nil, fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform)
}
body, _, err := GetQualified(ctx, s, ArtifactKey(acct, app, ver, art.File))
if err != nil {
- return fmt.Errorf("download %s: %w", ver, err)
+ return nil, fmt.Errorf("download %s: %w", ver, err)
}
if got := etagOf(body); got != art.SHA256 {
- return fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)
+ return nil, fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver)
}
if err := StageArtifact(optDir, acct, ver, app, body); err != nil {
- return err
+ return nil, err
}
}
// The sibling write is best-effort: a failure here is not fatal to
@@ -435,12 +658,12 @@ func ensureStaged(ctx context.Context, s Store, optDir, acct, app, ver, host str
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
fmt.Printf(" %s: marshal staged manifest for %s: %v\n", host, ver, err)
- return nil
+ return m, nil
}
if err := StageManifest(optDir, acct, ver, app, raw); err != nil {
fmt.Printf(" %s: stage manifest sibling for %s: %v\n", host, ver, err)
}
- return nil
+ return m, nil
}
// backfillManifest is adoptHost's per-pass repair for a LIVE version's
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 02572cff0..e59e9d29b 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -768,9 +768,16 @@ func TestEnsureStagedWritesTheSiblingManifest(t *testing.T) {
t.Fatalf("Ship: %v", err)
}
- if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com"); err != nil {
+ inHand, err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com")
+ if err != nil {
t.Fatalf("ensureStaged: %v", err)
}
+ // The manifest ensureStaged HAS IN HAND is what adoptHost's converge
+ // builds its plan from (Task 7's first pin) — not a re-read of the
+ // sibling it just wrote.
+ if inHand == nil || inHand.Capabilities == nil || inHand.Capabilities.Command != "relay" {
+ t.Fatalf("ensureStaged returned %+v, want the fetched manifest with its capability block", inHand)
+ }
got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail")
if err != nil {
@@ -822,9 +829,13 @@ func TestEnsureStagedBackfillsASiblingForAnAlreadyStagedBinary(t *testing.T) {
t.Fatalf("sibling should be absent before backfill: %+v, %v", m2, err)
}
- if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com"); err != nil {
+ inHand, err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "keymail.example.com")
+ if err != nil {
t.Fatalf("ensureStaged (backfill): %v", err)
}
+ if inHand == nil || inHand.Capabilities == nil || !inHand.Capabilities.Daemon {
+ t.Fatalf("back-fill returned %+v, want the fetched manifest in hand", inHand)
+ }
got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail")
if err != nil {
@@ -879,9 +890,17 @@ func TestEnsureStagedSiblingWriteFailureIsNotFatalToStagingTheBinary(t *testing.
t.Fatalf("pre-create tmp block: %v", err)
}
- if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com"); err != nil {
+ inHand, err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com")
+ if err != nil {
t.Fatalf("ensureStaged returned an error for a failed SIBLING write: %v — the binary staged fine and that must not be fatal", err)
}
+ // Task 7's first pin, in its sharpest form: the sibling write FAILED, so
+ // a converge that re-read the sibling would see (nil, nil) and leave the
+ // previous version's drop-ins in place under a new binary. The manifest
+ // this function returns is the fetched one and is unaffected.
+ if inHand == nil || inHand.Capabilities == nil || !inHand.Capabilities.Daemon {
+ t.Fatalf("ensureStaged returned %+v after a failed sibling write, want the fetched manifest still in hand", inHand)
+ }
body, err := os.ReadFile(VersionPath(opt, "bab", "v1abc", "keymail"))
if err != nil || string(body) != "relay binary" {
@@ -909,9 +928,17 @@ func TestEnsureStagedBackfillFetchFailureIsNotFatal(t *testing.T) {
t.Fatalf("StageArtifact: %v", err)
}
- if err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com"); err != nil {
+ inHand, err := ensureStaged(ctx, s, opt, "bab", "keymail", "v1abc", "h.example.com")
+ if err != nil {
t.Fatalf("ensureStaged returned an error for an already-staged binary's failed back-fill fetch: %v — adoption of an already-running version must proceed", err)
}
+ // This is the ONE path with no manifest to give. It stays (nil, nil) so
+ // ordinary adoption proceeds; a capability-armed pass turns that nil into
+ // a refusal of the version rather than a silent leave-alone — see
+ // TestArmedPassRefusesAVersionWhoseManifestItCouldNotObtain.
+ if inHand != nil {
+ t.Fatalf("ensureStaged returned %+v, want nil (no manifest could be obtained)", inHand)
+ }
if got, err := ReadStagedManifest(opt, "bab", "v1abc", "keymail"); err != nil || got != nil {
t.Fatalf("sibling = %+v, %v; want absent (the fetch failed, nothing to write)", got, err)
@@ -978,3 +1005,690 @@ func TestPassBackfillsTheLiveVersionsSiblingOnAnUnchangedPass(t *testing.T) {
t.Fatalf("live version moved unexpectedly: %q", v)
}
}
+
+// ---- Task 7: the capability converge inside adoptHost -----------------------
+
+// capsRecorder is a tiny in-memory systemd for the capability tests in this
+// package, plus the one thing internal/caps's own fake cannot observe: WHERE
+// THE LIVE SYMLINK POINTED at the moment each verb ran. That is how the
+// ordering rule — converge before PointLive — is asserted as a fact about
+// this pass rather than as a reading of the code.
+type capsRecorder struct {
+ opt, host string
+ calls []string
+ liveAt map[string]string // verb+unit -> LiveVersion at its FIRST call
+ active map[string]bool
+ enabled map[string]bool
+ fail map[string]error // "verb unit" -> error
+}
+
+func newCapsRecorder(opt, host string) *capsRecorder {
+ return &capsRecorder{
+ opt: opt, host: host,
+ liveAt: map[string]string{},
+ active: map[string]bool{},
+ enabled: map[string]bool{},
+ fail: map[string]error{},
+ }
+}
+
+func (r *capsRecorder) run(args ...string) error {
+ call := strings.Join(args, " ")
+ r.calls = append(r.calls, call)
+ if _, seen := r.liveAt[call]; !seen {
+ v, _ := LiveVersion(r.opt, r.host)
+ r.liveAt[call] = v
+ }
+ if err := r.fail[call]; err != nil {
+ return err
+ }
+ if len(args) >= 2 {
+ switch args[0] {
+ case "start", "restart":
+ r.active[args[1]] = true
+ case "stop":
+ r.active[args[1]] = false
+ case "enable":
+ r.enabled[args[1]] = true
+ }
+ }
+ return nil
+}
+
+func (r *capsRecorder) query(args ...string) bool {
+ if len(args) < 2 {
+ return false
+ }
+ switch args[0] {
+ case "is-active":
+ return r.active[args[1]]
+ case "is-enabled":
+ return r.enabled[args[1]]
+ }
+ return false
+}
+
+func (r *capsRecorder) called(call string) bool {
+ for _, c := range r.calls {
+ if c == call {
+ return true
+ }
+ }
+ return false
+}
+
+// capsTestOps wires every caps seam at a temp dir, so a Pass in this package
+// touches no systemd, no /etc, no /run and no /var/lib. Logf is left nil on
+// purpose: converge's own lines then go to os.Stdout with every other adopt
+// line, which is what withCapturedStdout asserts against.
+func capsTestOps(t *testing.T, r *capsRecorder) (caps.Ops, string) {
+ t.Helper()
+ etc := t.TempDir()
+ return caps.Ops{
+ EtcDir: etc,
+ StateRoot: t.TempDir(),
+ RunDir: t.TempDir(),
+ Systemctl: r.run,
+ Query: r.query,
+ EnsureUser: func(string) error { return nil },
+ }, etc
+}
+
+// addCapRoute adds a route with a backing and a granted capability column —
+// addRoute plus the two columns Task 7 reads.
+func addCapRoute(t *testing.T, reg *registry.Registry, host, app, channel, backing, grants string, hibernate, platform bool) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: app, Channel: channel,
+ Hibernate: hibernate, Backing: backing, Caps: grants, Platform: platform,
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", host, err)
+ }
+}
+
+// shipCapRelease is shipRelease with a capability block.
+func shipCapRelease(t *testing.T, s Store, acct, app, version string, set *caps.Set, body []byte) {
+ t.Helper()
+ m := Manifest{
+ App: app, Version: version, Kind: "binary",
+ Artifacts: map[string]Artifact{adoptPlatform: {}},
+ Capabilities: set,
+ }
+ if err := Ship(context.Background(), s, acct, m, map[string][]byte{adoptPlatform: body}); err != nil {
+ t.Fatalf("Ship %s/%s: %v", app, version, err)
+ }
+}
+
+func relaySet() *caps.Set {
+ return &caps.Set{
+ V: 1, Command: "relay", Daemon: true, RuntimeDir: "keymail",
+ Ports: []caps.Port{{Port: 25, Name: "smtp"}},
+ }
+}
+
+// TestAdoptConvergesCapabilitiesBeforePointLive is the ordering rule of spec
+// §4d, asserted where it can actually fail: the drop-ins are written and
+// daemon-reloaded while the live symlink still names the OLD version, and the
+// daemon's restart happens after it names the new one. Converge after
+// PointLive would leave a window where the new binary is live under the old
+// unit shape; converge without a reload would leave bytes systemd never read.
+func TestAdoptConvergesCapabilitiesBeforePointLive(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "keymail.dev"
+
+ // The box is up on v1, which declares nothing.
+ shipRelease(t, s, "bab", "keymail", "v1", adoptPlatform, []byte("old relay"))
+ if err := StageArtifact(opt, "bab", "v1", "keymail", []byte("old relay")); err != nil {
+ t.Fatal(err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", false, false)
+ if err := PointLive(opt, host, "bab", "v1", "keymail"); err != nil {
+ t.Fatal(err)
+ }
+
+ set := relaySet()
+ shipCapRelease(t, s, "bab", "keymail", "v2", set, []byte("new relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ var restarted []string
+ restart := func(h string) error { restarted = append(restarted, h); return nil }
+ if err := Pass(ctx, reg, s, pub, opt, restart, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ if v, _ := LiveVersion(opt, host); v != "v2" {
+ t.Fatalf("live = %q, want v2", v)
+ }
+ if v, seen := rec.liveAt["daemon-reload"]; !seen || v != "v1" {
+ t.Fatalf("daemon-reload observed live=%q (seen=%v), want v1 — the drop-ins must be loaded BEFORE the symlink moves", v, seen)
+ }
+
+ // The bytes are the renderer's, byte for byte — this test pins the
+ // wiring, not a second copy of the drop-in format.
+ g := caps.Grants{Ports: map[int]bool{25: true}}
+ svcPath, sockPath := caps.DropInPaths(etc, host)
+ wantSvc, err := caps.RenderService("keymail", set, g)
+ if err != nil {
+ t.Fatalf("RenderService: %v", err)
+ }
+ gotSvc, err := os.ReadFile(svcPath)
+ if err != nil || !bytes.Equal(gotSvc, wantSvc) {
+ t.Fatalf("service drop-in = %q (%v), want %q", gotSvc, err, wantSvc)
+ }
+ wantSock, err := caps.RenderSocket(set, g)
+ if err != nil {
+ t.Fatalf("RenderSocket: %v", err)
+ }
+ gotSock, err := os.ReadFile(sockPath)
+ if err != nil || !bytes.Equal(gotSock, wantSock) {
+ t.Fatalf("socket drop-in = %q (%v), want %q", gotSock, err, wantSock)
+ }
+
+ // A converged DAEMON's restart is caps.Restart, not the caller's
+ // try-restart callback — that is what makes its failure real enough to
+ // roll back (restartRoute swallows every error it sees).
+ if len(restarted) != 0 {
+ t.Fatalf("restart callback called for %v; a converged daemon's restart is caps.Restart's", restarted)
+ }
+ svcRestart := "restart " + caps.ServiceUnit(host)
+ if v, seen := rec.liveAt[svcRestart]; !seen || v != "v2" {
+ t.Fatalf("%q observed live=%q (seen=%v), want v2 — the daemon restarts onto the NEW binary", svcRestart, v, seen)
+ }
+}
+
+// TestRestartFailureRestoresBothTheSymlinkAndTheDropIns is the rollback that
+// keeps a bad build off a mail server: when the daemon will not come back on
+// the new version, the box must end the pass exactly where it started — old
+// symlink, old unit text, the version poisoned for the rest of the pass.
+// Restoring one without the other is the divergence this pairing exists to
+// prevent (bytes that say :465 under a binary that never asked for it).
+func TestRestartFailureRestoresBothTheSymlinkAndTheDropIns(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "a.example.com"
+
+ v1 := &caps.Set{V: 1, Command: "relay", Daemon: true, Ports: []caps.Port{{Port: 25}}}
+ v2 := &caps.Set{V: 1, Command: "relay", Daemon: true, Ports: []caps.Port{{Port: 25}, {Port: 465}}}
+ shipCapRelease(t, s, "bab", "keymail", "v1", v1, []byte("good relay"))
+ shipCapRelease(t, s, "bab", "keymail", "v2", v2, []byte("bad relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote v1: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25,port:465", false, false)
+ // b is exec-backed (materializes nothing) and exists only to prove the
+ // poison: it must never be moved onto the version a's restart rejected.
+ addCapRoute(t, reg, "b.example.com", "keymail", "canary/x", "", "", false, false)
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass v1: %v", err)
+ }
+ svcPath, sockPath := caps.DropInPaths(etc, host)
+ wantSvc, err := os.ReadFile(svcPath)
+ if err != nil {
+ t.Fatalf("read converged service drop-in: %v", err)
+ }
+ wantSock, err := os.ReadFile(sockPath)
+ if err != nil {
+ t.Fatalf("read converged socket drop-in: %v", err)
+ }
+ if !strings.Contains(string(wantSock), "ListenStream=25") || strings.Contains(string(wantSock), "465") {
+ t.Fatalf("v1 socket drop-in = %q, want :25 only", wantSock)
+ }
+
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ rec.fail["restart "+caps.ServiceUnit(host)] = errors.New("relay failed to start")
+
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass v2: %v", err)
+ }
+ })
+
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 — the symlink must be rolled back", v)
+ }
+ gotSvc, _ := os.ReadFile(svcPath)
+ gotSock, _ := os.ReadFile(sockPath)
+ if !bytes.Equal(gotSvc, wantSvc) {
+ t.Fatalf("service drop-in = %q, want v1's %q", gotSvc, wantSvc)
+ }
+ if !bytes.Equal(gotSock, wantSock) {
+ t.Fatalf("socket drop-in = %q, want v1's %q — a rolled-back version must not leave :465 bound", gotSock, wantSock)
+ }
+ // Bytes are not a binding: restoring the socket half has to re-run the
+ // socket sequence, or systemd keeps the listener the rolled-back version
+ // asked for.
+ if !rec.called("restart " + caps.SocketUnit(host)) {
+ t.Fatalf("calls = %v, want the socket unit re-bound during the restore", rec.calls)
+ }
+ if v, _ := LiveVersion(opt, "b.example.com"); v != "v1" {
+ t.Fatalf("b live = %q, want v1 — the failed version must be poisoned for the rest of the pass", v)
+ }
+ line := out.String()
+ if !strings.Contains(line, "rolled the symlink back") {
+ t.Fatalf("printed %q, want a line saying the symlink was rolled back", line)
+ }
+ if !strings.Contains(line, "capability drop-ins") {
+ t.Fatalf("printed %q, want a line saying the drop-ins were restored too", line)
+ }
+}
+
+// TestExecBackedRouteWithCapabilitiesMaterializesNothing: Backing == "" is
+// the whole existing fleet. A release declaring the full relay set must
+// change no unit text on such a route (its process is an exec child, not a
+// carlos-app@ unit) while adoption itself proceeds exactly as before. This
+// is also why no existing test in this package can reach the converge path.
+func TestExecBackedRouteWithCapabilitiesMaterializesNothing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "exec.example.com"
+
+ shipCapRelease(t, s, "bab", "keymail", "v1", relaySet(), []byte("relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", "", "port:25", false, false)
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ var restarted []string
+ if err := Pass(ctx, reg, s, pub, opt, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ assertEmptyDir(t, etc, "an exec-backed route must materialize no unit text")
+ if len(rec.calls) != 0 {
+ t.Fatalf("systemctl calls = %v, want none", rec.calls)
+ }
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 — adoption itself must proceed", v)
+ }
+ if len(restarted) != 1 || restarted[0] != host {
+ t.Fatalf("restarted = %v, want the ordinary callback for %s", restarted, host)
+ }
+}
+
+// TestPlatformAliasRouteMaterializesNothing: a platform alias is a second
+// name for one process. A second carlos-app@ unit claiming :25 would break
+// the real one, so an alias materializes nothing — and, being outside
+// adoptable(), is not adopted at all.
+func TestPlatformAliasRouteMaterializesNothing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "keymail.bab.apps.example.com"
+
+ shipCapRelease(t, s, "bab", "keymail", "v1", relaySet(), []byte("relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", false, true)
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ assertEmptyDir(t, etc, "a platform alias must materialize no unit text")
+ if len(rec.calls) != 0 {
+ t.Fatalf("systemctl calls = %v, want none", rec.calls)
+ }
+ if v, _ := LiveVersion(opt, host); v != "" {
+ t.Fatalf("live = %q, want unset — a platform alias is not adopted at all", v)
+ }
+}
+
+// TestPassWithoutWithCapabilitiesIsUnchanged is the byte-identical-to-before
+// assertion, and the reason this task could land on its own: a unit-backed
+// route with a full capability manifest, adopted by a Pass that was never
+// given the option, writes no unit text anywhere and adopts exactly as it
+// always did.
+func TestPassWithoutWithCapabilitiesIsUnchanged(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "unit.example.com"
+ etc := t.TempDir() // stands in for /etc/systemd/system; nothing may reach it
+
+ shipCapRelease(t, s, "bab", "keymail", "v1", relaySet(), []byte("relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", false, false)
+
+ var restarted []string
+ if err := Pass(ctx, reg, s, pub, opt, func(h string) error {
+ restarted = append(restarted, h)
+ return nil
+ }); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ assertEmptyDir(t, etc, "a Pass without WithCapabilities must write no unit text")
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1", v)
+ }
+ if len(restarted) != 1 || restarted[0] != host {
+ t.Fatalf("restarted = %v, want the ordinary callback for %s", restarted, host)
+ }
+}
+
+// TestUnconvergedRepointOfACapabilityRouteLogsTheWindow pins the 2026-08-03
+// ruling on the canary fast loop. That loop runs inside carlos-edge.service
+// (ProtectSystem=strict) and CANNOT write /etc/systemd/system, so it repoints
+// a capability-bearing canary and leaves the unit shape to the hourly root
+// pass — a window of up to an hour where a new binary runs under the previous
+// version's drop-ins. The window is accepted; being silent about it is not.
+//
+// The signal is deliberately the cheapest honest one: "the new version's
+// manifest declares capabilities at all", not "the new set differs from what
+// is materialized" — the difference test needs the grants and a render of
+// both sides, and this line exists to mark the window, not to diff it.
+func TestUnconvergedRepointOfACapabilityRouteLogsTheWindow(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+
+ shipCapRelease(t, s, "bab", "keymail", "v1", relaySet(), []byte("relay"))
+ shipRelease(t, s, "bab", "plain", "v1", adoptPlatform, []byte("plain"))
+ for _, app := range []string{"keymail", "plain"} {
+ if err := Promote(ctx, s, priv, "bab", app, "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote %s: %v", app, err)
+ }
+ }
+ addCapRoute(t, reg, "caps.example.com", "keymail", "canary/x", registry.BackingUnit, "port:25", false, false)
+ // A unit-backed route whose release declares NOTHING must stay quiet —
+ // otherwise the line fires on every ordinary tenant and nobody reads it.
+ addCapRoute(t, reg, "plain.example.com", "plain", "canary/x", registry.BackingUnit, "", false, false)
+
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := PassFilter(ctx, reg, s, pub, opt, func(string) error { return nil }, isCanaryTestRoute); err != nil {
+ t.Fatalf("PassFilter: %v", err)
+ }
+ })
+
+ line := out.String()
+ if !strings.Contains(line, "caps.example.com: v1 declares capabilities") {
+ t.Fatalf("printed %q, want one line naming the unconverged capability repoint", line)
+ }
+ if !strings.Contains(line, "carlos-adopt.service") {
+ t.Fatalf("printed %q, want the line to name what closes the window", line)
+ }
+ if strings.Contains(line, "plain.example.com: v1 declares") {
+ t.Fatalf("printed %q, want silence for a route whose release declares nothing", line)
+ }
+}
+
+// TestHibernatingUnitRouteConvergesWithoutStarting: a hibernating route
+// reaches adoptHost with restart == nil. Its drop-ins are unit TEXT, not a
+// wake, so they converge — but nothing may start the service, or an hourly
+// timer would defeat hibernation.
+func TestHibernatingUnitRouteConvergesWithoutStarting(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "sleepy.example.com"
+
+ // No daemon: daemon + hibernate is a contradiction converge refuses.
+ set := &caps.Set{V: 1, Command: "web", Ports: []caps.Port{{Port: 25}}}
+ shipCapRelease(t, s, "bab", "keymail", "v1", set, []byte("relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", true, false)
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error {
+ t.Fatal("a hibernating host must never reach the restart callback")
+ return nil
+ }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+
+ svcPath, _ := caps.DropInPaths(etc, host)
+ if _, err := os.Stat(svcPath); err != nil {
+ t.Fatalf("stat %s: %v — a hibernating route's drop-ins still converge", svcPath, err)
+ }
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1", v)
+ }
+ for _, c := range rec.calls {
+ if strings.HasSuffix(c, caps.ServiceUnit(host)) {
+ t.Fatalf("calls = %v, want no verb against the SERVICE unit — a drop-in is not a wake", rec.calls)
+ }
+ }
+}
+
+// TestConvergeRefusalRefusesTheVersion: a refusal from converge falls exactly
+// where an ensureStaged failure falls — nothing pointed live, the box keeps
+// serving what it has, one line naming the operator's fix. Pointing live
+// anyway would run a new binary against a unit shape the platform has just
+// declined to write.
+//
+// The refusal used here is spec §4e's daemon + hibernate contradiction,
+// because it is refused with nothing written at all.
+func TestConvergeRefusalRefusesTheVersion(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "sleepy.example.com"
+
+ shipRelease(t, s, "bab", "keymail", "v1", adoptPlatform, []byte("old"))
+ if err := StageArtifact(opt, "bab", "v1", "keymail", []byte("old")); err != nil {
+ t.Fatal(err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", true, false)
+ if err := PointLive(opt, host, "bab", "v1", "keymail"); err != nil {
+ t.Fatal(err)
+ }
+ shipCapRelease(t, s, "bab", "keymail", "v2", relaySet(), []byte("new"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 — a refused converge must not point live", v)
+ }
+ assertEmptyDir(t, etc, "a refusal writes nothing")
+ if len(rec.calls) != 0 {
+ t.Fatalf("systemctl calls = %v, want none", rec.calls)
+ }
+ line := out.String()
+ if !strings.Contains(line, "not pointed live") {
+ t.Fatalf("printed %q, want a line saying the version was not pointed live", line)
+ }
+ if !strings.Contains(line, "-hibernate=false") {
+ t.Fatalf("printed %q, want converge's own line naming the operator's fix", line)
+ }
+}
+
+// TestArmedPassRefusesAVersionWhoseManifestItCouldNotObtain is the second of
+// Task 7's two pins, in the one place it can bite: the binary is staged (so
+// ensureStaged succeeds) but its manifest is obtainable neither from the
+// sibling nor from the bucket. caps.Plan's nil Set means "leave the drop-ins
+// alone" — correct for a pass surveying the box, and exactly wrong here,
+// where it would point a new binary at the previous version's unit shape and
+// say nothing. So an armed pass refuses the version.
+//
+// The second half is the compatibility half: the SAME situation on a pass
+// with no WithCapabilities adopts normally, which is what keeps every
+// existing caller byte-identical.
+func TestArmedPassRefusesAVersionWhoseManifestItCouldNotObtain(t *testing.T) {
+ dir := t.TempDir()
+ s := NewDirStore(dir)
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "keymail.dev"
+
+ shipRelease(t, s, "bab", "keymail", "v1", adoptPlatform, []byte("old"))
+ if err := StageArtifact(opt, "bab", "v1", "keymail", []byte("old")); err != nil {
+ t.Fatal(err)
+ }
+ if err := StageManifest(opt, "bab", "v1", "keymail", []byte(`{"schema":1,"app":"keymail","version":"v1","kind":"binary"}`)); err != nil {
+ t.Fatal(err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", false, false)
+ if err := PointLive(opt, host, "bab", "v1", "keymail"); err != nil {
+ t.Fatal(err)
+ }
+
+ // v2's binary is on disk with no sibling, and its manifest object is then
+ // removed from the store: the back-fill fetch cannot answer either.
+ shipCapRelease(t, s, "bab", "keymail", "v2", relaySet(), []byte("new"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ if err := StageArtifact(opt, "bab", "v2", "keymail", []byte("new")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(filepath.Join(dir, filepath.FromSlash(ManifestKey("bab", "keymail", "v2")))); err != nil {
+ t.Fatalf("remove v2 manifest: %v", err)
+ }
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("armed Pass: %v", err)
+ }
+ })
+ if v, _ := LiveVersion(opt, host); v != "v1" {
+ t.Fatalf("live = %q, want v1 — an unknown unit shape must refuse the version", v)
+ }
+ assertEmptyDir(t, etc, "a refusal writes nothing")
+ if line := out.String(); !strings.Contains(line, "not pointed live") {
+ t.Fatalf("printed %q, want a line saying the version was not pointed live", line)
+ }
+
+ // Unarmed, the identical situation adopts exactly as it always did.
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
+ t.Fatalf("unarmed Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, host); v != "v2" {
+ t.Fatalf("live = %q after an unarmed pass, want v2 — capabilities off means behaviour unchanged", v)
+ }
+}
+
+// assertEmptyDir fails unless dir has no entries at all.
+func assertEmptyDir(t *testing.T, dir, why string) {
+ t.Helper()
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("read %s: %v", dir, err)
+ }
+ if len(ents) != 0 {
+ var names []string
+ for _, e := range ents {
+ names = append(names, e.Name())
+ }
+ t.Fatalf("%s contains %v — %s", dir, names, why)
+ }
+}
+
+// TestAdoptRemovesDropInsWhenTheNewVersionDeclaresNothing is Task 7's second
+// pin, and the mutant it kills is a one-word one: Plan{Set: m.Capabilities}.
+// Manifest.Capabilities is a nil pointer for BOTH a legacy manifest and a
+// fetch that never happened, and caps.Plan reads a nil Set as "leave the
+// drop-ins exactly as they are". Passing it through verbatim would mean a
+// capability DROPPED from a manifest is never removed — a relay that stopped
+// asking for :25 would keep binding it, which is the exact opposite of spec
+// §4d's removal semantics. A manifest that RESOLVED always carries a Set,
+// empty when it declares nothing.
+func TestAdoptRemovesDropInsWhenTheNewVersionDeclaresNothing(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ reg := testRegistry(t)
+ host := "keymail.dev"
+
+ shipCapRelease(t, s, "bab", "keymail", "v1", relaySet(), []byte("relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote v1: %v", err)
+ }
+ addCapRoute(t, reg, host, "keymail", "canary/x", registry.BackingUnit, "port:25", false, false)
+
+ rec := newCapsRecorder(opt, host)
+ o, etc := capsTestOps(t, rec)
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass v1: %v", err)
+ }
+ svcPath, sockPath := caps.DropInPaths(etc, host)
+ for _, p := range []string{svcPath, sockPath} {
+ if _, err := os.Stat(p); err != nil {
+ t.Fatalf("stat %s after v1: %v", p, err)
+ }
+ }
+
+ // v2 declares nothing at all — a plain manifest, exactly like every
+ // release in every production bucket today.
+ shipRelease(t, s, "bab", "keymail", "v2", adoptPlatform, []byte("plain relay"))
+ if err := Promote(ctx, s, priv, "bab", "keymail", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, WithCapabilities(o)); err != nil {
+ t.Fatalf("Pass v2: %v", err)
+ }
+
+ if v, _ := LiveVersion(opt, host); v != "v2" {
+ t.Fatalf("live = %q, want v2", v)
+ }
+ for _, p := range []string{svcPath, sockPath} {
+ if _, err := os.Stat(p); !os.IsNotExist(err) {
+ b, _ := os.ReadFile(p)
+ t.Fatalf("%s still exists after a version that declares nothing (%q) — a dropped capability must be removed", p, b)
+ }
+ }
+ // Removing the socket half has to reach systemd, or the listener the
+ // removed file asked for is still bound.
+ if !rec.called("restart " + caps.SocketUnit(host)) {
+ t.Fatalf("calls = %v, want the socket unit re-bound when its drop-in was removed", rec.calls)
+ }
+}