carlos / platform Public

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:

  1. ExecBackend.Acquire, before lease.Acquire: the only place an instance or sidecar lease is taken (doWake is its only caller, so request wakes, ticks, the sweeper and the worker boot pass are all covered). Acquire calls ExecBackend.AdmitConfig.
  2. Worker in-place respawns (respawnWorkerLocked: crash restart, build convergence, max_run recycle). They keep the lease and call StartWorker directly, so they never pass Acquire. They ask AdmitConfig through the optional configAdmitter capability (SplitBackend dispatches it like every other call). On refusal the worker stands down: the old child is already stopped, nothing starts, and a normal stop checkpoints 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.
  3. provision.Reconcile and 4. provision.ReconcileSidecars mint branches, before reg.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_KEY AND 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, and instanceEnv accepts 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.env on disk proves nothing about its values (names alone pass A=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}, plus config_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_guard while 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.
Human

A box that cannot open an instance's sealed config does not take its lease or mint its record (#213)

Commits 9
Discussions 0 0 open
Tasks 0/0 done

Discussions

  • No discussions on this branch yet.

Tasks

  • No tasks yet.

Activity