carlos / platform Public

host: unit-backed apps join config fast convergence (issue #44)

PR #45 gave exec-backed instances seconds-not-hours config convergence and
left carlos-app@ routes on the hourly carlos-adopt.timer floor, because the
agent cannot write /etc/carlos/apps or restart units. It can ask, though.

genPollPass now polls the UNION of appconfig.ExecConfigApps and the new
appconfig.UnitConfigApps — one generation read per app, however its routes
are backed — and reports two bools. execChanged runs SyncExec exactly as
before; unitChanged touches <dataDir>/config-sync-requested, which the new
carlos-adopt.path relays into a root carlos-adopt.service run that already
owns the unit half (appconfig.Sync + try-restart). No new privileged code:
this process still writes nothing under /etc and restarts no unit.

bootstrap embeds and arms the path unit the same way it arms timers — a
path unit that is enabled but never started watches nothing until the next
boot, which is the carlos-adopt.timer bug in a new suit.

Two consequences, recorded in the spec as accepted: a PathChanged event
landing while the adopt service is mid-run can be lost (the next write
re-touches, the timer floor delivers within the hour), and the loop's
CARLOS_SECRETS_KEY gate now covers the unit half too (both units read the
same host.env in practice).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 782895033abaa5b4324b493a1d181bd516eee71b parent 78db5b0
9 files changed, +428 −73
  • appconfig_cli.go +9 −5
  • docs/superpowers/specs/2026-08-04-config-console-api-design.md +33 −5
  • internal/appconfig/sync.go +34 −0
  • internal/appconfig/syncexec_test.go +61 −0
  • internal/host/agent.go +95 −25
  • internal/host/agent_test.go +149 −18
  • internal/host/bootstrap.go +23 −18
  • internal/host/host_test.go +8 −2
  • internal/host/units/carlos-adopt.path +16 −0
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 784f8c240..de2bcb704 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -44,8 +44,9 @@ when set.
sync touches apps/<acct>/<app>/config-gen.json — the agent's fast poll loop
(host package) notices within ~3s and re-renders exec-backed instances;
-unit-backed routes still wait for the next root adopt pass. It never
-restarts a box's shared edge service, only the one app's own delivery.
+unit-backed routes converge in seconds too, via a root adopt pass the same
+loop requests (the agent cannot write /etc or restart units itself). It
+never restarts a box's shared edge service, only the one app's own delivery.
Every command above also works through a logged-in console
(carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are
@@ -448,8 +449,11 @@ func runEnvEnvironments(args []string) {
// config write that needs to reach a running instance faster than
// configSyncLoop's hourly floor: it touches apps/<acct>/<app>/config-gen.json
// (appconfig.GenKey), which the agent's configGenLoop polls every ~3s and
-// treats a moved etag as "run SyncExec now". It never restarts a box's
-// shared edge service and never targets a bundle directly — the generation
+// treats a moved etag as "run SyncExec now" for the app's exec-backed
+// instances and "ask the root adopt pass to run now" (carlos-adopt.path) for
+// its unit-backed ones, so both shapes converge in seconds rather than one
+// waiting out the hour. It never restarts a box's shared edge service and
+// never targets a bundle directly — the generation
// object covers every bundle an app has (default plus every environment) in
// one touch, matching TouchGeneration's own "app granularity, not
// per-bundle" design (generation.go).
@@ -476,7 +480,7 @@ func runEnvSync(args []string) {
log.Fatal(err)
}
fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; "+
- "unit-backed routes on the next root adopt pass\n", *app)
+ "unit-backed routes within seconds too, on the root adopt pass the agent requests\n", *app)
}
const secretsUsage = `usage: carlos secrets <command>
diff --git a/docs/superpowers/specs/2026-08-04-config-console-api-design.md b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
index b8e1fe040..90dc09cfd 100644
--- a/docs/superpowers/specs/2026-08-04-config-console-api-design.md
+++ b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
@@ -161,8 +161,30 @@ silently (boot must not re-bounce the world; `configSyncLoop`'s ~1min first
pass owns boot convergence). Any etag change — including absent→present —
triggers ONE `SyncExec` pass for the tick, not one per app. Absent stays
absent silently. Hourly `configSyncLoop` stays as the floor (old CLIs,
-missed ticks, #27's boot-retry gap). Unit-backed routes keep the root adopt
-path — the agent cannot restart units, and this spec does not try.
+missed ticks, #27's boot-retry gap).
+
+Unit-backed apps join the same fast path (amended 2026-08-04, approved).
+The poll set is the UNION of `appconfig.ExecConfigApps` and the new
+`appconfig.UnitConfigApps`, read once per app (one generation object per
+app however its routes are backed), and `genPollPass` reports two bools:
+`execChanged` runs `SyncExec` as before; `unitChanged` touches
+`<dataDir>/config-sync-requested`, which a new `carlos-adopt.path`
+(`PathChanged=/data/config-sync-requested` → `Unit=carlos-adopt.service`)
+relays into the ROOT pass that already owns the unit half — `appconfig.Sync`
+into `/etc/carlos/apps` plus try-restart. No new privileged code: the agent
+still writes nothing under `/etc` and restarts no unit; it only asks. An app
+holding both route shapes sets both halves off the one read.
+
+Two consequences, both accepted:
+
+- A `PathChanged` event landing while `carlos-adopt.service` is mid-run can
+ be lost (systemd does not queue it). The next config write re-touches the
+ marker, the exec half converged in-process regardless, and
+ `carlos-adopt.timer` delivers within the hour.
+- The loop stays gated on `CARLOS_SECRETS_KEY`, so a box whose AGENT lacks
+ the key gets no fast path for its unit apps either, even though the root
+ pass that would run holds its own key. In practice both units read the
+ same `/etc/carlos/host.env`; the timer floor stands regardless.
Cardinality note: unlike canary channels this polls every exec-config app,
but one small GET per app per 3s against the store the canary loop already
@@ -171,7 +193,9 @@ the read is the cheapest object in the bucket.
## §5 Out of scope (recorded, deliberate)
-- Unit-backed route fast convergence (root adopt path owns it).
+- ~~Unit-backed route fast convergence (root adopt path owns it).~~ Closed
+ by the §4 amendment above: the root adopt path still owns the delivery,
+ the agent just asks it to run now.
- `#27` (configSyncLoop boot retry) — narrowed by this spec (the gen loop
re-arms convergence on the next write) but not fixed by it.
- Browser console config UI — CLI-only surface today, same as ledgers.
@@ -192,7 +216,11 @@ the read is the cheapest object in the bucket.
order (env → console → fatal).
- agent: the per-tick decision extracted as a pure-ish helper
(`genPollPass`) and tested (first-sight silent, change triggers, absent
- stays silent, one sync per tick); the loop itself stays untested by
- convention like adoptLoop/statusLoop.
+ stays silent, one sync per tick; and per half — unit movement requests the
+ root pass only, exec movement syncs only, a both-shapes app sets both off
+ ONE read, cache pruning covers the union); the loop itself stays untested
+ by convention like adoptLoop/statusLoop. `UnitConfigApps` mirrors
+ `ExecConfigApps`' own test; the bootstrap unit-list and arm-the-timers
+ tests cover `carlos-adopt.path` being installed, enabled AND started.
- e2e (auth_e2e_test.go pattern): login → env set → list → secrets set
(console-served pubkey) → list names → unset → sync → gen object moved.
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index 8a471e7e0..083a29443 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -285,6 +285,40 @@ func syncable(rt registry.Route) bool {
(rt.Status == "" || rt.Status == "ready")
}
+// UnitConfigApps lists the distinct (account, app) pairs having at least
+// one unit-backed syncable route on this box — the apps whose config the
+// ROOT adopt pass delivers (Sync + try-restart), which the agent itself
+// cannot. The fast loop uses it to decide when to request a root pass
+// (see configGenLoop): same dedupe-and-sort shape as ExecConfigApps, and
+// the two may overlap — an app can have both route shapes at once.
+//
+// Exported from THIS file, beside the predicate Sync itself gates on, for
+// the reason ExecConfigApps gives for living beside its own: an app the
+// loop watched but this file's sweep skipped would burn reads forever, and
+// one it didn't watch would silently fall back to the hourly timer.
+func UnitConfigApps(routes []registry.Route) [][2]string {
+ seen := map[[2]string]bool{}
+ var out [][2]string
+ for _, rt := range routes {
+ if !syncable(rt) || rt.Backing != registry.BackingUnit {
+ continue
+ }
+ k := [2]string{release.RouteAccount(rt), rt.App}
+ if seen[k] {
+ continue
+ }
+ seen[k] = true
+ out = append(out, k)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i][0] != out[j][0] {
+ return out[i][0] < out[j][0]
+ }
+ return out[i][1] < out[j][1]
+ })
+ return out
+}
+
// syncHost converges one route's env file. It is called only with a manifest
// that really resolved — the object (default bundle, named environment, or
// both) exists in the bucket — so an empty manifest here means "every var
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 9db2fda22..53d5519d9 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -1567,3 +1567,64 @@ func TestExecConfigAppsNoRoutes(t *testing.T) {
t.Fatalf("ExecConfigApps(nil) = %v, want empty", got)
}
}
+
+// TestUnitConfigApps is TestExecConfigApps' mirror for the other half of the
+// fast loop's poll set: the apps whose config only the ROOT adopt pass can
+// deliver. Same fixture shapes, opposite inclusion on Backing — and the
+// both-shapes app below is the case that matters most, because an app can
+// hold a socket-exec route and a unit route at once and must appear in BOTH
+// functions' outputs (the loop then syncs exec in-process AND asks for a
+// root pass off one generation read).
+func TestUnitConfigApps(t *testing.T) {
+ routes := []registry.Route{
+ // In: the carlos-app@ unit shape the root adopt pass renders.
+ {Host: "unit.example.com", Kind: "instance", App: "unitapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/unit.sock", Backing: registry.BackingUnit},
+ // In, and deduped: one app, two unit routes, one generation object.
+ {Host: "unit2.example.com", Kind: "instance", App: "unitapp", Account: "bab",
+ Status: "", Socket: "/run/carlos/unit2.sock", Backing: registry.BackingUnit},
+ // In under release.OpsAccount, exactly as ExecConfigApps resolves it.
+ {Host: "legacy.example.com", Kind: "instance", App: "kass", Account: "",
+ Status: "ready", Socket: "/run/carlos/legacy.sock", Backing: registry.BackingUnit},
+ // In BOTH sets: this app's unit route is here, its exec route is in
+ // ExecConfigApps (asserted below).
+ {Host: "mixed-unit.example.com", Kind: "instance", App: "mixed", Account: "zed",
+ Status: "ready", Socket: "/run/carlos/mixed-unit.sock", Backing: registry.BackingUnit},
+ {Host: "mixed-exec.example.com", Kind: "instance", App: "mixed", Account: "zed",
+ Status: "ready", Socket: "/run/carlos/mixed-exec.sock"},
+ // Out: exec-backed only — the agent delivers this one itself.
+ {Host: "sock.example.com", Kind: "instance", App: "eleven", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/sock.sock"},
+ // Out: platform routes are not app config.
+ {Host: "plat.example.com", Kind: "instance", App: "platapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/plat.sock", Platform: true,
+ Backing: registry.BackingUnit},
+ // Out: a draining route is on its way out; nothing to converge.
+ {Host: "drain.example.com", Kind: "instance", App: "drainapp", Account: "bab",
+ Status: "draining", Socket: "/run/carlos/drain.sock", Backing: registry.BackingUnit},
+ // Out: no app means no config manifest to key on.
+ {Host: "noapp.example.com", Kind: "instance", App: "", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/noapp.sock", Backing: registry.BackingUnit},
+ // Out: not an instance route at all.
+ {Host: "proxy.example.com", Kind: "proxy", App: "proxyapp", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/proxy.sock", Backing: registry.BackingUnit},
+ }
+ got := UnitConfigApps(routes)
+ want := [][2]string{
+ {"bab", "kass"},
+ {"bab", "unitapp"},
+ {"zed", "mixed"},
+ }
+ if !slices.Equal(got, want) {
+ t.Fatalf("UnitConfigApps = %v, want %v (distinct, sorted by account then app)", got, want)
+ }
+ if gotExec := ExecConfigApps(routes); !slices.Contains(gotExec, [2]string{"zed", "mixed"}) {
+ t.Fatalf("ExecConfigApps = %v, want it to also hold {zed mixed} — an app with both route shapes is in both sets", gotExec)
+ }
+}
+
+func TestUnitConfigAppsNoRoutes(t *testing.T) {
+ if got := UnitConfigApps(nil); len(got) != 0 {
+ t.Fatalf("UnitConfigApps(nil) = %v, want empty", got)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index bfef5ab14..41133a5ac 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -314,9 +314,10 @@ func bounce(svc *activator.Service) func(string) error {
// DirStore's is hex).
const genAbsent = "\x00absent"
-// genPollPass is one tick of configGenLoop, split out for tests: read every
-// exec-config app's generation etag and report whether ANY moved since the
-// cache was built.
+// genPollPass is one tick of configGenLoop, split out for tests: read the
+// generation etag of every app the box can converge — exec-config apps and
+// unit-backed apps alike — and report which HALVES moved since the cache
+// was built.
//
// Semantics, each of which is a deliberate choice:
//
@@ -333,9 +334,17 @@ const genAbsent = "\x00absent"
// created and overwritten, never deleted), but it's treated as a change
// too — one extra SyncExec pass is cheap, and pretending we didn't see
// it would be a lie about the only thing this function reports.
-// - ONE bool for the whole pass, however many apps moved: the caller runs
-// one SyncExec, which resolves every route itself. Per-app syncs would
-// multiply bucket reads to deliver exactly the same files.
+// - ONE bool PER HALF for the whole pass, however many apps moved: the
+// caller runs one SyncExec (which resolves every route itself) and asks
+// for at most one root adopt pass (which sweeps every unit route
+// itself). Per-app syncs would multiply bucket reads to deliver exactly
+// the same files, and per-app root requests would ask the same pass to
+// run several times over.
+// - The halves are independent: an app whose routes are all exec-backed
+// sets execChanged alone, an all-unit app sets unitChanged alone, and an
+// app holding both shapes at once sets both — off the SAME single read,
+// because its whole config lives in one generation object however its
+// routes are backed.
// - Apps that vanish from the route set lose their cache entries, so a
// re-added app is a first sight again rather than a comparison against
// an etag remembered from before it left (a route being re-added gets
@@ -349,11 +358,28 @@ const genAbsent = "\x00absent"
// The cache is mutated in place — it lives across ticks in the loop, which
// is the whole point (there is nowhere else to remember what "changed"
// means).
-func genPollPass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (bool, []error) {
- apps := appconfig.ExecConfigApps(routes)
+func genPollPass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (execChanged, unitChanged bool, readErrs []error) {
+ execApps := appconfig.ExecConfigApps(routes)
+ unitApps := appconfig.UnitConfigApps(routes)
+ // The poll set is the UNION, read once per app: the two sets overlap
+ // whenever an app has both route shapes, and its config is one object
+ // either way. inExec/inUnit then say which half a movement belongs to.
+ inExec := make(map[[2]string]bool, len(execApps))
+ for _, k := range execApps {
+ inExec[k] = true
+ }
+ inUnit := make(map[[2]string]bool, len(unitApps))
+ for _, k := range unitApps {
+ inUnit[k] = true
+ }
+ apps := make([][2]string, 0, len(execApps)+len(unitApps))
+ apps = append(apps, execApps...)
+ for _, k := range unitApps {
+ if !inExec[k] {
+ apps = append(apps, k)
+ }
+ }
live := make(map[[2]string]bool, len(apps))
- changed := false
- var readErrs []error
for _, k := range apps {
live[k] = true
_, etag, err := store.Get(ctx, appconfig.GenKey(k[0], k[1]))
@@ -367,7 +393,8 @@ func genPollPass(ctx context.Context, routes []registry.Route, store release.Sto
prev, seen := cache[k]
cache[k] = etag
if seen && prev != etag {
- changed = true
+ execChanged = execChanged || inExec[k]
+ unitChanged = unitChanged || inUnit[k]
}
}
for k := range cache {
@@ -375,27 +402,53 @@ func genPollPass(ctx context.Context, routes []registry.Route, store release.Sto
delete(cache, k)
}
}
- return changed, readErrs
+ return execChanged, unitChanged, readErrs
+}
+
+// configSyncMarker is the file configGenLoop touches to ask the ROOT adopt
+// pass to run now: carlos-adopt.path watches it (PathChanged) and starts
+// carlos-adopt.service, which owns the unit half of config delivery
+// (/etc/carlos/apps + try-restart) that this unprivileged process cannot.
+// It lives in the data dir, deliberately outside the *.config.env namespace
+// reapConfigEnv sweeps — the reaper matches that suffix alone and must go
+// on ignoring this.
+const configSyncMarker = "config-sync-requested"
+
+// requestRootSync touches the marker. The content is a timestamp only so an
+// operator reading /data can see when the last request went out; systemd
+// watches the path, not the bytes. Errors are the caller's to log and
+// swallow: a request that cannot be written costs latency, not correctness,
+// because carlos-adopt.timer's hourly pass is the floor underneath it.
+func requestRootSync(dd string) error {
+ return os.WriteFile(filepath.Join(dd, configSyncMarker),
+ []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644)
}
// configGenLoop is canaryAdoptLoop's shape applied to issue #44: a config
// set should converge like a canary adopts — seconds, not the next hour.
-// Every ~3s it reads one small object per exec-config app (the generation
-// object every config write touches) and, on any movement, runs ONE
-// SyncExec pass for the whole tick. Spurious movement costs a pass that
-// finds nothing to write: SyncExec bounces only routes whose rendered file
+// Every ~3s it reads one small object per config-carrying app (the
+// generation object every config write touches) and, on movement, delivers
+// the halves it can: ONE SyncExec pass for the whole tick for exec-backed
+// instances, and one touch of the root-sync marker for unit-backed ones.
+// Spurious movement costs a pass that finds nothing to write: SyncExec (and
+// the root pass's appconfig.Sync) bounces only routes whose rendered file
// actually changed, so a redundant touch can never cost a restart.
//
// Gated on CARLOS_SECRETS_KEY exactly as configSyncLoop is, for a reason
// beyond convention: without the key SyncExec cannot render an app's
// environment at all, so this loop would poll the bucket every 3 seconds
// forever to signal no one. openDeployStore failing is the same story with
-// nothing to read.
+// nothing to read. The gate covers the unit half too, which is an accepted
+// asymmetry rather than a considered one: a box whose agent lacks the key
+// gets no fast path for its unit apps either, even though the root pass
+// that would run holds its own key. In practice both units read the same
+// /etc/carlos/host.env, so the case is theoretical — and the hourly timer
+// floor stands regardless.
//
// Cardinality: unlike canaryAdoptLoop, which is bounded to the deliberately
-// rare canary/* routes, this watches EVERY exec-config app on the box. That
-// is accepted knowingly — one GET of the smallest object in the bucket per
-// app per 3s, against the store canaryAdoptLoop already hits every 2s. What
+// rare canary/* routes, this watches EVERY config-carrying app on the box.
+// That is accepted knowingly — one GET of the smallest object in the bucket
+// per app per 3s, against the store canaryAdoptLoop already hits every 2s. What
// keeps it bounded is that the pass reads generation objects only: no
// manifests, no pointers, no per-environment resolution. All of that
// happens inside the SyncExec a real change triggers.
@@ -407,8 +460,13 @@ func genPollPass(ctx context.Context, routes []registry.Route, store release.Sto
// convergence belongs to configSyncLoop's ~1min first pass, and a box
// coming up must not bounce every instance it has because it has never
// seen their etags before.
-// - No unit-backed routes. This process cannot restart units (see
-// RunAdopt); the root adopt path keeps that half, at its own cadence.
+// - No unit-backed rendering or restarting. This process still writes
+// nothing under /etc and restarts no unit (see RunAdopt): a unit app's
+// movement only touches <dataDir>/config-sync-requested, which
+// carlos-adopt.path relays into a root carlos-adopt.service run that
+// does both. The relay can drop an event that lands while that service
+// is mid-run — accepted: the next config write re-touches the marker,
+// and the timer floor delivers within the hour regardless.
// - No hourly floor replaced. configSyncLoop stays exactly as it was: it
// catches apps written by older CLIs (no generation object at all),
// ticks this loop missed, and the boot-retry gap of issue #27.
@@ -435,7 +493,7 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) {
// One cache for the life of the process: what "changed" means is
// exactly "differs from the last tick that could read it".
cache := map[[2]string]string{}
- log.Printf("agent: config generation loop armed (3s + jitter, exec-config apps)")
+ log.Printf("agent: config generation loop armed (3s + jitter, exec-config and unit-backed apps)")
for {
time.Sleep(3*time.Second + rand.N(time.Second))
routes, err := reg.List()
@@ -447,7 +505,7 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) {
log.Printf("agent: config generation poll: list routes: %v", err)
continue
}
- changed, readErrs := genPollPass(ctx, routes, store, cache)
+ execChanged, unitChanged, readErrs := genPollPass(ctx, routes, store, cache)
if len(readErrs) > 0 {
// ONE line per pass, never one per app: a bucket having a bad
// minute would otherwise flood the journal at 20 lines a minute
@@ -455,7 +513,19 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) {
// the cause; they are the same cause in practice.
log.Printf("agent: config generation poll: %d apps unreadable: %v", len(readErrs), readErrs[0])
}
- if !changed {
+ if unitChanged {
+ // Requested first and never fatal: the write is instant while
+ // syncExecSerialized can block on a serialized pass, so the root
+ // run gets to start alongside it rather than behind it. A failed
+ // marker write costs those apps the wait until
+ // carlos-adopt.timer's next hourly pass — never correctness.
+ if err := requestRootSync(dd); err != nil {
+ log.Printf("agent: request root config sync: %v", err)
+ } else {
+ log.Printf("agent: config changed for unit-backed app(s), requested root adopt pass")
+ }
+ }
+ if !execChanged {
continue
}
if err := syncExecSerialized(ctx, reg, store, priv, dd, svc); err != nil {
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index d76f88694..3bddab9a9 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -31,6 +31,32 @@ func execRoute(host, acct, app string) registry.Route {
}
}
+// unitRoute is the carlos-app@ shape appconfig.UnitConfigApps accepts: the
+// same syncable instance, backed by a systemd unit whose env file only the
+// root adopt pass can write. The agent's half of a change to one of these
+// is the marker file, never a render.
+func unitRoute(host, acct, app string) registry.Route {
+ rt := execRoute(host, acct, app)
+ rt.Backing = registry.BackingUnit
+ return rt
+}
+
+// countingStore records how many times each key was read, so a test can
+// pin that an app carrying BOTH route shapes still costs ONE generation
+// read per tick — the whole economy of the poll set being a union.
+type countingStore struct {
+ release.Store
+ gets map[string]int
+}
+
+func (c *countingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if c.gets == nil {
+ c.gets = map[string]int{}
+ }
+ c.gets[key]++
+ return c.Store.Get(ctx, key)
+}
+
// genEtag reads the app's generation etag straight from the store, so a
// test can assert the cache holds what the bucket actually reports rather
// than a value the test made up.
@@ -74,8 +100,8 @@ func TestGenPollPassFirstSightIsSilent(t *testing.T) {
routes := []registry.Route{execRoute("a.example.com", "bab", "eleven")}
cache := map[[2]string]string{}
- changed, errs := genPollPass(ctx, routes, s, cache)
- if changed {
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if execChanged || unitChanged {
t.Fatal("first sight of an app must not trigger a sync (boot would re-bounce the world)")
}
if len(errs) != 0 {
@@ -94,8 +120,8 @@ func TestGenPollPassUnchangedIsSilent(t *testing.T) {
cache := map[[2]string]string{}
genPollPass(ctx, routes, s, cache)
- changed, errs := genPollPass(ctx, routes, s, cache)
- if changed {
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if execChanged || unitChanged {
t.Fatal("an unmoved generation etag must not trigger a sync")
}
if len(errs) != 0 {
@@ -111,10 +137,13 @@ func TestGenPollPassTouchedGenerationTriggersSync(t *testing.T) {
genPollPass(ctx, routes, s, cache)
touch(t, ctx, s, "bab", "eleven")
- changed, errs := genPollPass(ctx, routes, s, cache)
- if !changed {
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if !execChanged {
t.Fatal("a moved generation etag must trigger a sync")
}
+ if unitChanged {
+ t.Fatal("an exec-only app must never ask for a root adopt pass")
+ }
if len(errs) != 0 {
t.Fatalf("unexpected read errors: %v", errs)
}
@@ -122,7 +151,7 @@ func TestGenPollPassTouchedGenerationTriggersSync(t *testing.T) {
if got := cache[[2]string{"bab", "eleven"}]; got != want {
t.Fatalf("cache etag = %q, want the new %q (a stale cache would re-sync forever)", got, want)
}
- if changed, _ := genPollPass(ctx, routes, s, cache); changed {
+ if execChanged, unitChanged, _ := genPollPass(ctx, routes, s, cache); execChanged || unitChanged {
t.Fatal("the pass after a change must be silent again")
}
}
@@ -135,8 +164,8 @@ func TestGenPollPassAbsentGenerationStaysSilent(t *testing.T) {
cache := map[[2]string]string{}
for i := range 2 {
- changed, errs := genPollPass(ctx, routes, s, cache)
- if changed {
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if execChanged || unitChanged {
t.Fatalf("pass %d: an app with no generation object must never trigger a sync", i)
}
if len(errs) != 0 {
@@ -155,8 +184,8 @@ func TestGenPollPassAbsentToPresentTriggersSync(t *testing.T) {
genPollPass(ctx, routes, s, cache) // records known-absent
touch(t, ctx, s, "bab", "eleven") // the app's first ever config write
- changed, errs := genPollPass(ctx, routes, s, cache)
- if !changed {
+ execChanged, _, errs := genPollPass(ctx, routes, s, cache)
+ if !execChanged {
t.Fatal("absent -> present is the first write to an old app: it must sync")
}
if len(errs) != 0 {
@@ -176,8 +205,8 @@ func TestGenPollPassOneChangedAppSyncsOnce(t *testing.T) {
genPollPass(ctx, routes, s, cache)
touch(t, ctx, s, "bab", "titogo")
- changed, errs := genPollPass(ctx, routes, s, cache)
- if !changed {
+ execChanged, _, errs := genPollPass(ctx, routes, s, cache)
+ if !execChanged {
t.Fatal("one moved app in the set must report the pass as changed")
}
if len(errs) != 0 {
@@ -189,7 +218,7 @@ func TestGenPollPassOneChangedAppSyncsOnce(t *testing.T) {
if got, want := cache[[2]string{"bab", "eleven"}], genEtag(t, ctx, s, "bab", "eleven"); got != want {
t.Fatalf("unchanged app's cache = %q, want %q", got, want)
}
- if changed, _ := genPollPass(ctx, routes, s, cache); changed {
+ if execChanged, _, _ := genPollPass(ctx, routes, s, cache); execChanged {
t.Fatal("the next pass must be silent for both apps")
}
}
@@ -215,8 +244,8 @@ func TestGenPollPassDropsVanishedApps(t *testing.T) {
// again), not fire a sync off a remembered etag — a route being
// re-added already gets fresh config from the spawn path.
touch(t, ctx, s, "bab", "titogo")
- changed, errs := genPollPass(ctx, both, s, cache)
- if changed {
+ execChanged, unitChanged, errs := genPollPass(ctx, both, s, cache)
+ if execChanged || unitChanged {
t.Fatal("a re-added app is a first sight, not a change")
}
if len(errs) != 0 {
@@ -241,8 +270,8 @@ func TestGenPollPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) {
s := &failingKeyStore{Store: dir, failKey: appconfig.GenKey("bab", "eleven"), err: boom}
touch(t, ctx, dir, "bab", "titogo")
- changed, errs := genPollPass(ctx, routes, s, cache)
- if !changed {
+ execChanged, _, errs := genPollPass(ctx, routes, s, cache)
+ if !execChanged {
t.Fatal("one app failing must not stop the others being evaluated")
}
if len(errs) != 1 {
@@ -255,3 +284,105 @@ func TestGenPollPassReadErrorLeavesCacheAndKeepsGoing(t *testing.T) {
t.Fatalf("unreadable app's cache = %q, want it untouched at %q (a cleared entry would re-fire on recovery)", got, elevenEtag)
}
}
+
+func TestGenPollPassUnitFirstSightIsSilent(t *testing.T) {
+ ctx, s := genKit(t)
+ touch(t, ctx, s, "bab", "unitapp")
+ routes := []registry.Route{unitRoute("u.example.com", "bab", "unitapp")}
+ cache := map[[2]string]string{}
+
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if execChanged || unitChanged {
+ t.Fatal("first sight of a unit-backed app must not request a root pass (boot would restart every tenant)")
+ }
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ want := genEtag(t, ctx, s, "bab", "unitapp")
+ if got := cache[[2]string{"bab", "unitapp"}]; got != want {
+ t.Fatalf("cache etag = %q, want the store's %q", got, want)
+ }
+}
+
+func TestGenPollPassUnitMovementRequestsRootPassOnly(t *testing.T) {
+ ctx, s := genKit(t)
+ touch(t, ctx, s, "bab", "unitapp")
+ touch(t, ctx, s, "bab", "eleven")
+ routes := []registry.Route{
+ unitRoute("u.example.com", "bab", "unitapp"),
+ execRoute("a.example.com", "bab", "eleven"),
+ }
+ cache := map[[2]string]string{}
+ genPollPass(ctx, routes, s, cache)
+
+ touch(t, ctx, s, "bab", "unitapp")
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if !unitChanged {
+ t.Fatal("a moved generation for a unit-backed app must request the root adopt pass")
+ }
+ if execChanged {
+ t.Fatal("the exec half must stay quiet: no exec-config app moved, so SyncExec would find nothing to write")
+ }
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if execChanged, unitChanged, _ := genPollPass(ctx, routes, s, cache); execChanged || unitChanged {
+ t.Fatal("the pass after a change must be silent again (a stale cache would re-touch the marker forever)")
+ }
+}
+
+func TestGenPollPassBothShapesSetBothHalvesOnOneRead(t *testing.T) {
+ ctx, dir := genKit(t)
+ touch(t, ctx, dir, "zed", "mixed")
+ // One app, two routes: the activator spawns one, systemd owns the other.
+ // Its config lives in ONE generation object, so the union must read that
+ // object once per tick and report both halves off it.
+ routes := []registry.Route{
+ execRoute("mixed-exec.example.com", "zed", "mixed"),
+ unitRoute("mixed-unit.example.com", "zed", "mixed"),
+ }
+ s := &countingStore{Store: dir}
+ cache := map[[2]string]string{}
+ genPollPass(ctx, routes, s, cache)
+
+ touch(t, ctx, dir, "zed", "mixed")
+ execChanged, unitChanged, errs := genPollPass(ctx, routes, s, cache)
+ if !execChanged || !unitChanged {
+ t.Fatalf("execChanged=%v unitChanged=%v, want both — the app has both route shapes", execChanged, unitChanged)
+ }
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if got := s.gets[appconfig.GenKey("zed", "mixed")]; got != 2 {
+ t.Fatalf("generation object read %d times over 2 ticks, want 2 — an app in both sets must cost one read per tick", got)
+ }
+}
+
+func TestGenPollPassPrunesUnitAppsToo(t *testing.T) {
+ ctx, s := genKit(t)
+ touch(t, ctx, s, "bab", "unitapp")
+ both := []registry.Route{
+ execRoute("a.example.com", "bab", "eleven"),
+ unitRoute("u.example.com", "bab", "unitapp"),
+ }
+ one := both[:1]
+ cache := map[[2]string]string{}
+ genPollPass(ctx, both, s, cache)
+
+ // The live set the cache is pruned against is the UNION: a unit app whose
+ // last route left must lose its entry exactly as an exec app does, or its
+ // remembered etag would fire a bogus root-pass request when the route
+ // comes back (adoption already delivers fresh config on the way in).
+ genPollPass(ctx, one, s, cache)
+ if _, ok := cache[[2]string{"bab", "unitapp"}]; ok {
+ t.Fatal("a unit-backed route that left the registry must leave no cache entry behind")
+ }
+ touch(t, ctx, s, "bab", "unitapp")
+ execChanged, unitChanged, errs := genPollPass(ctx, both, s, cache)
+ if execChanged || unitChanged {
+ t.Fatal("a re-added unit-backed app is a first sight, not a change")
+ }
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+}
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go
index 024587fd8..488d52ccb 100644
--- a/internal/host/bootstrap.go
+++ b/internal/host/bootstrap.go
@@ -26,7 +26,7 @@ import (
// no separate files to copy. Ported from Eleven's deploy/systemd (renamed,
// same hardening) and titogo's deploy/systemd (litestream + restore-verify).
//
-//go:embed units/*.service units/*.timer units/*.socket
+//go:embed units/*.service units/*.timer units/*.socket units/*.path
var unitsFS embed.FS
// serviceUser is the unprivileged account the edge unit runs as; bootstrap
@@ -42,25 +42,30 @@ const serviceUser = "carlos"
const tmpfilesConf = "d /run/carlos 0750 " + serviceUser + " " + serviceUser + " -\n" +
"d /run/carlos/apps 0750 " + serviceUser + " " + serviceUser + " -\n"
-// enabledUnits are what a live bootstrap turns on: the always-on front door
-// and the hourly adoption timer. The litestream + restore-verify units install
+// enabledUnits are what a live bootstrap turns on: the always-on front door,
+// the hourly adoption timer, and the path unit that lets the agent ask for an
+// adoption pass NOW (carlos-adopt.path — the unit-backed half of config fast
+// convergence, issue #44). The litestream + restore-verify units install
// everywhere but stay inert until an operator stages /data/litestream.env and
// generates /etc/litestream.yml (`carlos ops litestream`) — a box with no
// replication bucket is unaffected.
-var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer"}
+var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path"}
// systemctlPlan is the exact systemctl sequence a live bootstrap runs — and
// the same sequence a staging run (-root <dir>) PRINTS instead of running, so
// the two can never drift into describing different behaviour.
//
-// Timers get an explicit `start` after the `enable`, and that line is the
-// point of this function: `systemctl enable` writes a .wants symlink and
-// stops there, which arms the timer at the NEXT boot and not a moment sooner.
-// carlos-adopt.timer sat enabled-but-inactive on the flagship for weeks for
-// exactly this reason — symlink present, nothing rebooted, no release ever
-// adopted itself, every deploy silently needing a hand-run adoption pass. A
-// re-run is harmless: starting an already-running timer is a no-op, which
-// keeps bootstrap idempotent.
+// Timers and path units get an explicit `start` after the `enable`, and that
+// line is the point of this function: `systemctl enable` writes a .wants
+// symlink and stops there, which arms the unit at the NEXT boot and not a
+// moment sooner. carlos-adopt.timer sat enabled-but-inactive on the flagship
+// for weeks for exactly this reason — symlink present, nothing rebooted, no
+// release ever adopted itself, every deploy silently needing a hand-run
+// adoption pass. A path unit fails the same way and just as quietly: an
+// unstarted carlos-adopt.path watches nothing, so the agent's config-sync
+// marker lands in a file no one is reading. A re-run is harmless: starting an
+// already-running timer or path unit is a no-op, which keeps bootstrap
+// idempotent.
//
// Services are deliberately NOT started. carlos-edge needs its binary at
// /opt/carlos/carlos and /etc/carlos/host.env staged first — neither of which
@@ -73,14 +78,14 @@ func systemctlPlan(units []string) [][]string {
{"daemon-reload"},
append([]string{"enable"}, units...),
}
- var timers []string
+ var armed []string
for _, u := range units {
- if strings.HasSuffix(u, ".timer") {
- timers = append(timers, u)
+ if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") {
+ armed = append(armed, u)
}
}
- if len(timers) > 0 {
- plan = append(plan, append([]string{"start"}, timers...))
+ if len(armed) > 0 {
+ plan = append(plan, append([]string{"start"}, armed...))
}
return plan
}
@@ -194,7 +199,7 @@ func bootstrap(root string) error {
return err
}
}
- fmt.Printf("bootstrap: units installed, %s enabled (timers armed, not just symlinked)\n", strings.Join(enabledUnits, " + "))
+ fmt.Printf("bootstrap: units installed, %s enabled (timers and path units armed, not just symlinked)\n", strings.Join(enabledUnits, " + "))
fmt.Println(" next: copy the carlos binary to /opt/carlos/carlos, stage /etc/carlos/host.env, then: systemctl start carlos-edge")
return nil
}
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index a94cc4d65..c15f0cb29 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -24,6 +24,7 @@ import (
// must be added here (and a removed one taken out) or TestUnitsEmbedComplete
// fails, so the embed directive and the fleet's unit set can't drift apart.
var expectedUnits = []string{
+ "carlos-adopt.path",
"carlos-adopt.service",
"carlos-adopt.timer",
"carlos-app@.service",
@@ -401,6 +402,11 @@ func TestIsCanaryRoute(t *testing.T) {
// Every timer bootstrap enables must therefore also be started, and no
// SERVICE may be (carlos-edge needs its binary and host.env staged first —
// bootstrap's own closing line tells the operator to start it).
+//
+// PATH units are the same trap with the same fix: an enabled-but-unstarted
+// carlos-adopt.path watches nothing until the next reboot, so the agent's
+// config-sync marker would be written to a file no one reads and unit-backed
+// apps would silently keep only the hourly floor.
func TestSystemctlPlanArmsTimers(t *testing.T) {
plan := systemctlPlan(enabledUnits)
if len(plan) == 0 || plan[0][0] != "daemon-reload" {
@@ -423,8 +429,8 @@ func TestSystemctlPlanArmsTimers(t *testing.T) {
if !enabled[u] {
t.Errorf("%s: enabled by the plan? no — bootstrap must still enable everything it did before", u)
}
- if strings.HasSuffix(u, ".timer") && !started[u] {
- t.Errorf("%s: enabled but never started — that is exactly the bug (enable only symlinks; the timer arms at the next boot)", u)
+ if (strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path")) && !started[u] {
+ t.Errorf("%s: enabled but never started — that is exactly the bug (enable only symlinks; the unit arms at the next boot)", u)
}
if strings.HasSuffix(u, ".service") && started[u] {
t.Errorf("%s: bootstrap must not start a service — the binary and /etc/carlos/host.env may not be staged yet", u)
diff --git a/internal/host/units/carlos-adopt.path b/internal/host/units/carlos-adopt.path
new file mode 100644
index 000000000..823eabfbd
--- /dev/null
+++ b/internal/host/units/carlos-adopt.path
@@ -0,0 +1,16 @@
+# Fast config convergence for unit-backed apps (issue #44). The agent's
+# configGenLoop touches this file when a config write moves the generation
+# object of an app with carlos-app@ routes on this box: the agent renders
+# exec-backed instances itself, but it cannot write /etc/carlos/apps or
+# restart units, so it asks the root pass to do both. /data is hardcoded
+# like every path in these unit files — a box with a nonstandard data dir
+# simply never fast-triggers and keeps carlos-adopt.timer's hourly floor.
+[Unit]
+Description=CARLOS root adopt pass on agent request (config fast convergence)
+
+[Path]
+PathChanged=/data/config-sync-requested
+Unit=carlos-adopt.service
+
+[Install]
+WantedBy=multi-user.target