fix/keyless-box-refuses
Keyless box refuses (amadan #213), revision 3
A box takes an instance's lease, or mints its record, only if it can provide that instance's config. The console cannot prove what reconcile will admit (four review rounds of drift), so the invariant lives at admission.
Where the check sits, and why there
One predicate, appconfig.CanProvideConfig, asked from four places on the box, each BEFORE the side effect it guards:
ExecBackend.Acquire, beforelease.Acquire: the only place an instance or sidecar lease is taken (doWakeis its only caller, so request wakes, ticks, the sweeper and the worker boot pass are all covered). Acquire callsExecBackend.AdmitConfig.- Worker in-place respawns (
respawnWorkerLocked: crash restart, build convergence, max_run recycle). They keep the lease and callStartWorkerdirectly, so they never pass Acquire. They askAdmitConfigthrough the optionalconfigAdmittercapability (SplitBackenddispatches it like every other call). On refusal the worker stands down: the old child is already stopped, nothing starts, and a normalstopcheckpoints and releases the lease so a sibling can take it. Nothing re-wakes it until the box can provide the config, because every wake is refused until then. Ordinary instance restarts release their lease and re-enter via Acquire. The legacy co-spawned sidecar reads only<host>-sidecar.env, never config.env, so it is not gated. provision.Reconcileand 4.provision.ReconcileSidecarsmint branches, beforereg.Upsert.
provision and activator take a seam (ReconcileConfig.ConfigGate, ExecBackend.ConfigGate, both func(ctx, registry.Route) error); internal/host builds one configGuard and wires it into both. nil gate = today's behaviour. Existing routes are not unminted; a keyless box refuses at wake.
Which routes: needsBoxConfig is Kind instance with a Socket, or a sidecar worker, with an App, not Platform, and Backing != unit. The exclusion is Backing and only Backing, because that is what SplitBackend.pick dispatches on. A socket-backed instance that names a Unit with an empty Backing still reaches ExecBackend.Acquire, so it is gated.
What "can provide config" means
Over the merged view SyncExec renders (LoadMergedHost: default < bound environment < instance overlay):
- No secret var: yes, key or no key. Dev with no sealed config is unaffected.
- Some secret var: a parseable
CARLOS_SECRETS_KEYAND the merged view must RENDER (RenderEnv, the very function sync uses): every envelope opens under the route's own binding and every value is writable. Sealing records no recipient key, so there is no fingerprint to compare; rendering is the proof and the one that matches delivery. SyncExec writes the whole config.env or none of it, andinstanceEnvaccepts a missing file, so a half-openable manifest on a box with no rendered file starts the instance with none of its sealed config. Fresh admission therefore needs all of it. - No fallback to a leftover file. A rendered
<host>.config.envon disk proves nothing about its values (names alone passA=old,A=, or credentials rendered for another environment), and sync leaves it untouched when it cannot render. A fresh acquisition or respawn starts only if the whole merged config renders now. A mixed-key or partly corrupt manifest refuses; the fix is to give the box the key or re-seal the config. Tests plant leftover files (wrong values, empty values, environment rebound, every name present) and assert refusal. Continuing a process that is already running is a different policy and is not this check's. - A manifest with only plain vars counts as "no sealed config" (as the discussion says); a keyless box does not deliver those either (status quo).
Fail closed
Every inability to decide refuses: a store read error on any layer, an unparseable manifest, a missing bound environment, no key, an unparseable key, config that does not render, a deployment store that is configured but will not open (store_error), and a box with NO deployment store configured (no_config_source).
That last one is the explicit-opt-in case. Config lives in the deployment store (CARLOS_DEPLOYMENT_BUCKET / _DIR / fleet door) but CARLOS_HIBERNATE_BUCKET enables leases independently, so "no store configured" cannot establish "nothing sealed". Config-free development opts in with CARLOS_NO_SEALED_CONFIG=1 (or true). It is honoured only when there is no deployment store; with a store, config is read as usual, and a configured store that fails to open or read still refuses with the flag set.
The bypass is loud. It is logged at startup (config guard: BYPASSED ... when in effect, or ... ignored when the flag sits beside a real store), logged once per admitted host (BYPASS: admitting <host> WITHOUT checking ...), and published in the private status block as metrics.config_guard: bypassed (CARLOS_NO_SEALED_CONFIG). The field is absent in the normal mode and when the flag is ignored.
A refusal is cached 30s per host so a request storm costs one set of bucket reads per TTL; that is also the retry delay after a store error. An admission is never cached. The wake caller gets a typed error and answers 503 with Retry-After 30 (no sibling failover; the edge does not retry elsewhere). Reconcile does not mint this pass and retries next tick. Concurrent uncached checks for one host are not coalesced (optional, left out).
What the operator sees
- One log line per host per reason, again when the reason changes and on recovery.
status.Box.Metrics.ConfigRefusals:{host, reason, since, last, count}, plusconfig_refusals_total, capped at 100, pruned after 24h unasked. Reasons are closed words:no_key,key_unparseable,cannot_open,store_error,no_config_source. Private block only (it names hosts); nothing in public fields; contract and dry-run leak tests cover it.metrics.config_guardwhile the bypass is in effect (above).
Release notes: the key is read at start
CARLOS_SECRETS_KEY is parsed once when the agent starts. Adding or changing the key requires an agent restart; waiting out the 30s cache is not enough. Stage and verify keys and delivered config before rolling boxes (including customer fleets), roll gradually, and restart agents to load keys. A keyless box that retains routes becomes unavailable for sealed instances on its next wake.
Out of scope
- internal/hostclaim and the host-lifecycle claim (#199). Not needed.
- Console-side eligibility,
secrets_key_fp,serves_shared_pool. - The move worker's checkpoint, the status-lease and housekeeper leases.
- Instances already awake; the gate applies at wake, mint and worker respawn.
- Plain env vars on a keyless box; the legacy co-spawned sidecar.
- Automatic sibling retry of a refused request.
A box that cannot open an instance's sealed config does not take its lease or mint its record (#213)
Discussions
- No discussions on this branch yet.
Tasks
- No tasks yet.
Activity
- claude agent, operated by paul@keymail.dev pushed d2f50574e Merge origin/main into fix/keyless-box-refuses (snapshot metrics gate keeps both the config guard and alerts fields)
- claude agent, operated by paul@keymail.dev pushed 005f7ac50 Merge origin/main into fix/keyless-box-refuses
- claude agent, operated by paul@keymail.dev pushed e03196e5d README: approved copy for the config guard (#213)
- claude agent, operated by paul@keymail.dev pushed 699c6c732 config guard: drop the stale-file fallback, make the no-sealed-config bypass loud (#213)
- claude agent, operated by paul@keymail.dev pushed d0498ac1c config guard: gate worker respawns, key on Backing, require complete renderability, fail closed without a store (#213)
- claude agent, operated by paul@keymail.dev pushed eb2fbc93a host: the config guard, wired to the lease, the mint and the status snapshot (#213)
- claude agent, operated by paul@keymail.dev pushed b65f57e68 status: publish config refusals in the private Metrics block (#213)
- claude agent, operated by paul@keymail.dev pushed 9a03d19ea activator, provision: a ConfigGate before the lease and before a mint (#213)
- claude agent, operated by paul@keymail.dev pushed 104e78469 appconfig: CanProvideConfig, can this box provide an instance's config (#213)