Instance key provision plan rev 3: answers Astra round 2
One evidence rule, two sources: instance.key, or an adoption record bound to the pinned writer (host, incarnation, session, generation, taken from the agent's launch state) for the key the target delivers, pinned by digest and verified at install. Source (c) and ConfiguredInstanceKey are dropped. The record is a trusted-helper assertion; it is published on the running session through hostfiles.PlatformRelay; O6 gates claim-box relocation for such apps; O7 puts the record in moves' artifact lifecycle. O2' for stage B rewritten and run against 919662d2. Rastrillo addendum: report on every process start, with source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+1050
−571
docs/superpowers/plans/2026-10-10-instance-key-provision.md+1050 −571
diff --git a/docs/superpowers/plans/2026-10-10-instance-key-provision.md b/docs/superpowers/plans/2026-10-10-instance-key-provision.md| index 7b4097d20..4de56f122 100644 |
| --- a/docs/superpowers/plans/2026-10-10-instance-key-provision.md |
| +++ b/docs/superpowers/plans/2026-10-10-instance-key-provision.md |
| @@ -1,10 +1,10 @@ |
| -# Instance Key Provision (Region HA L21) Implementation Plan, rev 2 |
| +# Instance Key Provision (Region HA L21) Implementation Plan, rev 3 |
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| -**Goal:** The platform mints every exec-backed instance's `CARLOS_INSTANCE_KEY` on the box, create-only and durably, into the instance's own `<host>.env` before any process of the instance starts, and backfills it into every existing instance. Relocation accepts a key only with evidence that the app actually seals with it: its own `instance.key`, the platform key plus an adoption record the app's helper asked the agent to write, or a key from the app's configuration. |
| +**Goal:** The platform mints every exec-backed instance's `CARLOS_INSTANCE_KEY` on the box, create-only and durably, into the instance's own `<host>.env` before any process of the instance starts, and backfills it into every existing instance. Relocation accepts a key only with evidence that the app actually seals with it: its own `instance.key`, or an adoption record bound to the writer whose data is pinned, for the exact key the target will deliver, pinned by digest and checked again at install. |
| -**Architecture:** A small leaf package, `internal/instancekey`, holds the one rule for the key: the env parser, which file's key the app receives, and the adoption record. The activator mints by it (`instancekey.go`), right after the existing `CARLOS_ADMIN_TOKEN` mint on every launch path and under the same lock, and makes the file durable on every call. The control socket gains one verb, `PUT /instance-key/adoption`, that records adoption beside `<host>.env`. A startup backfill in `internal/host` appends a key to every existing `<host>.env` on boxes where host claims are off. On claim boxes the launch source mints under the claim's guard before `Materialize`. `appconfig.ConfiguredInstanceKey` answers the pinner's question about a configured key. Stage B's pinner (O2'), the C8 provider (O1') and the relocation install (O3') carry the evidence across boxes. No flag. |
| +**Architecture:** A small leaf package, `internal/instancekey`, holds the one rule for the key: the env parser, which file's key the app receives, and the adoption record. The activator mints by it (`instancekey.go`), right after the existing `CARLOS_ADMIN_TOKEN` mint on every launch path and under the same lock, and makes the file durable on every call. A control-socket verb, `PUT /instance-key/adoption`, records a process's adoption beside `<host>.env`, bound to the writer the agent admitted it as, and publishes it on the running session through `hostfiles.PlatformRelay`; claim launches mount it once they bind a control socket (O6). A startup backfill in `internal/host` appends a key to every existing `<host>.env` on boxes where host claims are off. On claim boxes the launch source mints under the claim's guard before `Materialize`. Stage B's pinner (O2'), the C8 provider (O1'), the relocation install (O3') and moves (O7) carry the evidence across boxes and retire it. No flag. |
| **Tech Stack:** Go, `crypto/rand`, `encoding/base64`, `crypto/sha256`, `internal/instancefs` (openat2), `internal/safefile`, the registry, the claim launch source, the control socket. |
| @@ -12,8 +12,29 @@ |
| - Decision: the L21 row of `docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md` on branch `region-ha-1b-next-build` (line 357; Paul, 2026-10-09). |
| - Region HA spec `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`: H6 (line 131) and §5.6 (line 539): `instance.key`, `report.token`, `<host>.env` are always continuous. |
| - Framework contract: rastrillo/rastrillo discussion #66 (agreed; landed on rastrillo main as e3ea08d). `carlos.InstanceKey(own string) (string, error)` resolves, in order: (1) `own`, the app's own key; (2) `$CARLOS_INSTANCE_KEY`, verbatim; (3) on CARLOS (`carlos.Running()`, which checks `$CARLOS_CONTROL_SOCKET`) with no key, `ErrNoInstanceKey`; (4) off CARLOS, `ErrNotOnCarlos`, today's behaviour. Read at e3ea08de in `~/amadan.net/rastrillo/rastrillo` (`carlos/instancekey.go`, `carlos/schedule.go:28`). |
| -- Stage B plan `docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md` on `region-ha-1b-next-build` (1651ff7d): rulings 25 and 54, Task 8b step 0 (O2) and Task 8c (O3). This plan's O2' replaces O2. |
| -- Review: Astra round 1, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md` (verdict: not ready). Its findings are answered in the table below. |
| +- Stage B plan `docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md` on `region-ha-1b-next-build` (919662d2): rulings 25 and 55, Task 8b step 0 (its O2' variant with a `KeyEvidence` reader) and Task 8c (O3). This plan's O2' replaces that step. |
| +- Review: Astra round 1, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md`, and round 2, `CODEX-PLAN-INSTANCE-KEY-R2.md` (verdict both times: not ready). Their findings are answered in the two tables below, round 2's first. |
| + |
| +## Rev 3: round 2 dispositions |
| + |
| +Astra's round 2 (`CODEX-PLAN-INSTANCE-KEY-R2.md`, reviewed at a21e8225) found three P1s and two P2s. It also withdrew round 1's finding 6, and accepted the missing claim socket as an explicit refusal. Each finding was checked against the code, and rulings 19 to 24 (Paul's R-A to R-F) answer them. Source (c) and `appconfig.ConfiguredInstanceKey` are gone. The rev 2 table below stays as history; where it and this table disagree, this one stands. |
| + |
| +| # | Finding | Disposition | Change | Test | |
| +|---|---|---|---|---| |
| +| R2-1 | P1. A configured-key source still bypasses the refusal: configuration supplies A, the app's own unpublished B wins (step 1), and (c) passes. | **Accepted** (ruling 20). Verified: rev 2's O2' consulted `ConfigKey` with no word from the app. | Source (c) and `ConfiguredInstanceKey` are dropped. A configured key counts only through an adoption record, reported by the helper with `source: config`, exactly as a platform key. An app on its own key B sends no report, so there is no record and the pin refuses. | O2' `TestStorePinnerKeyEvidence`, row "configured A, the app's own unpublished B: no report, no record" | |
| +| R2-2 | P1. Configuration evidence neither identifies nor pins the key: a configuration changed from A to B before the box dies, or between pin and install, delivers B over data sealed with A. | **Accepted** (ruling 20). Verified: `ConfiguredInstanceKey` returned a boolean from the current configuration. | The record names the key by digest. The pinner pins it (`PinObject.InstanceKey`). The install checks that the env files it is about to launch with deliver exactly that key (`instancekey.VerifyDelivered`), and refuses otherwise (O3'). | `TestVerifyDeliveredRefusesAnyOtherKey` ("the configuration changed since the pin"); O2' config rows; O3''s install test | |
| +| R2-3 | P1. An adoption record outlives the writer it describes: a later release on its own key, or a rollback, inherits an earlier session's record. | **Accepted** (rulings 20, 21). Verified: rev 2's record held only a version, a name and a digest. | The record binds host, incarnation, writer session and generation, all taken from the agent's own launch state for that process. Every new writer admission starts with no record (O1'). The pinner accepts only a record that binds the pinned writer. | `TestAdoptionRecordBindsOneWriter`; O2' rows "a stale record from a previous release", "a rollback", "a record from another installation"; `TestInstanceKeyAdoptionRecordsTheLaunchesWriter` (a session in the request is a 400) | |
| +| R2-4 | P2. A first adoption has no publication path during the running session, and O6 is not gated in the HA checklist. | **Accepted** (rulings 22, 23). | The verb hands the durable record to the session's publisher through `hostfiles.PlatformRelay`, and the publisher's continuous loop commits it. 204 means durable on disk; publication follows the publisher's bound. O6 is a gate in O4's checklist. | `TestAdoptionSurvivesBoxLossWithoutAnotherAdmissionOrRelease`, `TestPlatformRelayStagesTheLatestBodyWithoutBlockingItsCaller`, `TestInstanceKeyAdoptionIsDurableBeforeItAnswers` | |
| +| R2-5 | P2. Deferring the record's retirement breaks a later install on the source box: the census refuses the leftover. | **Accepted** (ruling 24). Verified: `installArtifactPaths` (movesinstall.go:751) does not name it, and `EmptyInstallationCensus.CheckLocal` refuses an unclassified host artifact. | O7 puts the record in moves' journalled artifact lifecycle, with the exact lists. Its writer (O6) does not ship before O7. | O7's `TestMoveAwayRetiresTheAdoptionRecordSoTheHostCanReturn` (in the moves change) | |
| +| note | The record is tenant-forgeable. | **Answered** (ruling 19). | The record is a trusted-helper assertion against accidental loss, not independent evidence. A tenant who forges it can lose only its own data. | none | |
| +| R1-6 | Round 1's merge option. | **Closed**: Astra withdrew it. Reconciling AGENTS.md's local-squash text with the hub rule is Paul's call. | none | none | |
| +| gap | Claim launches have no control socket. | **Accepted as an explicit refusal** (ruling 23). | O4's checklist gates relocation for apps on a platform or configured key on O6. | none | |
| + |
| +**Counts:** findings R2-1 to R2-5: 5 accepted, 0 declined. The forgeability note is answered by ruling 19, and R1-6 is closed. |
| + |
| +**Found while revising:** |
| +- **The helper cannot tell the source itself.** A platform key and a configured key both arrive as `$CARLOS_INSTANCE_KEY` (ruling 13). So O6 delivers the source beside the key, as an agent-owned `CARLOS_INSTANCE_KEY_SOURCE`, and the helper echoes it. The agent still checks the echo against its own launch state, which is authoritative. |
| +- **Stage B's rev 3 (919662d2) already changed its O2.** It moved to a `StorePinner.KeyEvidence` reader (its ruling 55) that still accepts a configured key on presence, and still parses `export` lines. This plan's O2' replaces that reader. The evidence is in the pinned manifest itself, so the pinner needs no outside reader. |
| ## Rev 2: dispositions of Astra round 1 |
| @@ -75,7 +96,7 @@ Its callers are every launch path, each before the env is read: `seedChild` (bac |
| **Creation against first start.** The console writes the instance record. The box's reconcile mints the registry row with `Status: "ready"` and starts nothing (internal/provision/reconcile.go:425-435). The first process is started by the first wake, `doWake`: `Restore` (internal/activator/activator.go:1797), then `Seed` when the database is fresh (:1817), then `Start`. `Restore` restores the database only (backend_exec.go:663-757). Both seed and start mint before they read the env. |
| -**The control socket.** `edge.ListenControl` (internal/edge/purge.go:126) binds `<socket>.control` before every exec spawn and serves `POST /purge` (when there is a cache) plus whatever the activator mounts: today `schedule.MountGated`, which serves `GET /schedule-capabilities`, `PUT` and `DELETE /schedule/{name}` and `GET /schedule`, each bearer-gated on the host's own `CARLOS_ADMIN_TOKEN` (internal/schedule/control.go:97-225). It is mounted in `spawnChildFrom` (backend_exec.go:1406-1414) and in the nested `listenControl` (prepare.go:135-155), and only when `b.Timers != nil`. Rastrillo e3ea08de's `carlos` package calls only `PUT` and `DELETE /schedule/{name}` (`carlos/schedule.go`). **Nothing on the socket lets an app tell the agent which key it uses,** so source (b) needs a new verb here and a call in rastrillo (the ask below). |
| +**The control socket.** `edge.ListenControl` (internal/edge/purge.go:126) binds `<socket>.control` before every exec spawn and serves `POST /purge` (when there is a cache) plus whatever the activator mounts: today `schedule.MountGated`, which serves `GET /schedule-capabilities`, `PUT` and `DELETE /schedule/{name}` and `GET /schedule`, each bearer-gated on the host's own `CARLOS_ADMIN_TOKEN` (internal/schedule/control.go:97-225). It is mounted in `spawnChildFrom` (backend_exec.go:1406-1414) and in the nested `listenControl` (prepare.go:135-155), and only when `b.Timers != nil`. Rastrillo e3ea08de's `carlos` package calls only `PUT` and `DELETE /schedule/{name}` (`carlos/schedule.go`). **Nothing on the socket lets an app tell the agent which key it uses,** so source (b') needs a new verb here and a call in rastrillo (the ask below). |
| **Claim launches have no control socket.** `SnapshotClaimLaunch` composes the launch env with `agentOwnedVars(rt, "", vars, false)` (claim_snapshot.go:116), which strips `CARLOS_CONTROL_SOCKET`. A claim-launched app is not `carlos.Running()`, but rastrillo's step 2 still returns `$CARLOS_INSTANCE_KEY`. It cannot call the adoption verb (O6). |
| @@ -107,7 +128,7 @@ Each ruling ends with its cost if wrong. Rulings 1 to 12 are rev 1's, amended wh |
| **6. Durability before the first start.** |
| - *Legacy boxes:* nothing can publish today, and region HA cannot be on, because it requires host claims. The key's durability is the box's disk, now fsynced, and a move's sealed archive. A dead legacy box loses its keys with its data; that is today's state, unchanged (row 10). |
| -- *Claim boxes* are region HA's only path. The launch source mints in `Select`. `Materialize` then calls `ClaimFilesManifest.Establish`, and the app executes only after that returns. O1' binds the provider to publish the env file and the adoption record, committed, before it returns. |
| +- *Claim boxes* are region HA's only path. The launch source mints in `Select`. `Materialize` then calls `ClaimFilesManifest.Establish`, and the app executes only after that returns. O1' binds the provider to publish the env file, committed, before it returns, and to start the new generation with no adoption record. |
| - *Bucket unreachable at first start:* `Establish` fails, so the wake fails and the app does not start. The key stays on disk unchanged. The next wake publishes the same bytes. |
| *Cost if wrong:* a C8 provider that ignores O1' reopens the window. O2' is the backstop: without evidence, the host goes `stuck` and is never relocated without its key. |
| @@ -136,12 +157,12 @@ Each ruling ends with its cost if wrong. Rulings 1 to 12 are rev 1's, amended wh |
| | move | landing installs env only on an empty path; archive carries bytes exactly; env report fails a missing or different key | Task 7 `TestArchiveCarriesTheInstanceKeyByteForByte`, `TestEnvReportComparesTheInstanceKey`; existing `TestLandingNeverDeletesFilesItDidNotCreate` | |
| | relocation install | stage B writes `EnvItem`'s bytes; the next launch finds the key present | O3' (stage B branch) | |
| | layout move (flat to nested) | `<host>.env` is one of the pass's named artifacts; the adoption record is one of its extras | existing layout pass tests | |
| -| adoption record | written only for the own file's key; identical bytes are not rewritten | Task 5 `TestInstanceKeyAdoptionRecordsOnlyTheOwnFilesKey` | |
| +| adoption record | written only for the launch's own writer and delivered key; identical bytes are not rewritten; a new admission starts with none (O1') | Task 5 `TestInstanceKeyAdoptionRecordsTheLaunchesWriter` | |
| **9. Secrecy.** |
| - No log line or error takes the value. The shape warning names the path only, and a test checks the value is not in the log. |
| - `<host>.env` is 0600 (ruling 4), as is the adoption record and the nested launch file (prepare.go:204). |
| -- The adoption record holds `sha256(key)`, never the key. It is published beside the env item, which holds the key itself, so it gives a bucket reader nothing new. A configured key is never recorded (ruling 14), so no digest of a configuration secret is published. |
| +- The adoption record holds `sha256(key)`, never the key. For a platform key it is published beside the env item, which holds the key itself, so it gives a bucket reader nothing new. For a configured key (rev 3, ruling 20) it publishes the digest of a configuration secret: nothing for a random key, a guessing target for a weak one (follow-ups). |
| - The console never reads a box's `<host>.env`. `carlos env list` reads the bucket configuration through the console API (appconfig_cli.go:462-507), never `<host>.env`. The moves env report carries HMACs only. |
| - Once region HA publishes the env item, the key sits in the data bucket beside the database it seals. That is inherent in making the key recoverable with the data. |
| @@ -152,8 +173,8 @@ Each ruling ends with its cost if wrong. Rulings 1 to 12 are rev 1's, amended wh |
| - If the source never had a key, the target's first start mints one. That is correct: no source process ever held a key. |
| - The move's env report classes `CARLOS_INSTANCE_KEY` as `equal`. A target that would start with a different key, or none, is a `differs` or `missing` difference, never a note. |
| - A move's probe launches with the env its readiness pin checks against the source's report (internal/host/movesprobeowners.go:106-120). So the probe path never mints. |
| -- A move does not carry the adoption record (the archive holds the data directory and `<host>.env` only). The app re-records it at its first start on the target, because the helper calls the verb once per process. Until then a relocation of that host would refuse (O2'), which is safe. |
| -- Relocation's install (stage B Task 8c) writes the pinned manifest's `EnvItem` and adoption item verbatim (O3'). The next launch's mint finds the key present. |
| +- A move does not carry the adoption record (the archive holds the data directory and `<host>.env` only). The target's sessions are new writers and start with none (ruling 20); the app re-reports at its first process start there. The source's record is retired with the move's artifacts (O7). |
| +- Relocation's install (stage B Task 8c) writes the pinned manifest's `EnvItem` verbatim and checks the pinned key before anything launches (O3'). The next launch's mint finds the key present. |
| *Cost if wrong:* a key lost in transit is the L21 failure itself. The env report makes it visible on moves. |
| @@ -169,21 +190,9 @@ The agent sets `CARLOS_CONTROL_SOCKET` only for instance children (`agentOwnedVa |
| **13. One rule for the effective key (new; finding 4).** `instancekey.Effective(config, own)` is the key a launched app receives: the own file's when non-empty, else the configuration's when non-empty, else none. An empty own value never shadows a non-empty configured one. `mergeEnv` applies it on top of its merge, so the child's env follows the rule; the mint decides by it; the adoption verb checks by it; and the pinner reads the env item with the same parser (`instancekey.ParseEnv`, which `parseEnvBytes` now calls). *Cost if wrong:* a deliberately empty `CARLOS_INSTANCE_KEY=` in `<host>.env` can no longer hide a configured key. Nobody can rely on that today: an empty key makes an adopting app refuse to start. |
| -**14. The adoption record (new; finding 1, source b).** |
| -- *The verb:* `PUT /instance-key/adoption` on the instance's control socket, bearer `CARLOS_ADMIN_TOKEN`, body `{"sha256":"<hex>"}`, the SHA-256 of the key the app resolved. The host is the listener's, never the request's. |
| -- *What it checks:* the host's effective key (ruling 13) must have that digest. If it is the own file's key, the agent writes `<host>.instance-key-adoption.json` beside `<host>.env`: `{"v":1,"var":"CARLOS_INSTANCE_KEY","sha256":"<hex>"}`, atomically, 0600, file and directory fsynced, and only then answers 204. Identical bytes already there are made durable and not rewritten. If it is the configuration's key, it answers 204 and records nothing: source (c) evidences that key. A digest that matches no effective key, or a replica row, is 409 and records nothing. |
| -- *Who calls it:* rastrillo's `InstanceKey`, once per process, when it returns `$CARLOS_INSTANCE_KEY` (step 2). Failure is non-fatal to the app (the ask below). |
| -- *Where it travels:* the layout pass carries it as an extra. O1' publishes it as the platform item `instance-key-adoption.json`; O5 stages it in stage B's release flush; O3' restores it. A move does not carry it (ruling 10); the next process start re-records it. |
| -- *Claim boxes:* the verb is mounted wherever the agent binds a control socket. Claim launches bind none today (O6). |
| - |
| -*Cost if wrong:* an app that adopted on a release of rastrillo without the call has no record, and its relocation refuses (stuck) until it runs a release with the call. That is safe and visible. |
| - |
| -**15. Relocation's key evidence (new; findings 1 and 5).** For a nonempty database written by an admitted writer, the pinner (O2') accepts the pinned manifest only with positive evidence that it holds the key the app uses: |
| -- (a) a live `instance.key` entry, as today; |
| -- (b) the env item's `CARLOS_INSTANCE_KEY` is non-empty and the adoption item is a record for exactly that key; |
| -- (c) the env item has no non-empty key (or there is no env item), and the host's configuration renders a non-empty one (`appconfig.ConfiguredInstanceKey`, through `StorePinner.ConfigKey`). |
| +**14. The adoption record (rev 2; superseded by rulings 20 to 22).** Rev 2's record named only a key, was written for any launch with a control socket, and was not bound to a writer. Rev 3's record, its writer and its publication are rulings 20 to 22. |
| -A non-empty env key overrides a configured key at launch, so (c) is never consulted for it. A named item that cannot be read is an error, never "no evidence". Without any of the three, relocation refuses, as today. That is not a regression. *Cost if wrong:* (c) carries a residual that (b) closes: an app that keeps its own `instance.key`, never published, and also has a `CARLOS_INSTANCE_KEY` in its configuration passes (c) though it seals with its own key. Requiring an adoption record for configured keys too would close it; the binding ruling accepts the configuration itself as the evidence (concern 3 in the report). |
| +**15. Relocation's key evidence (rev 2; superseded by ruling 20).** Rev 2 accepted three sources: (a) `instance.key`, (b) an adopted platform key, and (c) a configured key on presence. Rev 3 keeps (a), replaces (b) with the writer-bound (b'), and drops (c). |
| **16. Claim retries reuse their inputs (new; question 5's premise corrected).** On a claim box every serving start is a claim wake. The launch source, which mints, runs in `Select`. A session's retries reuse the captured inputs and call `Materialize`, and so `Establish`, before every attempt. The legacy spawn path does not serve a claim host, and production claim swaps refuse. No claim attempt can run with a key minted after its files manifest was established. Any future path that mints before an asynchronous publish needs a synchronous publication gate. *Cost if wrong:* `TestStartAdmittedRetriesReuseTheirInputsAndMaterializeFirst` fails on the change that breaks it. |
| @@ -191,18 +200,64 @@ A non-empty env key overrides a configured key at launch, so (c) is never consul |
| **18. The claim early return syncs without writing (new; finding 2).** When the token and the key are both present, the claim launch source writes nothing and needs no authority, as before. It still opens the env file read-only, checks the mode, fsyncs the file and fsyncs its directory before it answers. fsync changes no byte, mode or name, so it is not a host write and runs outside the claim's guard. A file whose mode is not 0600 needs a write, so it takes the guarded path, whose ensure sets it. *Cost if wrong:* if fsync were judged a host write, this step would move under `authorize`, which re-reads the claim on every selection. |
| +**19. Threat model: the adoption record is a trusted-helper assertion (rev 3; Paul's R-A).** The record exists to stop *accidental* loss, such as a relocation that restores a database without the key that sealed it. It is not independent platform evidence. The tenant holds the bearer and the key, the nested record sits in tenant-writable storage, and the helper is the tenant's code. A tenant who forges a record, or reports a key it does not seal with, can lose only its own data. Authentication pins the request to the listener's host. Writer binding (ruling 20) stops a record outliving its writer. Neither proves which key seals the database, and nothing here claims to. *Cost if wrong:* if the record were ever treated as a security boundary between tenants, it would be the wrong tool. It is not used as one. |
| + |
| +**20. One evidence rule, two sources (rev 3; Paul's R-B; findings R2-1 to R2-3).** For a nonempty database written by an admitted writer, the pinner (O2') accepts the pinned manifest only with: |
| +- **(a)** a live `instance.key` entry, unchanged; or |
| +- **(b')** an adoption record, the platform item `instance-key-adoption.json`, **bound to the writer whose data is pinned**. That writer is the session admitted as the pinned database's generation: the holder's own session when that generation is the holder's, else the matching admission (`pinnedWriter`). All three of these must hold: |
| + - the record binds exactly that writer's host, incarnation, session and generation; |
| + - its digest is the digest of the key the target will deliver. A `platform` record must match the pinned env item's own key, which the install restores byte for byte. A `config` record needs an env item with no own key, because one would override the configuration at launch; |
| + - the key is pinned by digest (`PinObject.InstanceKey`) and verified again at install. The install refuses if the env files it would launch with deliver any other key, for example a configuration changed since the pin (O3', `instancekey.VerifyDelivered`). |
| + |
| +Source (c) and `ConfiguredInstanceKey` are gone: a configured key is covered by (b'). **Invalidation:** every new writer admission starts with no record (O1'). A later release that picks its own key (step 1) sends no report, so it has no record and never inherits an earlier session's. A rollback is a new session and is the same case. Without (a) or (b'), relocation refuses (stuck), as before L21. *Cost if wrong:* an app that adopts but whose report never landed for the pinned session goes stuck rather than relocating. That is safe and visible, and the next process start re-reports. |
| + |
| +**21. The agent binds the record from its own launch state (rev 3; Paul's R-B).** The helper reports on every process start, at most once per process: the digest of the key it resolved at step 2, and its source, `platform` or `config`. The agent checks both against the launch state it holds for that process (`AdoptionLaunch`). That state is the writer (holder incarnation, session, generation at admission), plus the digest and source of the key the launch env delivered, which `SnapshotClaimLaunch` now records (`InstanceKeySHA256`, `InstanceKeySource`, computed with `instancekey.Effective` from the same two files as the env). It then writes the record: `{"v":2,"var":"CARLOS_INSTANCE_KEY","host","incarnation","session","generation","source","sha256"}`. Nothing in the record comes from the request; a body naming a session is a 400. The verb is mounted only on launches that have a writer session, which means claim launches once they bind a control socket (O6). A legacy launch has no session to bind, so nothing mounts the verb there, and the helper's call is the 404 it ignores. Rev 2 mounted it on legacy launches; that is withdrawn. *Cost if wrong:* if a launch's state were taken from anything but its admission, a record could bind the wrong writer. `AdoptionLaunch` is built once per launch, by O6, from the admission. |
| + |
| +**22. Adoption is published on the running session (rev 3; Paul's R-C; finding R2-4).** |
| +- The verb writes the record beside `<host>.env`: atomically, 0600, file and directory fsynced. |
| +- It then hands the record to the session's files publisher through `hostfiles.PlatformRelay` and answers 204. 204 means the record is durable on disk; it does not wait on the bucket. |
| +- The relay stages the record as the platform item (`StagePlatform`), retrying while the store refuses. The publisher's continuous loop (`hostfiles.Run`) commits it within its deadline, as it commits every staged change. The item is always continuous, whatever the app's files durability. |
| +- An adoption followed by box loss, with no further admission or release, is therefore in the bucket for the pinner. |
| + |
| +*Cost if wrong:* a box lost inside the publisher's bound (one deadline, a second by default) after the first adoption loses that report, and the pin refuses until a later session re-reports. That is safe. |
| + |
| +**23. O6 is a gate in the HA rollout (rev 3; Paul's R-D).** Claim launches bind no control socket today (claim_snapshot.go:116 strips `CARLOS_CONTROL_SOCKET`). Until O6, an app on the platform key or a configured key cannot report adoption on a claim box. Its relocation refuses: an explicit refusal, not platform-key HA support. O4's checklist says so. *Cost if wrong:* none beyond the refusal, which is the point. |
| + |
| +**24. The move lifecycle includes the record (rev 3; Paul's R-E; finding R2-5).** The record is an installation artifact. Nested, it lives in the instance directory and is retired with it. Flat, it must be in moves' journalled artifact lists, so a move away retires it and a later empty-installation census on that box finds nothing of the host's. That is O7, with the exact lists. The writer (O6) does not ship before O7. The census classification does not change: the record stays an unclassified host artifact, so a leftover still refuses, which is the check that keeps O7 honest. *Cost if wrong:* a host that moved away could not be installed on its old box again until an operator removed the file. |
| + |
| ## Obligations on other plans |
| -- **O1' (the C8 files-manifest provider, `ClaimFilesManifest`; replaces O1):** `Establish` leaves a committed manifest whose platform items mirror the disk when it runs: `hostfiles.EnvItem` with exactly the bytes of `<host>.env`, and `instancekey.AdoptionItem` with exactly the bytes of `<host>.instance-key-adoption.json`, or no such item when the file is absent (`StagePlatformDelete`). It never writes either file on disk. Any error (store unreachable, `If-Match` lost) is returned, and the wake fails before the app executes. Tests, in the provider's plan, with the provider itself, not a fake: `TestEstablishPublishesTheEnvFileWithItsInstanceKey` (a dir store, a predecessor manifest whose env item lacks the key, a local `<host>.env` that carries it and an adoption record; after `Establish`, `ReadPlatform` of both items equals the local bytes, and the manifest is the committed revision); `TestEstablishFailsTheWakeWhenThePublishFails` (a store that refuses the PUT: `Establish` errors, and through `claimServingOwners.Materialize` and `startAdmitted` no start is reserved). |
| -- **O2' (stage B, ruling 25's `needKey` in `hostrelocate/pin.go` on `region-ha-1b-next-build`; replaces O2 and ruling 54):** ruling 15. The diff, the test and the wiring are below ("O2' for stage B"). Ruling 54's text becomes: "The instance key is evidenced by (a) `instance.key`, (b) the env item's key with an adoption record for it, or (c) no env key and a configured one (instance-key plan rev 2, ruling 15). The env item is parsed with `instancekey.ParseEnv`." |
| -- **O3' (stage B Task 8c, relocation install; replaces O3):** the install writes the pinned manifest's `EnvItem` and `AdoptionItem` byte for byte (`hostfiles.ReadPlatform`, then `writeFileSynced` to `<host>.env` and `<host>.instance-key-adoption.json` in the instance directory), and removes either file when its item is absent. It never mints. `TestRelocationInstallKeepsThePublishedEnvByteForByte` gains: the adoption item's bytes equal the record after `Install`, and `activator.EnsureInstanceKeyBeneath(dir, host+".env")` then returns `InstanceKeyPresent` and leaves the env bytes unchanged. |
| -- **O4 (stage B Task 10 checklist item 9):** becomes: "L21. The instance-key plan is rolled to the region's boxes, O1', O2', O3' and O5 are in, and every app that adopted the platform key runs a rastrillo release with the adoption call (#66 ask). A host whose manifest evidences its key by none of (a), (b), (c) stays `stuck`." |
| -- **O5 (stage B's release flush, `internal/host/relocationflush.go` on that branch):** stages `AdoptionItem` from disk beside `EnvItem` (or `StagePlatformDelete` when the file is absent), in the same commit. Test: the flush of a host with a record publishes it; without one, publishes none. |
| -- **O6 (claim Plan 9 Group D, the isolated-root launcher):** when claim launches bind a control socket, mount `mountInstanceKeyAdoption` on it, with the record's write run under the claim's guard (a hostwrite selection for the host, as the token's mint is). Until then, an app that first adopts the platform key on a claim box cannot record adoption, and its relocation refuses. A record written while the box was legacy stays on disk and is published by `Establish` (O1'). |
| +Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7. |
| + |
| +- **O1' (the C8 files-manifest provider, `ClaimFilesManifest`):** `Establish` leaves a committed manifest whose `hostfiles.EnvItem` holds exactly the bytes of `<host>.env` when it runs. A new admission starts with no adoption record (ruling 20): the new generation's manifest carries no `instancekey.AdoptionItem` (`StagePlatformDelete`), and `Establish` removes the local `<host>.instance-key-adoption.json` under the start's captured work. It never writes `<host>.env`. Any error (store unreachable, `If-Match` lost) is returned, and the wake fails before the app executes. Tests in the provider's plan, run against the provider itself rather than a fake: |
| + - `TestEstablishPublishesTheEnvFileWithItsInstanceKey`: a dir store, a predecessor manifest whose env item lacks the key and which carries an adoption item, and a local `<host>.env` with the key plus a local record. After `Establish`, `ReadPlatform(EnvItem)` equals the local bytes, the new manifest has no adoption item, the local record is gone, and the manifest is the committed revision. |
| + - `TestEstablishFailsTheWakeWhenThePublishFails`: a store that refuses the PUT. `Establish` errors, and through `claimServingOwners.Materialize` and `startAdmitted` no start is reserved. |
| +- **O2' (stage B, `hostrelocate/pin.go` on `region-ha-1b-next-build`):** the pinner's check for a nonempty database is **(a) or (b')** (ruling 20). It replaces rev 2's O2' and stage B's own rev 3 variant: ruling 55, its `StorePinner.KeyEvidence` reader and its `KeyQuestion`, `KeySource` and `envHasInstanceKey`. The evidence is in the pinned manifest, so the pinner needs no outside reader and no wiring. The diff and the test are below ("O2' for stage B"). Stage B's ruling 55 becomes: "The instance key is evidenced by (a) `instance.key` or (b') an adoption record bound to the pinned writer for the key the target delivers, pinned by digest (instance-key plan rev 3, ruling 20). The env item is parsed with `instancekey.ParseEnv`." |
| +- **O3' (stage B Task 8c, relocation install):** |
| + - The install writes the pinned manifest's `EnvItem` byte for byte with `writeFileSynced`, or removes `<host>.env` when the item is absent. It never mints. |
| + - It does not restore the adoption item: the target's sessions are new writers, which start with no record (ruling 20). |
| + - When the pin carries `InstanceKey`, then after the env file and the held render (`RenderHeldUnder`) are on disk and before anything launches, it reads both files and calls `instancekey.VerifyDelivered(config, env, pin.InstanceKey.SHA256)`. On an error it refuses (the operation goes stuck) and starts nothing. |
| + - Tests: `TestRelocationInstallKeepsThePublishedEnvByteForByte` gains "after `Install`, `activator.EnsureInstanceKeyBeneath(dir, host+".env")` returns `InstanceKeyPresent` and leaves the bytes unchanged". New `TestRelocationInstallDeliversThePinnedKey` has three cases: a pinned platform key installs; a pinned configured key whose configuration is unchanged installs; a configuration changed between the pin and the install refuses with nothing launched. |
| +- **O4 (stage B Task 10 checklist item 9)** becomes: "L21. The instance-key plan is rolled to the region's boxes, and O1', O2', O3', O5 and O7 are in. **O6 gates platform-key and configured-key relocation:** until claim launches bind a control socket and mount the adoption verb, an app whose key is not its own `instance.key` cannot report adoption on a claim box, and its relocation refuses (stuck). That is an explicit refusal, not HA support for those apps. Apps must also run a rastrillo release with the adoption call (#66 ask and addendum)." |
| +- **O5 (stage B's release flush, `internal/host/relocationflush.go` on that branch):** stages the local adoption record as `AdoptionItem` when it parses and binds the releasing writer (the holder's incarnation, session and generation); otherwise it stages `StagePlatformDelete`. Both go in the same commit as the env item. Test: a flush with the releasing writer's record publishes it; with a stale record, or none, publishes none. |
| +- **O6 (claim Plan 9 Group D, the isolated-root launcher), the gate of ruling 23:** |
| + - When claim launches bind a control socket, mount `ExecBackend.MountInstanceKeyAdoption` on it. |
| + - Its `launch` returns the process's `AdoptionLaunch`: `Writer` from the admission (holder incarnation, session and generation as admitted); `KeySHA256` and `Source` from `ClaimLaunchSnapshot.InstanceKeySHA256` and `InstanceKeySource`, carried through `ClaimServingLaunch` and `ClaimLaunchInputs`; and the instance directory. |
| + - `Publish` is a `hostfiles.PlatformRelay` over the session's files publisher, whose `hostfiles.Run` loop runs for the session. |
| + - The record's write runs under the claim's guard (a hostwrite selection for the host, as the token's mint does). |
| + - The launch env gains the agent-owned `CARLOS_INSTANCE_KEY_SOURCE` (`platform` or `config`, from `InstanceKeySource`), set only when the socket is bound. It is stripped from tenant files like `CARLOS_CONTROL_SOCKET` (`agentOwnedVars`) and added to appconfig's `agentOwned` list. |
| + - **O6 does not ship before O7.** Until O6, ruling 23's refusal stands. |
| +- **O7 (moves, the journalled artifact lifecycle; ruling 24).** These lists change: |
| + - `installArtifactPaths` (internal/host/movesinstall.go:751-754): append `host + instancekey.AdoptionSuffix` after `host + ".env"`. It flows into the dirty return's pre-move park (movesinstall.go:852), into `sourceArtifactPaths` (movesfinal.go:586-587) for the source finalization's park (movesfinal.go:527), and into the rollback's candidates (movesrollback.go:1314). |
| + - The landing's flat inventory (movesinstall.go:664): `paths` gains `host + instancekey.AdoptionSuffix`. The installation's inventory then claims the path its app's sessions will write. |
| + - `rollbackOwnership.owns` (movesrollback.go:1449-1470): a case for the record. It is the target's when it parses (`instancekey.ParseAdoption`) and binds the move's incarnation. Anything else is not ours. |
| + - The census classification does not change (ruling 24). A nested record lives in the instance directory and is retired with it. |
| + - Journals: the lists are journalled in order, and a journal written before O7 keeps its recorded list. No record exists anywhere before O6 ships, so an older journal cannot miss one. |
| + - Tests (in the moves change): `TestMoveAwayRetiresTheAdoptionRecordSoTheHostCanReturn`. A flat source holds a record; the move completes; the source finalization parks the record; `EmptyInstallationCensus.CheckLocal` for a reinstall of the host on the source box passes; a move back lands. Also the dirty-return variant: a return over a source that still holds a stale record parks it before placing anything. |
| ### O2' for stage B |
| -Apply to `internal/hostrelocate/pin.go` on `region-ha-1b-next-build` (after merging main, which brings `internal/instancekey`), in place of Task 8b step 0's O2 diff. Verified in a scratch copy of `origin/region-ha-1b-next-build` (1651ff7d) with `internal/instancekey` added: `go vet` clean, and `go test ./internal/hostrelocate/ -count=1` passes, every existing pinner test included. |
| +Apply to `internal/hostrelocate/pin.go` on `region-ha-1b-next-build` after merging main, which brings `internal/instancekey`. It goes in place of Task 8b step 0's diff, and that step's `KeyEvidence` reader goes with it. It was verified in a scratch copy of `origin/region-ha-1b-next-build` (919662d2) with `internal/instancekey` added: `go build ./internal/...` and `go vet` clean, and `go test ./internal/hostrelocate/ -count=1` passes, every existing pinner test included. |
| ```diff |
| --- a/internal/hostrelocate/pin.go |
| @@ -215,87 +270,150 @@ Apply to `internal/hostrelocate/pin.go` on `region-ha-1b-next-build` (after merg |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -@@ -88,6 +89,12 @@ |
| - // Stage B wires it to the controller's registry. Nil is ErrUnsupported; |
| - // an empty version refuses the pin, never a guess. |
| - Release func(ctx context.Context, host string) (string, error) |
| -+ // ConfigKey reports whether the host's configuration in the bucket |
| -+ // renders a non-empty CARLOS_INSTANCE_KEY: appconfig. |
| -+ // ConfiguredInstanceKey over the route a relocation target mints for |
| -+ // the host (relocationRecord), with the box's secrets key. It is the |
| -+ // key evidence (c) of O2'. Nil leaves only evidence (a) and (b). |
| -+ ConfigKey func(ctx context.Context, host string) (bool, error) |
| +@@ -37,6 +38,18 @@ |
| + // Release is the app release version the relocation installs, read |
| + // at pinning from StorePinner.Release (R9); a reuse carries it. |
| + Release string `json:"release"` |
| ++ // InstanceKey is the key a nonempty database is sealed with when the |
| ++ // pinned manifest evidences it by an adoption record rather than by |
| ++ // instance.key (O2', instance-key plan rev 3): its digest and source. |
| ++ // The install delivers exactly this key or refuses (O3'). |
| ++ InstanceKey *KeyPin `json:"instance_key,omitempty"` |
| ++} |
| ++ |
| ++// KeyPin is a pinned instance key: the SHA-256 of the key and the file |
| ++// that supplies it at launch (instancekey.SourcePlatform or SourceConfig). |
| ++type KeyPin struct { |
| ++ Source string `json:"source"` |
| ++ SHA256 string `json:"sha256"` |
| } |
| - func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.Time) (hostclaim.RecoveryPin, error) { |
| -@@ -142,7 +149,7 @@ |
| + // FilesPin names one generation's files manifest by prefix, sequence and |
| +@@ -142,11 +155,15 @@ |
| // that writer's instance.key: the pinned manifest must carry it (rev |
| // 18, round 17 finding 1). |
| needKey := c.Holder.Session != 0 && obj.DB != nil && obj.DB.MaxTXID > 0 |
| - files, retained, err := p.filesPin(ctx, c.Holder, released && rel.ManifestSeq == 0, needKey) |
| -+ files, retained, err := p.filesPin(ctx, c.Host, c.Holder, released && rel.ManifestSeq == 0, needKey) |
| ++ // The writer whose data is pinned: the session admitted as the pinned |
| ++ // database's generation (O2', instance-key plan ruling 20). Asked only |
| ++ // when instance.key is missing. |
| ++ writer := func() (instancekey.Binding, error) { return pinnedWriter(c, obj.DB.Generation) } |
| ++ files, keyPin, retained, err := p.filesPin(ctx, writer, c.Holder, released && rel.ManifestSeq == 0, needKey) |
| if err != nil { |
| return hostclaim.RecoveryPin{}, err |
| } |
| -@@ -221,7 +228,7 @@ |
| +- obj.Files = files |
| ++ obj.Files, obj.InstanceKey = files, keyPin |
| + if released && obj.Files.Seq < rel.ManifestSeq { |
| + return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the files manifest is at %d, the release at %d", ErrNotYet, obj.Files.Seq, rel.ManifestSeq) |
| + } |
| +@@ -221,7 +238,7 @@ |
| // stays restorable at g10. Every referenced object must lie under this |
| // holder's location (its incarnation) in this region's data bucket, at |
| // files/<generation>/objects/; any other reference refuses the pin. |
| -func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) { |
| -+func (p StorePinner) filesPin(ctx context.Context, host string, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) { |
| ++func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Binding, error), h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, *KeyPin, []byte, error) { |
| chain := []string{h.Generation} |
| for i := len(h.Admissions) - 1; i >= 0; i-- { |
| chain = append(chain, h.Admissions[i].Generation, h.Admissions[i].Predecessor) |
| -@@ -247,16 +254,20 @@ |
| - if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion { |
| - return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| +@@ -241,24 +258,33 @@ |
| + continue |
| } |
| + if err != nil { |
| +- return FilesPin{}, nil, err |
| ++ return FilesPin{}, nil, nil, err |
| + } |
| + var m hostfiles.Manifest // decoded from the bytes the digest names |
| + if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion { |
| +- return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| +- } |
| - if needKey && !liveEntry(m, InstanceKeyPath) { |
| - // The manifest was copied before the app started and the |
| - // key it minted afterwards never published (§5.6's |
| - // continuous-sync window, L21): installing this set would mint |
| - // a new key over data sealed with the old one. |
| - return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath) |
| -- } |
| - if origins, err = referencedOrigins(h.Location, origins, m); err != nil { |
| - return FilesPin{}, nil, err |
| ++ return FilesPin{}, nil, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| } |
| + if origins, err = referencedOrigins(h.Location, origins, m); err != nil { |
| +- return FilesPin{}, nil, err |
| ++ return FilesPin{}, nil, nil, err |
| ++ } |
| ++ var keyPin *KeyPin |
| + if needKey && !liveEntry(m, InstanceKeyPath) { |
| -+ // (a) failed: no instance.key. The key is accepted only with |
| -+ // evidence the app seals with it (O2', instance-key plan rev |
| -+ // 2): (b) or (c). Without either, the manifest may have been |
| -+ // copied before the app minted a key of its own (§5.6's |
| -+ // continuous-sync window), and installing this set would mint |
| -+ // a new key over data sealed with the old one. |
| -+ if err := p.keyEvidenced(ctx, host, prefix, m, origins); err != nil { |
| -+ return FilesPin{}, nil, err |
| ++ // (a) failed: no instance.key. (b'): an adoption record bound |
| ++ // to the pinned writer, for the key the target will deliver |
| ++ // (O2', instance-key plan rev 3). Without it the manifest may |
| ++ // have been copied before the app minted a key of its own |
| ++ // (§5.6's continuous-sync window), and installing this set |
| ++ // would mint a new key over data sealed with the old one. |
| ++ w, err := writer() |
| ++ if err != nil { |
| ++ return FilesPin{}, nil, nil, err |
| + } |
| -+ } |
| ++ if keyPin, err = p.adoptedKey(ctx, w, prefix, m, origins); err != nil { |
| ++ return FilesPin{}, nil, nil, err |
| ++ } |
| + } |
| sum := sha256.Sum256(raw) |
| - return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil |
| +- return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil |
| ++ return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, keyPin, raw, nil |
| + } |
| + // No manifest anywhere in the chain. An empty files pin needs the |
| + // same kind of evidence an empty database pin does (ruling 15): no |
| +@@ -272,14 +298,14 @@ |
| + // 17, finding F2), never an install that mints a fresh key over data |
| + // sealed with the old one. |
| + if h.Session != 0 && !releasedEmpty { |
| +- return FilesPin{}, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location) |
| ++ return FilesPin{}, nil, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location) |
| + } |
| + gen := h.Generation |
| + if gen == "" { |
| + gen = hostclaim.RootGeneration |
| } |
| -@@ -395,3 +406,54 @@ |
| + prefix := h.Location + "/files/" + gen + "/" |
| +- return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil |
| ++ return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil, nil |
| + } |
| + |
| + // referencedOrigins adds to origins the files prefix of every generation |
| +@@ -395,3 +421,72 @@ |
| } |
| return pinned, nil |
| } |
| + |
| -+// keyEvidenced is O2' (instance-key plan rev 2): a pinned manifest without |
| -+// instance.key still evidences the key a nonempty database is sealed with |
| -+// when either |
| -+// |
| -+// (b) its env item's CARLOS_INSTANCE_KEY is non-empty and its adoption |
| -+// item is a record for exactly that key: the app told the agent it |
| -+// resolved $CARLOS_INSTANCE_KEY and was handed this key; |
| -+// (c) its env item carries no non-empty key (or there is none) and the |
| -+// host's configuration renders one, which the target renders the |
| -+// same way. |
| -+// |
| -+// The env item is parsed by instancekey.ParseEnv, the activator's own |
| -+// rule, so the key read here is the key the launch hands the app. A |
| -+// non-empty env key overrides a configured one at launch, so (c) is never |
| -+// consulted for it. A named item that cannot be read is an error, never |
| -+// "no evidence". |
| -+func (p StorePinner) keyEvidenced(ctx context.Context, host, prefix string, m hostfiles.Manifest, origins []string) error { |
| ++// pinnedWriter is the writer whose data a pin of generation gen holds: the |
| ++// holder's own session when gen is the holder's generation, else the |
| ++// admitted session recorded for gen. A generation no admission names has |
| ++// no writer to bind evidence to, and refuses. |
| ++func pinnedWriter(c *hostclaim.Claim, gen string) (instancekey.Binding, error) { |
| ++ h := c.Holder |
| ++ w := instancekey.Binding{Host: c.Host, Incarnation: h.Incarnation, Generation: gen} |
| ++ if gen == h.Generation { |
| ++ w.Session = h.Session |
| ++ } |
| ++ for i := len(h.Admissions) - 1; w.Session == 0 && i >= 0; i-- { |
| ++ if h.Admissions[i].Generation == gen { |
| ++ w.Session = h.Admissions[i].Session |
| ++ } |
| ++ } |
| ++ if w.Session == 0 || w.Incarnation == "" || gen == "" { |
| ++ return instancekey.Binding{}, fmt.Errorf("%w: no admitted writer for generation %q", ErrFilesUnevidenced, gen) |
| ++ } |
| ++ return w, nil |
| ++} |
| ++ |
| ++// adoptedKey is O2' source (b') (instance-key plan rev 3, ruling 20): the |
| ++// pinned manifest's adoption record must bind exactly the pinned writer, |
| ++// and name the key the target will deliver. A platform-sourced record must |
| ++// match the env item's own key, which the install restores byte for byte. |
| ++// A config-sourced record needs an env item with no own key (one would |
| ++// override the configuration at launch); its digest is pinned, and the |
| ++// install verifies the configuration it renders still delivers it (O3'). |
| ++// The env item is parsed with instancekey.ParseEnv, the activator's own |
| ++// rule. A named item that cannot be read is an error, never "no evidence". |
| ++func (p StorePinner) adoptedKey(ctx context.Context, writer instancekey.Binding, prefix string, m hostfiles.Manifest, origins []string) (*KeyPin, error) { |
| + read := func(name string) ([]byte, bool, error) { |
| + if e, ok := m.Platform[name]; !ok || e.Deleted { |
| + return nil, false, nil |
| @@ -303,35 +421,39 @@ Apply to `internal/hostrelocate/pin.go` on `region-ha-1b-next-build` (after merg |
| + raw, err := hostfiles.ReadPlatform(ctx, p.Data, prefix, m, name, origins) |
| + return raw, err == nil, err |
| + } |
| ++ rec, ok, err := read(instancekey.AdoptionItem) |
| ++ if err != nil { |
| ++ return nil, err |
| ++ } |
| ++ refuse := func(why string) (*KeyPin, error) { |
| ++ return nil, fmt.Errorf("%w: %s's pinned manifest has no %s and %s, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, why) |
| ++ } |
| ++ if !ok { |
| ++ return refuse("no adoption record") |
| ++ } |
| ++ a, err := instancekey.ParseAdoption(rec) |
| ++ if err != nil { |
| ++ return refuse("its adoption record is malformed") |
| ++ } |
| ++ if !a.Binds(writer) { |
| ++ return refuse("its adoption record is not the pinned writer's") |
| ++ } |
| + env, _, err := read(hostfiles.EnvItem) |
| + if err != nil { |
| -+ return err |
| ++ return nil, err |
| + } |
| + own, _ := instancekey.Effective(nil, instancekey.ParseEnv(env)) |
| -+ if own != "" { |
| -+ rec, ok, err := read(instancekey.AdoptionItem) |
| -+ if err != nil { |
| -+ return err |
| -+ } |
| -+ if ok && instancekey.Adopted(rec, own) { |
| -+ return nil |
| -+ } |
| -+ return fmt.Errorf("%w: %s's pinned manifest has no %s, and no adoption record for the %s in its env, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, instancekey.Var) |
| -+ } |
| -+ if p.ConfigKey != nil { |
| -+ ok, err := p.ConfigKey(ctx, host) |
| -+ if err != nil { |
| -+ return err |
| -+ } |
| -+ if ok { |
| -+ return nil |
| -+ } |
| ++ switch { |
| ++ case a.Source == instancekey.SourcePlatform && own != "" && instancekey.Digest(own) == a.SHA256: |
| ++ case a.Source == instancekey.SourceConfig && own == "": |
| ++ default: |
| ++ return refuse("its adoption record does not name the key the env delivers") |
| + } |
| -+ return fmt.Errorf("%w: %s's pinned manifest has no %s, no adopted %s and no configured one, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, instancekey.Var) |
| ++ return &KeyPin{Source: a.Source, SHA256: a.SHA256}, nil |
| +} |
| ``` |
| -Replace `internal/hostrelocate/pin_envkey_test.go` (stage B's `TestStorePinnerAcceptsAnInstanceKeyInTheEnv`) with: |
| +`internal/hostrelocate/pin_envkey_test.go` (in place of stage B's `TestStorePinnerNeedsPositiveEvidenceForAnEnvKey`): |
| ```go |
| package hostrelocate |
| @@ -340,54 +462,73 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// O2' (instance-key plan rev 2, replacing stage B's O2): a nonempty |
| -// database's pinned manifest evidences its key by (a) instance.key, (b) an |
| -// env key with an adoption record for that very key, or (c) no env key and |
| -// a key in the host's configuration. A platform key that is merely present |
| -// is not evidence: an app with a key of its own keeps it, and its data is |
| -// sealed under that key (Astra round 1 finding 1). |
| +// O2' (instance-key plan rev 3, replacing stage B's O2 and ruling 55's |
| +// KeyEvidence reader): a nonempty database's pinned manifest evidences its |
| +// key by (a) instance.key, or (b') an adoption record bound to the pinned |
| +// writer for the key the target will deliver. Presence of a key, in the env |
| +// or in the configuration, is never evidence (Astra rounds 1 and 2). |
| func TestStorePinnerKeyEvidence(t *testing.T) { |
| ctx := context.Background() |
| at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| const key = "q+/Zk3vJ0aW1Yx9sR7tUe2oPl4mN8bC6dF5gH0iJ1kA=" |
| + writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"} |
| + earlier := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 2, Generation: "g2"} |
| envWithKey := "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=" + key + "\n" |
| - configured := func(context.Context, string) (bool, error) { return true, nil } |
| - unconfigured := func(context.Context, string) (bool, error) { return false, nil } |
| + rec := func(b instancekey.Binding, source, k string) string { |
| + return string(instancekey.AdoptionRecord(b, source, instancekey.Digest(k))) |
| + } |
| for _, tc := range []struct { |
| name string |
| appKey bool // (a) instance.key in the manifest |
| env, adoption string // "" is no item |
| unreadable string // a named item whose object is missing |
| - configKey func(context.Context, string) (bool, error) |
| + holder func(*hostclaim.Holder) |
| want error |
| + pinned *KeyPin |
| }{ |
| {name: "(a) the app's own instance.key", appKey: true, env: envWithKey, want: nil}, |
| - {name: "(b) the env key, adopted", env: envWithKey, adoption: string(instancekey.AdoptionRecord(key)), want: nil}, |
| + {name: "(b') the platform key, adopted by the pinned writer", env: envWithKey, adoption: rec(writer, "platform", key), pinned: &KeyPin{Source: "platform", SHA256: instancekey.Digest(key)}}, |
| + {name: "(b') a configured key, adopted by the pinned writer", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}}, |
| + {name: "(b') a configured key under an empty override", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}}, |
| {name: "a backed-up platform key, the app's own key missing", env: envWithKey, want: ErrFilesUnevidenced}, |
| - {name: "an adoption record for another key", env: envWithKey, adoption: string(instancekey.AdoptionRecord("another-key")), want: ErrFilesUnevidenced}, |
| - {name: "the env key overrides a configured one, unadopted", env: envWithKey, configKey: configured, want: ErrFilesUnevidenced}, |
| - {name: "an exported key line is not what the app receives", env: "export CARLOS_INSTANCE_KEY=" + key + "\n", adoption: string(instancekey.AdoptionRecord(key)), configKey: unconfigured, want: ErrFilesUnevidenced}, |
| - {name: "(c) no env key, a configured key", env: "CARLOS_ADMIN_TOKEN=t\n", configKey: configured, want: nil}, |
| - {name: "(c) an empty override, a configured key", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", configKey: configured, want: nil}, |
| - {name: "(c) no env item, a configured key", configKey: configured, want: nil}, |
| - {name: "no key anywhere", env: "CARLOS_ADMIN_TOKEN=t\n", configKey: unconfigured, want: ErrFilesUnevidenced}, |
| - {name: "no configuration reader", env: "CARLOS_ADMIN_TOKEN=t\n", want: ErrFilesUnevidenced}, |
| - {name: "the configuration cannot be read", env: "CARLOS_ADMIN_TOKEN=t\n", configKey: func(context.Context, string) (bool, error) { return false, errBucket }, want: errBucket}, |
| + {name: "configured A, the app's own unpublished B: no report, no record", env: "CARLOS_ADMIN_TOKEN=t\n", want: ErrFilesUnevidenced}, |
| + {name: "a stale record from a previous release", env: envWithKey, adoption: rec(earlier, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a rollback: a new session, the old session's record", env: envWithKey, adoption: rec(writer, "platform", key), |
| + holder: func(h *hostclaim.Holder) { |
| + h.Session, h.Generation = 4, "g4" |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: "g4", Predecessor: "g3", Session: 4}) |
| + }, want: ErrFilesUnevidenced}, |
| + {name: "a record from another installation", env: envWithKey, adoption: rec(instancekey.Binding{Host: host, Incarnation: "inc-0", Session: 3, Generation: "g3"}, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a platform record for another key", env: envWithKey, adoption: rec(writer, "platform", "another-key"), want: ErrFilesUnevidenced}, |
| + {name: "a config record while the env's own key overrides it", env: envWithKey, adoption: rec(writer, "config", key), want: ErrFilesUnevidenced}, |
| + {name: "a platform record without the platform key", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "an exported key line is not what the app receives", env: "export CARLOS_INSTANCE_KEY=" + key + "\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a malformed record", env: envWithKey, adoption: `{"v":1}`, want: ErrFilesUnevidenced}, |
| + {name: "no admitted writer for the pinned generation", env: envWithKey, adoption: rec(writer, "platform", key), holder: func(h *hostclaim.Holder) { h.Incarnation = "" }, want: ErrFilesUnevidenced}, |
| {name: "a named adoption item that cannot be read", env: envWithKey, unreadable: instancekey.AdoptionItem, want: errUnreadable}, |
| - {name: "a named env item that cannot be read", unreadable: hostfiles.EnvItem, configKey: configured, want: errUnreadable}, |
| + {name: "a named env item that cannot be read", adoption: rec(writer, "config", "configured-A"), unreadable: hostfiles.EnvItem, want: errUnreadable}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + obs := pinClaim() |
| + obs.Claim.Holder.Incarnation = "inc-1" |
| + gen := "g3" |
| + if tc.holder != nil { |
| + tc.holder(obs.Claim.Holder) |
| + gen = obs.Claim.Holder.Generation |
| + } |
| m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} |
| if tc.appKey { |
| m = keyed(m) |
| @@ -395,7 +536,7 @@ func TestStorePinnerKeyEvidence(t *testing.T) { |
| item := func(name, body string, put bool) { |
| sum := sha256.Sum256([]byte(body)) |
| sha := hex.EncodeToString(sum[:]) |
| - obj := pinLocation + "/files/g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha) |
| + obj := pinLocation + "/files/" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha) |
| if put { |
| if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil { |
| t.Fatal(err) |
| @@ -412,96 +553,97 @@ func TestStorePinnerKeyEvidence(t *testing.T) { |
| if tc.unreadable != "" { |
| item(tc.unreadable, "never stored\n", false) |
| } |
| - putManifest(t, data, "g3", m) |
| - p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41), ConfigKey: tc.configKey} |
| - _, err := p.Pin(ctx, pinClaim(), at) |
| + putManifest(t, data, gen, m) |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + ref, err := p.Pin(ctx, obs, at) |
| switch { |
| case tc.want == errUnreadable: |
| if err == nil || errors.Is(err, ErrFilesUnevidenced) { |
| t.Fatalf("got %v, want a read error, never a refusal for no evidence", err) |
| } |
| + return |
| case !errors.Is(err, tc.want): |
| t.Fatalf("got %v, want %v", err, tc.want) |
| } |
| + if err != nil { |
| + return |
| + } |
| + raw, _, err := claims.Get(ctx, ref.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var obj PinObject |
| + if err := json.Unmarshal(raw, &obj); err != nil { |
| + t.Fatal(err) |
| + } |
| + if (obj.InstanceKey == nil) != (tc.pinned == nil) || (tc.pinned != nil && *obj.InstanceKey != *tc.pinned) { |
| + t.Fatalf("pinned key %+v, want %+v", obj.InstanceKey, tc.pinned) |
| + } |
| }) |
| } |
| } |
| -var ( |
| - errBucket = errors.New("bucket unreachable") |
| - errUnreadable = errors.New("sentinel: any read error") |
| -) |
| -``` |
| - |
| -Wiring, in `NewRelocationRuntime`'s worker literal (stage B Task 10), beside `Release: channelRelease(store, pub)`. This sketch is not compiled here, because `relocationRecord` exists only in stage B's plan text: |
| - |
| -```go |
| -// relocationConfigKey is O2' evidence (c): the host's configuration, |
| -// rendered for the route a relocation target mints (relocationRecord), so |
| -// the pinner asks exactly what the target's held render will deliver. |
| -func relocationConfigKey(dataDir string, store release.Store, priv *ecdh.PrivateKey) func(context.Context, string) (bool, error) { |
| - return func(ctx context.Context, host string) (bool, error) { |
| - o, err := hostclaim.Read(ctx, store, host) |
| - if err != nil { |
| - return false, err |
| - } |
| - rec, err := relocationRecord(ctx, dataDir, store, o) |
| - if err != nil { |
| - return false, err |
| - } |
| - return appconfig.ConfiguredInstanceKey(ctx, store, priv, rec.Route) |
| - } |
| -} |
| +var errUnreadable = errors.New("sentinel: any read error") |
| ``` |
| -and `ConfigKey: relocationConfigKey(cfg.DataDir, store, cfg.SecretsKey)` in `hostrelocate.StorePinner{...}`. |
| - |
| ## Ask for rastrillo #66 |
| -*For Paul to post on rastrillo/rastrillo discussion #66.* |
| +*For Paul to post on rastrillo/rastrillo discussion #66. Rev 2's ask is below, updated; the rev 3 addendum follows it.* |
| -**The ask.** When `carlos.InstanceKey(own)` returns the platform's key, that is `$CARLOS_INSTANCE_KEY` at step 2, have it tell the agent once, so the platform can record that this app seals with that key. Failure is non-fatal to the app. |
| +**The ask.** When `carlos.InstanceKey(own)` returns `$CARLOS_INSTANCE_KEY` (step 2), have it tell the agent so, on every process start, so the platform can record that this app's current writer seals with that key. Failure is non-fatal to the app. |
| -**Why.** Region HA relocates an instance's data to another box. It may hand the app the platform key there only if that key is the one the data is sealed with. A platform key being present proves nothing: an app with its own key keeps it (step 1). So the platform needs positive evidence, and only the app's helper knows which step it took. |
| +**Why.** Region HA relocates an instance's data to another box. It may hand the app the delivered key there only if that key is the one the data is sealed with. A key being present proves nothing: an app with its own key keeps it (step 1). So the platform needs positive word, and only the app's helper knows which step it took. |
| **The call.** |
| - `PUT http://carlos/instance-key/adoption` over `$CARLOS_CONTROL_SOCKET`, the same transport and placeholder host `ScheduleAt` uses. |
| - Header `Authorization: Bearer $CARLOS_ADMIN_TOKEN`; `Content-Type: application/json`. |
| -- Body `{"sha256":"<hex>"}`: the lowercase hex SHA-256 of the key string exactly as resolved. Never the key. |
| -- When: inside `InstanceKey`, only on the step 2 return, only when `Running()` and the token is set, at most once per process (a `sync.Once`). |
| -- How long: synchronous, before `InstanceKey` returns, so the record is on disk before the app seals anything. Bounded by a short timeout of its own (2 seconds suggested), since `InstanceKey` takes no context. |
| -- Answers: 204 recorded (or the key is the app's configured one, which needs no record). 404 or 405: an agent from before this verb; ignore it silently. 409: the agent's effective key is not the one the app holds, or the host is a replica; worth one log line. 401, 5xx, a timeout, a dial error: one log line at most. Never retry in a loop, never log the key. |
| +- Body `{"sha256":"<hex>","source":"<platform|config>"}`. `sha256` is the lowercase hex SHA-256 of the key string exactly as resolved; never send the key. `source` is `$CARLOS_INSTANCE_KEY_SOURCE` verbatim, which the platform sets beside the key. |
| +- When: inside `InstanceKey`, only on the step 2 return, only when `Running()` and the token are set, at most once per process (a `sync.Once`), on every process start. |
| +- How long: synchronous, before `InstanceKey` returns, so the record is on disk before the app seals anything. It is bounded by a short timeout of its own (2 seconds suggested), since `InstanceKey` takes no context. |
| +- Answers: 204 means recorded. 404 or 405 means an agent or a launch without the verb: ignore it silently. 409 means the agent's launch delivered another key or source, or the host is a replica: worth one log line. 400, 401, 5xx, a timeout or a dial error: one log line at most. Never retry in a loop, and never log the key. |
| - Whatever the answer, `InstanceKey` returns the key and a nil error. |
| **Not called** at step 1 (the app's own key), step 3 or step 4, or with no `$CARLOS_ADMIN_TOKEN`. |
| -**What changes for apps.** Nothing visible. An app on a rastrillo release without the call still runs; its data simply does not relocate (it is held as `stuck`) until it runs a release with the call. The reference page could say that `InstanceKey` may make one local call to the platform. |
| +**What changes for apps.** Nothing visible. An app on a rastrillo release without the call still runs. Its data does not relocate (it is held as `stuck`) until it runs a release with the call. |
| + |
| +### Addendum (rev 3), for Paul to post |
| + |
| +Two changes to the ask above, and only the second is new: |
| +1. **Every process start.** The helper reports once per process, on every process start, not once per agent or per deployment. The platform binds each report to the writer session that started the process, and a new session starts with no record. |
| +2. **New: `source`.** The body adds `"source": "platform"` or `"source": "config"`, copied verbatim from `$CARLOS_INSTANCE_KEY_SOURCE`, which the platform sets beside `$CARLOS_INSTANCE_KEY`. Both keys arrive as the same variable, so the helper cannot tell them apart without it. If `$CARLOS_INSTANCE_KEY_SOURCE` is unset, skip the call: that agent cannot record adoption. |
| ## Review Focus |
| -1. **A failed sync, then a retry.** The retry keeps the bytes, redoes fchmod and both syncs, and only then succeeds; no child starts in between. Task 2 `TestEnsureInstanceKeyRedoesAFailedSyncBeforeReportingSuccess`, Task 3 `TestExecBackendStartStartsNoChildUntilTheKeyIsSynced`, Task 4 `TestEnsureClaimAdminTokenSyncsAPresentKeyBeforeItAnswers`. |
| -2. **An empty `CARLOS_INSTANCE_KEY=` in `<host>.env` with a configured key.** The child gets the configured key and nothing is minted. Task 3 `TestExecBackendStartNeverHandsTheChildAnEmptyKeyWhileOneExists`. |
| -3. **The adoption verb's checks.** Bearer, digest, own file only, replica refused, durable before 204. Task 5. |
| -4. **O2''s evidence table.** In particular "a backed-up platform key, the app's own key missing" refuses, and the exported line is not a key. |
| -5. **A FIFO planted at a nested `<host>.env`.** The startup backfill fails fast. Task 3 `TestBackfillInstanceKeyRefusesAFIFOWithoutBlocking`. |
| -6. **A visitor's wake, a seed and the startup backfill racing on one fresh host.** Exactly one key is written. Task 2 `TestEnsureInstanceKeyMintsOneKeyUnderConcurrentCallers`. |
| +1. **O2''s evidence table.** Astra's configured-A, own-B case refuses. So do a stale record from a previous release, a rollback, another installation, and a record naming a key the env does not deliver. The pinned key is in the pin object. |
| +2. **The install's last check (O3').** A configuration changed between the pin and the install refuses (`TestVerifyDeliveredRefusesAnyOtherKey`). |
| +3. **The verb takes nothing from the request but the digest and the source.** A session in the body is a 400. The record binds the launch's writer. 204 means durable on disk (Task 5). |
| +4. **Adoption, then box loss.** The record is in the bucket with no admission or release after it (`TestAdoptionSurvivesBoxLossWithoutAnotherAdmissionOrRelease`). |
| +5. **A failed sync, then a retry.** The retry keeps the bytes, redoes the fchmod and both syncs, and only then succeeds; no child starts in between (Tasks 2 to 4). |
| +6. **An empty `CARLOS_INSTANCE_KEY=` in `<host>.env` with a configured key.** The child gets the configured key and nothing is minted (Task 3). |
| ## File structure |
| -- Create `internal/instancekey/instancekey.go` and `instancekey_test.go`: the parser, `Effective`, `Digest`, the adoption record. Standard library only. |
| +- Create `internal/instancekey/instancekey.go` and `instancekey_test.go`. They hold the parser, `Effective`, `Digest`, the sources, the writer-bound adoption record (`Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `Binds`) and `VerifyDelivered`. Standard library only. |
| - Modify `internal/activator/admintoken.go` (`parseEnvBytes` calls `instancekey.ParseEnv`) and `internal/activator/backend_exec.go` (`mergeEnv`, `LaunchEnvironment`'s shadow list). |
| - Create `internal/activator/instancekey.go`: the mint (`EnsureInstanceKey`, `EnsureInstanceKeyBeneath`, `ensureInstanceKeyFor`, `BackfillInstanceKey`, `settleClaimInstanceEnv`, `InstanceKeyVar`, `InstanceKeyOutcome`). |
| -- Create `internal/activator/instancekey_test.go`, `instancekey_linux_test.go`, `claim_instancekey_test.go`, `claimretry_instancekey_linux_test.go`. |
| -- Modify `internal/activator/backend_exec.go` (`seedChild` :831, `spawnChildFrom` :1369 and its mount :1406), `internal/activator/prepare.go` (:60 and its mount :143), `internal/activator/claim_snapshot.go` (:40-67). |
| -- Create `internal/activator/keyadoption.go`, `keyadoption_test.go`, `keyadoption_linux_test.go`: the adoption verb. |
| -- Modify `internal/host/claimowners.go` (:125-138): O1' on `ClaimFilesManifest`. Create `internal/host/claim_instancekey_test.go`. |
| +- Create `internal/activator/instancekey_test.go`, `instancekey_linux_test.go`, `claim_instancekey_test.go` and `claimretry_instancekey_linux_test.go`. |
| +- Modify `internal/activator/backend_exec.go` (`seedChild` :831, `spawnChildFrom` :1369), `internal/activator/prepare.go` (:60) and `internal/activator/claim_snapshot.go` (the snapshot's fields, `EnsureClaimAdminToken` :40-67, `SnapshotClaimLaunch`'s env read). |
| +- Create `internal/activator/keyadoption.go`, `keyadoption_test.go` and `keyadoption_linux_test.go`: the adoption verb, mounted by O6. |
| +- Create `internal/hostfiles/relay.go` and `relay_test.go`: `PlatformRelay`. |
| +- Modify `internal/host/claimowners.go` (:125-138), for O1' on `ClaimFilesManifest`. Create `internal/host/claim_instancekey_test.go` and `instancekey_adoption_test.go`. |
| - Create `internal/host/instancekeys.go` and `instancekeys_test.go`: the backfill. Modify `internal/host/agent.go` (:476-484). |
| -- Create `internal/appconfig/instancekey.go` (`ConfiguredInstanceKey`) and `syncexec_instancekey_test.go`, `internal/moves/envreport_instancekey_test.go`, `internal/moves/archive/instancekey_test.go`. |
| +- Create `internal/appconfig/syncexec_instancekey_test.go`, `internal/moves/envreport_instancekey_test.go` and `internal/moves/archive/instancekey_test.go`. |
| -**Sandbox note for every command below:** if the toolchain reports "read-only file system" for the Go cache, prefix commands with `GOCACHE=$TMPDIR/gocache` (re-export per command; never delete that shared directory), and with `GOFLAGS=-buildvcs=false` in a worktree. On a shared machine run them under `nice -n 19`. The sandbox refuses unix sockets ("socket: operation not permitted"): tests that start a child, prepare a nested role or dial a control socket must run with the sandbox off. |
| +**Sandbox note for every command below:** |
| +- If the toolchain reports "read-only file system" for the Go cache, prefix commands with `GOCACHE=$TMPDIR/gocache`. Re-export it per command, and never delete that shared directory. In a worktree, also set `GOFLAGS=-buildvcs=false`. |
| +- On a shared machine, run commands under `nice -n 19`. |
| +- The sandbox refuses unix sockets ("socket: operation not permitted"). Tests that start a child or prepare a nested role must run with the sandbox off. |
| +- `go test ./internal/host/ -run Claim` takes longer than go test's default 10-minute timeout; pass `-timeout 30m`. |
| --- |
| + |
| ### Task 1: The shared rule |
| **Files:** |
| @@ -509,7 +651,7 @@ and `ConfigKey: relocationConfigKey(cfg.DataDir, store, cfg.SecretsKey)` in `hos |
| - Modify: `internal/activator/admintoken.go:245-257` (`parseEnvBytes`), `internal/activator/backend_exec.go:1879-1905` (`LaunchEnvironment`), `:1980-1990` (`mergeEnv`) |
| **Interfaces:** |
| -- Produces: `instancekey.Var`, `AdoptionSuffix`, `AdoptionItem`, `ParseEnv`, `Layer` (`LayerNone`, `LayerOwn`, `LayerConfig`), `Effective`, `Digest`, `Adoption`, `AdoptionRecord`, `Adopted`, `ValidDigest`. |
| +- Produces: `instancekey.Var`, `AdoptionSuffix`, `AdoptionItem`, `ParseEnv`, `Layer` (`LayerNone`, `LayerOwn`, `LayerConfig`), `Effective`, `Digest`, `SourcePlatform`, `SourceConfig`, `SourceOf`, `Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `(Adoption).Binds`, `VerifyDelivered`, `ValidDigest`. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -548,30 +690,64 @@ func TestEffectiveNeverHandsOutAnEmptyKeyWhileOneExists(t *testing.T) { |
| } |
| } |
| -func TestAdoptionRecordVouchesOnlyForItsOwnKey(t *testing.T) { |
| +// Ruling 20 (R-B): a record binds one writer. Another session, another |
| +// generation, another installation or another host never matches, so a |
| +// later release that picks its own key, a rollback and a reinstall never |
| +// inherit an earlier writer's adoption. |
| +func TestAdoptionRecordBindsOneWriter(t *testing.T) { |
| key := strings.Repeat("A", 43) + "=" |
| - rec := AdoptionRecord(key) |
| - if !Adopted(rec, key) { |
| - t.Fatal("the record does not vouch for its own key") |
| - } |
| + w := Binding{Host: "shop.example.com", Incarnation: "inc-1", Session: 3, Generation: "g3"} |
| + rec := AdoptionRecord(w, SourcePlatform, Digest(key)) |
| if strings.Contains(string(rec), key) { |
| t.Fatal("the record holds the key itself") |
| } |
| + a, err := ParseAdoption(rec) |
| + if err != nil || !a.Binds(w) || a.Source != SourcePlatform || a.SHA256 != Digest(key) { |
| + t.Fatalf("ParseAdoption = %+v, %v", a, err) |
| + } |
| + for name, other := range map[string]Binding{ |
| + "an earlier session (an earlier release)": {Host: w.Host, Incarnation: w.Incarnation, Session: 2, Generation: "g2"}, |
| + "a later session (a rollback)": {Host: w.Host, Incarnation: w.Incarnation, Session: 4, Generation: "g4"}, |
| + "the same session, another generation": {Host: w.Host, Incarnation: w.Incarnation, Session: 3, Generation: "g3b"}, |
| + "another installation (a reinstall)": {Host: w.Host, Incarnation: "inc-2", Session: 3, Generation: "g3"}, |
| + "another host": {Host: "other.example.com", Incarnation: w.Incarnation, Session: 3, Generation: "g3"}, |
| + "no writer": {}, |
| + } { |
| + if a.Binds(other) { |
| + t.Errorf("%s: the record matched", name) |
| + } |
| + } |
| + for name, raw := range map[string]string{ |
| + "version 1": `{"v":1,"var":"CARLOS_INSTANCE_KEY","sha256":"` + Digest(key) + `"}`, |
| + "no binding": `{"v":2,"var":"CARLOS_INSTANCE_KEY","source":"platform","sha256":"` + Digest(key) + `"}`, |
| + "unknown source": `{"v":2,"var":"CARLOS_INSTANCE_KEY","host":"h","incarnation":"i","session":1,"generation":"g","source":"own","sha256":"` + Digest(key) + `"}`, |
| + "an unknown field": `{"v":2,"var":"CARLOS_INSTANCE_KEY","host":"h","incarnation":"i","session":1,"generation":"g","source":"config","sha256":"` + Digest(key) + `","key":"x"}`, |
| + "trailing data": string(rec) + `{}`, |
| + "garbage": "not json", |
| + } { |
| + if _, err := ParseAdoption([]byte(raw)); err == nil { |
| + t.Errorf("%s: parsed", name) |
| + } |
| + } |
| +} |
| + |
| +// O3': the install refuses env files that would deliver any key but the |
| +// pinned one, including a configuration changed since the pin. |
| +func TestVerifyDeliveredRefusesAnyOtherKey(t *testing.T) { |
| + a, b := strings.Repeat("A", 43)+"=", strings.Repeat("B", 43)+"=" |
| for _, tc := range []struct { |
| - name string |
| - record []byte |
| - key string |
| + name, config, own string |
| + ok bool |
| }{ |
| - {"another key", rec, strings.Repeat("B", 43) + "="}, |
| - {"an empty key", AdoptionRecord(""), ""}, |
| - {"a wrong version", []byte(`{"v":2,"var":"CARLOS_INSTANCE_KEY","sha256":"` + Digest(key) + `"}`), key}, |
| - {"another variable", []byte(`{"v":1,"var":"OTHER","sha256":"` + Digest(key) + `"}`), key}, |
| - {"an unknown field", []byte(`{"v":1,"var":"CARLOS_INSTANCE_KEY","sha256":"` + Digest(key) + `","extra":1}`), key}, |
| - {"trailing data", append(append([]byte(nil), rec...), []byte(`{}`)...), key}, |
| - {"garbage", []byte("not json"), key}, |
| + {"the configured key, unchanged", Var + "=" + a + "\n", "T=1\n", true}, |
| + {"the configuration changed since the pin", Var + "=" + b + "\n", "T=1\n", false}, |
| + {"the configured key removed", "", "T=1\n", false}, |
| + {"an empty override over the configured key", Var + "=" + a + "\n", Var + "=\n", true}, |
| + {"the platform key, restored", "", Var + "=" + a + "\n", true}, |
| + {"a platform key over the pinned configured one", Var + "=" + a + "\n", Var + "=" + b + "\n", false}, |
| } { |
| - if Adopted(tc.record, tc.key) { |
| - t.Errorf("%s: the record vouched", tc.name) |
| + if err := VerifyDelivered([]byte(tc.config), []byte(tc.own), Digest(a)); (err == nil) != tc.ok { |
| + t.Errorf("%s: err = %v", tc.name, err) |
| } |
| } |
| } |
| @@ -589,8 +765,9 @@ Create `internal/instancekey/instancekey.go`: |
| ```go |
| // Package instancekey is the one rule for the platform's per-instance key |
| // (region HA L21, rastrillo #66): how an env file is parsed, which file's |
| -// CARLOS_INSTANCE_KEY a launched app receives, and the adoption record that |
| -// is the evidence an app seals with that key. The activator mints and |
| +// CARLOS_INSTANCE_KEY a launched app receives, and the adoption record, a |
| +// writer-bound assertion from the app's helper that it seals with that |
| +// key. The activator mints and |
| // delivers the key by these rules, the control socket records adoption by |
| // them, and the relocation pinner checks its evidence by them, so the three |
| // can never disagree about what the app was given. |
| @@ -604,6 +781,8 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| + "fmt" |
| "strings" |
| ) |
| @@ -611,9 +790,9 @@ import ( |
| const Var = "CARLOS_INSTANCE_KEY" |
| // AdoptionSuffix names the adoption record beside an instance's own env |
| -// file: <host>.env's sibling <host>.instance-key-adoption.json. The flat |
| -// layout's naming rules make it the host's (layout.belongsTo), so the |
| -// layout pass carries it into the instance directory with the rest. |
| +// file: <host>.env's sibling <host>.instance-key-adoption.json. It is an |
| +// installation artifact: moves retire it with <host>.env (O7), and nested |
| +// it lives in the instance directory, retired with it. |
| const AdoptionSuffix = ".instance-key-adoption.json" |
| // AdoptionItem is the adoption record's name among a files manifest's |
| @@ -668,44 +847,103 @@ func Effective(config, own map[string]string) (string, Layer) { |
| return "", LayerNone |
| } |
| -// Digest is the lowercase hex SHA-256 of key: what an app sends to say |
| -// which key it adopted, and what the adoption record holds. The record is |
| -// published beside the env file that holds the key itself, so the digest |
| -// reveals nothing the same reader lacks. |
| +// Digest is the lowercase hex SHA-256 of key: what an app's helper sends |
| +// to say which key it resolved, and what the adoption record holds. The |
| +// record is published beside the env file that holds a platform key |
| +// itself, so the digest gives that file's reader nothing new. |
| func Digest(key string) string { |
| sum := sha256.Sum256([]byte(key)) |
| return hex.EncodeToString(sum[:]) |
| } |
| -// Adoption is the record the agent writes when an app reports that it |
| -// resolved its key from $CARLOS_INSTANCE_KEY (rastrillo's step 2), and the |
| -// key it resolved is the one in the instance's own <host>.env. |
| +// The two sources rastrillo's step 2 can have resolved. Both arrive as |
| +// $CARLOS_INSTANCE_KEY once the env files are merged. |
| +const ( |
| + SourcePlatform = "platform" // the instance's own <host>.env, where the platform mints |
| + SourceConfig = "config" // the rendered <host>.config.env, the app's configuration |
| +) |
| + |
| +// SourceOf names the source of a layer, "" for LayerNone. |
| +func SourceOf(l Layer) string { |
| + switch l { |
| + case LayerOwn: |
| + return SourcePlatform |
| + case LayerConfig: |
| + return SourceConfig |
| + } |
| + return "" |
| +} |
| + |
| +// Binding is the writer an adoption belongs to: the host, its installation |
| +// (the claim holder's incarnation), and the writer session and generation |
| +// the process was admitted as. The agent takes all four from its own |
| +// launch state for the process, never from the request. |
| +type Binding struct { |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Session uint64 `json:"session"` |
| + Generation string `json:"generation"` |
| +} |
| + |
| +func (b Binding) valid() bool { |
| + return b.Host != "" && b.Incarnation != "" && b.Session != 0 && b.Generation != "" |
| +} |
| + |
| +// Adoption is the record the agent writes when a process reports that its |
| +// helper resolved the key at step 2: which writer, which source, which |
| +// key (by digest). It is a trusted-helper assertion, not independent |
| +// evidence: it stops accidental loss, and a tenant who forges one can lose |
| +// only its own data (instance-key plan ruling 19). |
| type Adoption struct { |
| - V int `json:"v"` |
| - Var string `json:"var"` |
| + V int `json:"v"` |
| + Var string `json:"var"` |
| + Binding |
| + Source string `json:"source"` |
| SHA256 string `json:"sha256"` |
| } |
| -// AdoptionRecord is the canonical bytes of the record for key. |
| -func AdoptionRecord(key string) []byte { |
| - raw, _ := json.Marshal(Adoption{V: 1, Var: Var, SHA256: Digest(key)}) |
| +// AdoptionRecord is the canonical bytes of the record for writer b, which |
| +// was handed the key with digest sha from source. |
| +func AdoptionRecord(b Binding, source, sha string) []byte { |
| + raw, _ := json.Marshal(Adoption{V: 2, Var: Var, Binding: b, Source: source, SHA256: sha}) |
| return append(raw, '\n') |
| } |
| -// Adopted reports whether record is an adoption record for exactly key. A |
| -// record for another key, a malformed record or an empty key is false: a |
| -// record never vouches for a key it does not name. |
| -func Adopted(record []byte, key string) bool { |
| - if key == "" { |
| - return false |
| - } |
| +// ParseAdoption reads a record strictly: version 2, this variable, a |
| +// complete binding, a known source and a digest. Anything else is an |
| +// error, never a record. |
| +func ParseAdoption(record []byte) (Adoption, error) { |
| var a Adoption |
| dec := json.NewDecoder(bytes.NewReader(record)) |
| dec.DisallowUnknownFields() |
| - if err := dec.Decode(&a); err != nil || dec.More() { |
| - return false |
| + if err := dec.Decode(&a); err != nil { |
| + return Adoption{}, fmt.Errorf("instancekey: adoption record: %w", err) |
| + } |
| + if dec.More() || a.V != 2 || a.Var != Var || !a.Binding.valid() || (a.Source != SourcePlatform && a.Source != SourceConfig) || !ValidDigest(a.SHA256) { |
| + return Adoption{}, errors.New("instancekey: adoption record: not a version 2 record for " + Var) |
| + } |
| + return a, nil |
| +} |
| + |
| +// Binds reports whether the record belongs to exactly writer w. A record |
| +// from another installation, another session or another generation (an |
| +// earlier release, a rollback, a reinstall) never vouches for w. |
| +func (a Adoption) Binds(w Binding) bool { |
| + return w.valid() && a.Binding == w |
| +} |
| + |
| +// VerifyDelivered checks that the env files a relocation target is about |
| +// to launch with deliver the key pinned by digest (O3'): the effective key |
| +// of the rendered configuration and the restored own env file, by |
| +// Effective's rule. A configuration changed since the pin, a key removed, |
| +// or a platform key where a configured one was pinned, is an error; the |
| +// install refuses rather than hand the app a different key. |
| +func VerifyDelivered(configEnv, ownEnv []byte, pinnedSHA string) error { |
| + key, _ := Effective(ParseEnv(configEnv), ParseEnv(ownEnv)) |
| + if key == "" || Digest(key) != pinnedSHA { |
| + return errors.New("instancekey: the env files would not deliver the pinned " + Var) |
| } |
| - return a.V == 1 && a.Var == Var && a.SHA256 == Digest(key) |
| + return nil |
| } |
| // ValidDigest reports whether s is a lowercase hex SHA-256. |
| @@ -1892,16 +2130,16 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| --- |
| -### Task 4: The claim's launch source mints under the guard, and syncs before it answers |
| +### Task 4: The claim's launch source mints under the guard, syncs before it answers, and names the key it delivers |
| **Files:** |
| -- Modify: `internal/activator/claim_snapshot.go:40-67` (`EnsureClaimAdminToken`) |
| +- Modify: `internal/activator/claim_snapshot.go:17-25` (`ClaimLaunchSnapshot`), `:40-67` (`EnsureClaimAdminToken`), `:69-85` (`SnapshotClaimLaunch`'s env read) |
| - Modify: `internal/host/claimowners.go:125-138` (doc on `ClaimFilesManifest`, O1') |
| - Test: `internal/activator/claim_instancekey_test.go`, `internal/activator/claimretry_instancekey_linux_test.go`, `internal/host/claim_instancekey_test.go` (create all three) |
| **Interfaces:** |
| -- Consumes: Task 2's `ensureInstanceKeyFor`, `settleClaimInstanceEnv`, `errInstanceEnvNeedsWrite`, `probeInstanceKeySync`; `claimServingLaunchSource` (claimproduction.go:421), `claimServingOwners` (claimowners.go:293) with `Select` (:375) and `Materialize` (:425); test fixture `newAdminTokenFixture`, `adminTokenHost`, `f.envPath()`, `f.token()` (claimproduction_admintoken_test.go:20-118); `leasetest.NewVersionedBucket`; `Service.startAdmitted` (claimwake.go:330) as `claimloop_linux_test.go` drives it. |
| -- Produces: `EnsureClaimAdminToken` initializes the token and the key inside the one authorized write, and syncs a present key before it answers. |
| +- Consumes: Task 1's `instancekey.Effective`, `Digest`, `SourceOf`; Task 2's `ensureInstanceKeyFor`, `settleClaimInstanceEnv`, `errInstanceEnvNeedsWrite`, `probeInstanceKeySync`; `instanceEnvParts` (backend_exec.go:1836); `claimServingLaunchSource` (claimproduction.go:421), `claimServingOwners` (claimowners.go:293) with `Select` (:375) and `Materialize` (:425); test fixture `newAdminTokenFixture`, `adminTokenHost`, `f.envPath()`, `f.token()` (claimproduction_admintoken_test.go:20-118); `leasetest.NewVersionedBucket`; `Service.startAdmitted` (claimwake.go:330) as `claimloop_linux_test.go` drives it. |
| +- Produces: `EnsureClaimAdminToken` initializes the token and the key inside the one authorized write, and syncs a present key before it answers. `ClaimLaunchSnapshot.InstanceKeySHA256` and `InstanceKeySource` name the key the snapshot's env delivers, for O6 (ruling 21). |
| - [ ] **Step 1: Write the failing tests** |
| @@ -2050,6 +2288,7 @@ package host |
| import ( |
| "context" |
| "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| @@ -2057,6 +2296,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| ) |
| @@ -2149,16 +2389,68 @@ func TestClaimLaunchMintsAKeyBesideAnExistingToken(t *testing.T) { |
| t.Fatal("a retried launch changed the env file or the launch identity") |
| } |
| } |
| + |
| +// Ruling 21: the claim snapshot names the key its env delivers, by digest, |
| +// and the file that supplied it, for O6 to check a process's report |
| +// against. The identity is the env's alone, so it does not change. |
| +func TestClaimSnapshotNamesTheDeliveredKeyAndItsSource(t *testing.T) { |
| + f := newAdminTokenFixture(t, "box-1") |
| + first, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + snap, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + own, _ := os.ReadFile(f.envPath()) |
| + var key string |
| + for _, line := range strings.Split(string(own), "\n") { |
| + if v, ok := strings.CutPrefix(line, activator.InstanceKeyVar+"="); ok { |
| + key = v |
| + } |
| + } |
| + if snap.InstanceKeySource != instancekey.SourcePlatform || snap.InstanceKeySHA256 != instancekey.Digest(key) || snap.Identity != first.Identity { |
| + t.Fatalf("platform key: source %q, digest match %v, identity kept %v", snap.InstanceKeySource, snap.InstanceKeySHA256 == instancekey.Digest(key), snap.Identity == first.Identity) |
| + } |
| + tok := "CARLOS_ADMIN_TOKEN=" + f.token() + "\n" |
| + if err := os.WriteFile(f.envPath(), []byte(tok+activator.InstanceKeyVar+"=\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"=from-config\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + snap, err = f.backend.SnapshotClaimLaunch(t.Context(), f.rt) |
| + if err != nil || snap.InstanceKeySource != instancekey.SourceConfig || snap.InstanceKeySHA256 != instancekey.Digest("from-config") { |
| + t.Fatalf("configured key: source %q, %v", snap.InstanceKeySource, err) |
| + } |
| +} |
| ``` |
| - [ ] **Step 2: Run them to verify they fail** |
| -Run: `nice -n 19 go test ./internal/activator/ -run 'TestEnsureClaimAdminTokenSyncs|TestStartAdmittedRetriesReuse' -count=1 -v && nice -n 19 go test ./internal/host/ -run 'TestClaimFirstWakeKey|TestClaimLaunchMintsAKey' -count=1 -v` |
| -Expected (checked in the scratch build): FAIL for `TestEnsureClaimAdminTokenSyncsAPresentKeyBeforeItAnswers` (the early return answers without a sync), `TestClaimFirstWakeKeyIsOnDiskBeforeTheFilesManifest` ("the env file at Establish carries no minted key") and `TestClaimLaunchMintsAKeyBesideAnExistingToken` (the token-only file is returned early). `TestStartAdmittedRetriesReuseTheirInputsAndMaterializeFirst` passes: it pins existing behaviour (ruling 16). |
| +Run: `nice -n 19 go test ./internal/activator/ -run 'TestEnsureClaimAdminTokenSyncs|TestStartAdmittedRetriesReuse' -count=1 -v && nice -n 19 go test ./internal/host/ -run 'TestClaimFirstWakeKey|TestClaimLaunchMintsAKey|TestClaimSnapshotNames' -count=1 -v` |
| +Expected, as checked in the scratch build: |
| +- `TestEnsureClaimAdminTokenSyncsAPresentKeyBeforeItAnswers` fails: the early return answers without a sync. |
| +- `TestStartAdmittedRetriesReuseTheirInputsAndMaterializeFirst` passes: it pins existing behaviour (ruling 16). |
| +- The host package fails to build: `snap.InstanceKeySHA256 undefined`. |
| -- [ ] **Step 3: Mint and sync in the launch source** |
| +- [ ] **Step 3: Mint, sync, and name the key in the launch source** |
| + |
| +In `internal/activator/claim_snapshot.go`, add `"errors"` and `"github.com/carlosframework/platform/internal/instancekey"` to the imports, and add the two fields to `ClaimLaunchSnapshot`: |
| + |
| +```go |
| + Roles []string |
| + Limit cgroup.Limit |
| + // InstanceKeySHA256 and InstanceKeySource name the CARLOS_INSTANCE_KEY |
| + // Env delivers (its digest) and the file that supplied it |
| + // (instancekey.SourcePlatform or SourceConfig); both empty when Env |
| + // delivers none. O6 hands them to the process's adoption verb. |
| + InstanceKeySHA256, InstanceKeySource string |
| +} |
| +``` |
| -In `internal/activator/claim_snapshot.go`, add `"errors"` to the imports and replace the body from the early return to the end of `EnsureClaimAdminToken`: |
| +Replace the body of `EnsureClaimAdminToken` from the early return to its end: |
| ```go |
| // The instance key is initialized the same way and in the same |
| @@ -2193,31 +2485,49 @@ In `internal/activator/claim_snapshot.go`, add `"errors"` to the imports and rep |
| } |
| ``` |
| -Leave `SnapshotClaimLaunch` read-only and unchanged. It never mints, and existing tests pin that. |
| +In `SnapshotClaimLaunch`, read the two layers, keep `instanceEnv`'s merge, and record the delivered key. The identity is computed from `Env` as before, so it does not change: |
| + |
| +```go |
| + cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| + if err != nil { |
| + return out, err |
| + } |
| + vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept |
| + if vars["CARLOS_ADMIN_TOKEN"] == "" { |
| + return out, hostwrite.ErrUnavailable |
| + } |
| + // The key this launch delivers and the file that supplied it: what the |
| + // process's adoption report is checked against (ruling 21, O6). Not in |
| + // the identity: the env already is. |
| + if key, layer := instancekey.Effective(cfgEnv, ownEnv); key != "" { |
| + out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer) |
| + } |
| +``` |
| -In `internal/host/claimowners.go`, extend the `ClaimFilesManifest` doc comment: |
| +In `internal/host/claimowners.go`, extend the `ClaimFilesManifest` doc comment (O1'): |
| ```go |
| // bootstrap; only an empty bootstrap (in.Empty, after EmptyAuthority) starts |
| // from no manifest. |
| // |
| -// The instance key (L21, plan 2026-10-10-instance-key-provision.md ruling |
| -// 6, obligation O1'): the launch source has already minted |
| +// The instance key (L21, plan 2026-10-10-instance-key-provision.md |
| +// obligation O1'): the launch source has already minted |
| // CARLOS_INSTANCE_KEY into the host's own <host>.env, durably, when |
| // Establish runs, and the app executes only after it returns. So the |
| -// manifest Establish leaves must carry two platform items with exactly the |
| -// bytes on disk when it runs, staged and committed before it returns: the |
| -// env file (region HA §5.6's always-continuous item) and the key's |
| -// adoption record, <host>.instance-key-adoption.json beside it |
| -// (instancekey.AdoptionItem; an absent file is an absent item). An error, |
| -// a bucket that cannot be reached included, fails the wake and the app |
| -// does not start. Establish never writes either file on disk. |
| +// manifest Establish leaves must carry the env file (region HA §5.6's |
| +// always-continuous platform item) with exactly the bytes on disk when it |
| +// runs, staged and committed before it returns. A new admission starts |
| +// with no adoption record: the manifest carries no instancekey.AdoptionItem |
| +// (StagePlatformDelete), and the local <host>.instance-key-adoption.json is |
| +// removed under work. An error, a bucket that cannot be reached included, |
| +// fails the wake and the app does not start. Establish never writes the env |
| +// file on disk. |
| type ClaimFilesManifest interface { |
| ``` |
| - [ ] **Step 4: Run the tests, then the claim tests around them** |
| -Run: `nice -n 19 go test ./internal/host/ -run 'InstanceKey|AdminToken|Rev2Shape|SnapshotClaimLaunch' -count=1` |
| +Run: `nice -n 19 go test ./internal/host/ -run 'InstanceKey|ClaimSnapshot|AdminToken|Rev2Shape|SnapshotClaimLaunch' -count=1` |
| Expected: PASS, including `TestClaimProductionAdminTokenRefusesWithoutAuthority`: without authority no `<host>.env` is written, so no key either. |
| Run: `nice -n 19 go test ./internal/activator/ -run 'Claim|StartAdmitted' -count=1` |
| @@ -2228,26 +2538,108 @@ Expected: PASS, including `TestEnsureClaimAdminTokenInspectsSidecarBeforeAuthori |
| ```bash |
| gofmt -l internal/activator internal/host |
| git add internal/activator/claim_snapshot.go internal/activator/claim_instancekey_test.go internal/activator/claimretry_instancekey_linux_test.go internal/host/claimowners.go internal/host/claim_instancekey_test.go |
| -git commit -m "claim: the launch source mints the instance key under the guard and syncs it before Establish (L21) |
| +git commit -m "claim: the launch source mints the instance key under the guard, syncs it before Establish, and names it (L21) |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |
| --- |
| -### Task 5: The adoption verb |
| +### Task 5: The adoption verb and its publication |
| + |
| +The verb is built and tested here and mounted nowhere on main (ruling 21). O6 mounts it on claim launches, after O7 (ruling 24). |
| **Files:** |
| -- Create: `internal/activator/keyadoption.go` |
| -- Modify: `internal/activator/backend_exec.go:1406-1414` (`spawnChildFrom`'s mount), `internal/activator/prepare.go:143-148` (`listenControl`'s mount) |
| -- Test: `internal/activator/keyadoption_test.go`, `internal/activator/keyadoption_linux_test.go` |
| +- Create: `internal/hostfiles/relay.go`, `internal/activator/keyadoption.go` |
| +- Test: `internal/hostfiles/relay_test.go`, `internal/activator/keyadoption_test.go`, `internal/activator/keyadoption_linux_test.go`, `internal/host/instancekey_adoption_test.go` |
| **Interfaces:** |
| -- Consumes: Task 1's `instancekey`; Task 2's `configEnvName`, `syncDir`; `adminTokenMu`; `instancefs.WriteAtomic`, `safefile.WriteFile` (which fsyncs the file and its directory); `nestedBackend`. |
| -- Produces: `PUT /instance-key/adoption` on every control socket the activator binds; `(*ExecBackend).mountInstanceKeyAdoption`, `recordInstanceKeyAdoption`; the seam `adoptionRecordSync`. |
| +- Consumes: Task 1's `instancekey` (`Binding`, `AdoptionRecord`, `ParseAdoption`, `ValidDigest`, the sources, `AdoptionSuffix`, `AdoptionItem`); Task 2's `syncDir`; `adminTokenMu`; `instancefs.WriteAtomic`, `instancefs.ReadBeneathLimit`, `safefile.WriteFile` (which fsyncs the file and its directory); `hostfiles.Open`, `Run`, `Options`, `ReadPlatform`, `ManifestKey`, `(*Publisher).StagePlatform`. |
| +- Produces: `activator.AdoptionLaunch`, `(*ExecBackend).MountInstanceKeyAdoption`; `hostfiles.PlatformStager`, `hostfiles.PlatformRelay` (`Publish`, `Run`); the test seam `adoptionRecordSync`. |
| - [ ] **Step 1: Write the failing tests** |
| +Create `internal/hostfiles/relay_test.go`: |
| + |
| +```go |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type flakyStager struct { |
| + mu sync.Mutex |
| + fail int |
| + staged [][]byte |
| + block chan struct{} |
| +} |
| + |
| +func (f *flakyStager) StagePlatform(_ context.Context, name string, body []byte) error { |
| + if f.block != nil { |
| + <-f.block |
| + } |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if f.fail > 0 { |
| + f.fail-- |
| + return errors.New("store unreachable") |
| + } |
| + f.staged = append(f.staged, append([]byte(nil), body...)) |
| + return nil |
| +} |
| + |
| +func (f *flakyStager) last() []byte { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + if len(f.staged) == 0 { |
| + return nil |
| + } |
| + return f.staged[len(f.staged)-1] |
| +} |
| + |
| +// Ruling 22: Publish never waits on the store; Run stages the latest body, |
| +// retrying a failed stage, and stages nothing again once it has. |
| +func TestPlatformRelayStagesTheLatestBodyWithoutBlockingItsCaller(t *testing.T) { |
| + st := &flakyStager{fail: 2, block: make(chan struct{})} |
| + r := &PlatformRelay{Name: "instance-key-adoption.json", Stage: st, Retry: 10 * time.Millisecond} |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- r.Run(ctx) }() |
| + published := make(chan struct{}) |
| + go func() { r.Publish([]byte("one")); r.Publish([]byte("two")); close(published) }() |
| + select { |
| + case <-published: |
| + case <-time.After(time.Second): |
| + t.Fatal("Publish waited on a store that does not answer") |
| + } |
| + close(st.block) |
| + deadline := time.Now().Add(2 * time.Second) |
| + for string(st.last()) != "two" { |
| + if time.Now().After(deadline) { |
| + t.Fatalf("staged %q, want the latest body after two failed stages", st.last()) |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + time.Sleep(50 * time.Millisecond) |
| + st.mu.Lock() |
| + n := len(st.staged) |
| + st.mu.Unlock() |
| + if n != 1 { |
| + t.Fatalf("staged %d times, want once", n) |
| + } |
| + cancel() |
| + if err := <-done; !errors.Is(err, context.Canceled) { |
| + t.Fatalf("Run = %v", err) |
| + } |
| +} |
| +``` |
| + |
| Create `internal/activator/keyadoption_test.go`: |
| ```go |
| @@ -2265,96 +2657,112 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| -// adoptionMux serves the adoption verb for rt on b, with the token read |
| -// from the host's own env file as the real listener reads it. |
| -func adoptionMux(b *ExecBackend, rt registry.Route) *http.ServeMux { |
| - mux := http.NewServeMux() |
| - b.mountInstanceKeyAdoption(mux, rt, func() (string, error) { |
| - env, err := readEnvFile(filepath.Join(b.DataDir, rt.Host+".env")) |
| - return env["CARLOS_ADMIN_TOKEN"], err |
| - }) |
| - return mux |
| +// adoptionFixture is one claim-launched process's control socket, served |
| +// in memory: its launch state, and every record handed to the publisher. |
| +type adoptionFixture struct { |
| + mux *http.ServeMux |
| + launch AdoptionLaunch |
| + hasLaunch bool |
| + published [][]byte |
| +} |
| + |
| +func newAdoptionFixture(t *testing.T, rt registry.Route, tok, key, source string) *adoptionFixture { |
| + t.Helper() |
| + f := &adoptionFixture{hasLaunch: true} |
| + f.launch = AdoptionLaunch{ |
| + Writer: instancekey.Binding{Host: rt.Host, Incarnation: "inc-1", Session: 3, Generation: "g3"}, |
| + KeySHA256: instancekey.Digest(key), Source: source, Dir: t.TempDir(), |
| + Publish: func(rec []byte) { f.published = append(f.published, rec) }, |
| + } |
| + f.mux = http.NewServeMux() |
| + (&ExecBackend{}).MountInstanceKeyAdoption(f.mux, rt, func() (string, error) { return tok, nil }, |
| + func() (AdoptionLaunch, bool) { return f.launch, f.hasLaunch }) |
| + return f |
| } |
| -func putAdoption(mux *http.ServeMux, token, body string) int { |
| +func (f *adoptionFixture) put(token, body string) int { |
| req := httptest.NewRequest(http.MethodPut, instanceKeyAdoptionPath, strings.NewReader(body)) |
| if token != "" { |
| req.Header.Set("Authorization", "Bearer "+token) |
| } |
| w := httptest.NewRecorder() |
| - mux.ServeHTTP(w, req) |
| + f.mux.ServeHTTP(w, req) |
| return w.Code |
| } |
| -// Finding 1, source (b): the agent records adoption only for the key in |
| -// the instance's own <host>.env, only for the key the app names, and only |
| -// with the instance's bearer. A configured key is acknowledged and not |
| -// recorded (source c is its evidence); a replica records nothing. |
| -func TestInstanceKeyAdoptionRecordsOnlyTheOwnFilesKey(t *testing.T) { |
| - b := &ExecBackend{DataDir: t.TempDir()} |
| +func adoptionBody(key, source string) string { |
| + return `{"sha256":"` + instancekey.Digest(key) + `","source":"` + source + `"}` |
| +} |
| + |
| +// Rulings 20 and 21: the agent records the launch's own writer and the key |
| +// the launch delivered, from its own launch state. A request cannot name a |
| +// writer, a digest or a source the launch did not have. |
| +func TestInstanceKeyAdoptionRecordsTheLaunchesWriter(t *testing.T) { |
| tok := strings.Repeat("f", 64) |
| key := strings.Repeat("K", 43) + "=" |
| - rt := registry.Route{Host: "shop.example.com", Kind: "instance", Socket: "/run/carlos/shop.sock"} |
| - if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("CARLOS_ADMIN_TOKEN="+tok+"\n"+InstanceKeyVar+"="+key+"\n"), 0o600); err != nil { |
| - t.Fatal(err) |
| - } |
| - mux := adoptionMux(b, rt) |
| - recPath := filepath.Join(b.DataDir, rt.Host+instancekey.AdoptionSuffix) |
| - body := func(k string) string { return `{"sha256":"` + instancekey.Digest(k) + `"}` } |
| + rt := registry.Route{Host: "shop.example.com", Kind: "instance"} |
| + f := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) |
| + recPath := filepath.Join(f.launch.Dir, rt.Host+instancekey.AdoptionSuffix) |
| for _, tc := range []struct { |
| name, token, body string |
| want int |
| }{ |
| - {"no bearer", "", body(key), http.StatusUnauthorized}, |
| - {"another host's bearer", strings.Repeat("0", 64), body(key), http.StatusUnauthorized}, |
| - {"not a digest", tok, `{"sha256":"abc"}`, http.StatusBadRequest}, |
| - {"an unknown field", tok, `{"sha256":"` + instancekey.Digest(key) + `","key":"x"}`, http.StatusBadRequest}, |
| - {"another key", tok, body("some-other-key"), http.StatusConflict}, |
| + {"no bearer", "", adoptionBody(key, "platform"), http.StatusUnauthorized}, |
| + {"another host's bearer", strings.Repeat("0", 64), adoptionBody(key, "platform"), http.StatusUnauthorized}, |
| + {"not a digest", tok, `{"sha256":"abc","source":"platform"}`, http.StatusBadRequest}, |
| + {"an unknown source", tok, adoptionBody(key, "own"), http.StatusBadRequest}, |
| + {"a session in the request", tok, `{"sha256":"` + instancekey.Digest(key) + `","source":"platform","session":9}`, http.StatusBadRequest}, |
| + {"another key", tok, adoptionBody("some-other-key", "platform"), http.StatusConflict}, |
| + {"another source", tok, adoptionBody(key, "config"), http.StatusConflict}, |
| } { |
| - if got := putAdoption(mux, tc.token, tc.body); got != tc.want { |
| + if got := f.put(tc.token, tc.body); got != tc.want { |
| t.Fatalf("%s: %d, want %d", tc.name, got, tc.want) |
| } |
| - if _, err := os.Stat(recPath); !os.IsNotExist(err) { |
| - t.Fatalf("%s: a record was written", tc.name) |
| + if _, err := os.Stat(recPath); !os.IsNotExist(err) || len(f.published) != 0 { |
| + t.Fatalf("%s: a record was written or published", tc.name) |
| } |
| } |
| - for i := 0; i < 2; i++ { // the app's helper calls once per process start |
| - if got := putAdoption(mux, tok, body(key)); got != http.StatusNoContent { |
| - t.Fatalf("adoption %d: %d, want 204", i, got) |
| + want := instancekey.AdoptionRecord(f.launch.Writer, instancekey.SourcePlatform, instancekey.Digest(key)) |
| + for i := 0; i < 2; i++ { // the helper reports on every process start |
| + if got := f.put(tok, adoptionBody(key, "platform")); got != http.StatusNoContent { |
| + t.Fatalf("report %d: %d, want 204", i, got) |
| } |
| rec, err := os.ReadFile(recPath) |
| - if err != nil || string(rec) != string(instancekey.AdoptionRecord(key)) { |
| - t.Fatalf("adoption %d: record = %q, %v", i, rec, err) |
| + if err != nil || string(rec) != string(want) { |
| + t.Fatalf("report %d: record = %q, %v", i, rec, err) |
| } |
| if st, _ := os.Stat(recPath); st.Mode().Perm() != 0o600 { |
| t.Fatalf("record mode = %o, want 600", st.Mode().Perm()) |
| } |
| + if len(f.published) != i+1 || string(f.published[i]) != string(want) { |
| + t.Fatalf("report %d: published %d records", i, len(f.published)) |
| + } |
| } |
| - cfgHost := registry.Route{Host: "cfg.example.com", Kind: "instance", Socket: "/run/carlos/cfg.sock"} |
| - if err := os.WriteFile(filepath.Join(b.DataDir, cfgHost.Host+".env"), []byte("CARLOS_ADMIN_TOKEN="+tok+"\n"+InstanceKeyVar+"=\n"), 0o600); err != nil { |
| - t.Fatal(err) |
| - } |
| - if err := os.WriteFile(filepath.Join(b.DataDir, cfgHost.Host+".config.env"), []byte(InstanceKeyVar+"=from-config\n"), 0o600); err != nil { |
| - t.Fatal(err) |
| - } |
| - if got := putAdoption(adoptionMux(b, cfgHost), tok, body("from-config")); got != http.StatusNoContent { |
| + // A configured key is recorded the same way, with its source (ruling 20). |
| + cfg := newAdoptionFixture(t, rt, tok, "from-config", instancekey.SourceConfig) |
| + if got := cfg.put(tok, adoptionBody("from-config", "config")); got != http.StatusNoContent { |
| t.Fatalf("configured key: %d, want 204", got) |
| } |
| - if _, err := os.Stat(filepath.Join(b.DataDir, cfgHost.Host+instancekey.AdoptionSuffix)); !os.IsNotExist(err) { |
| - t.Fatal("a configured key was recorded as a platform key adoption") |
| + if a, err := instancekey.ParseAdoption(cfg.published[0]); err != nil || a.Source != instancekey.SourceConfig || !a.Binds(cfg.launch.Writer) { |
| + t.Fatalf("configured key's record = %+v, %v", a, err) |
| } |
| - replica := rt |
| - replica.Replica = true |
| - if err := os.Remove(recPath); err != nil { |
| - t.Fatal(err) |
| + // No launch state (a legacy launch) is the 404 an agent without the |
| + // verb answers; a replica refuses (ruling 17). |
| + none := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) |
| + none.hasLaunch = false |
| + replicaRT := rt |
| + replicaRT.Replica = true |
| + replica := newAdoptionFixture(t, replicaRT, tok, key, instancekey.SourcePlatform) |
| + if got := none.put(tok, adoptionBody(key, "platform")); got != http.StatusNotFound { |
| + t.Fatalf("no launch state: %d, want 404", got) |
| } |
| - if got := putAdoption(adoptionMux(b, replica), tok, body(key)); got != http.StatusConflict { |
| + if got := replica.put(tok, adoptionBody(key, "platform")); got != http.StatusConflict { |
| t.Fatalf("replica: %d, want 409", got) |
| } |
| - if _, err := os.Stat(recPath); !os.IsNotExist(err) { |
| - t.Fatal("a replica recorded adoption") |
| + if len(none.published)+len(replica.published) != 0 { |
| + t.Fatal("a record was published without a writer") |
| } |
| } |
| ``` |
| @@ -2365,95 +2773,284 @@ Create `internal/activator/keyadoption_linux_test.go`: |
| package activator |
| import ( |
| - "context" |
| "errors" |
| - "net" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strings" |
| "testing" |
| - "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/instancekey" |
| - "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| -// The adoption verb is served on the nested control socket, and a nested |
| -// record is not acknowledged until its directory sync succeeds. |
| -func TestNestedControlSocketRecordsAdoptionDurably(t *testing.T) { |
| - b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| - _, ctl, logs, env, err := b.prepare(rt, instancectl.RoleRun) |
| - if err != nil { |
| - t.Fatalf("prepare: %v", err) |
| - } |
| - logs.Close() |
| - if ctl == nil { |
| - t.Fatal("no control listener") |
| - } |
| - defer ctl.Close() |
| - client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| - var d net.Dialer |
| - return d.DialContext(ctx, "unix", rt.Socket+".control") |
| - }}} |
| - put := func() int { |
| - req, _ := http.NewRequest(http.MethodPut, "http://carlos"+instanceKeyAdoptionPath, strings.NewReader(`{"sha256":"`+instancekey.Digest(env[InstanceKeyVar])+`"}`)) |
| - req.Header.Set("Authorization", "Bearer "+env["CARLOS_ADMIN_TOKEN"]) |
| - resp, err := client.Do(req) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - resp.Body.Close() |
| - return resp.StatusCode |
| - } |
| +// Ruling 22 (R-C): 204 means the record is durable on disk. A nested |
| +// record whose directory sync fails answers 500 and publishes nothing; the |
| +// next report writes it and publishes it. |
| +func TestInstanceKeyAdoptionIsDurableBeforeItAnswers(t *testing.T) { |
| + tok := strings.Repeat("f", 64) |
| + key := strings.Repeat("K", 43) + "=" |
| + rt := registry.Route{Host: "shop.example.com", Kind: "instance"} |
| + f := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform) |
| + f.launch.Beneath = true |
| adminTokenMu.Lock() |
| old := adoptionRecordSync |
| adoptionRecordSync = func(string) error { return errors.New("injected: EIO") } |
| adminTokenMu.Unlock() |
| - if code := put(); code != http.StatusInternalServerError { |
| - t.Fatalf("PUT with a failing directory sync = %d, want 500", code) |
| + if got := f.put(tok, adoptionBody(key, "platform")); got != http.StatusInternalServerError { |
| + t.Fatalf("a failing directory sync: %d, want 500", got) |
| + } |
| + if len(f.published) != 0 { |
| + t.Fatal("a record that is not durable was published") |
| } |
| adminTokenMu.Lock() |
| adoptionRecordSync = old |
| adminTokenMu.Unlock() |
| - if code := put(); code != http.StatusNoContent { |
| - t.Fatalf("PUT = %d, want 204", code) |
| + if got := f.put(tok, adoptionBody(key, "platform")); got != http.StatusNoContent { |
| + t.Fatalf("retry: %d, want 204", got) |
| + } |
| + rec, err := os.ReadFile(filepath.Join(f.launch.Dir, rt.Host+instancekey.AdoptionSuffix)) |
| + if err != nil || len(f.published) != 1 || string(rec) != string(f.published[0]) { |
| + t.Fatalf("after the retry: record %q (%v), %d published", rec, err, len(f.published)) |
| + } |
| +} |
| +``` |
| + |
| +Create `internal/host/instancekey_adoption_test.go`, the box-loss test of ruling 22: |
| + |
| +```go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Ruling 22 (R-C): an adoption is published by the running session's |
| +// publisher, with no admission or release after it. The box is then lost: |
| +// every process and every local file is gone. The data bucket's manifest |
| +// for the session's generation still holds the record, bound to the |
| +// session's writer, which is what O2' pins. |
| +func TestAdoptionSurvivesBoxLossWithoutAnotherAdmissionOrRelease(t *testing.T) { |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + defer cancel() |
| + data := release.NewDirStore(t.TempDir()) |
| + writer := instancekey.Binding{Host: "shop.example.com", Incarnation: "inc-1", Session: 3, Generation: "g3"} |
| + prefix := "instances/" + writer.Host + "/" + writer.Incarnation + "/files/" + writer.Generation + "/" |
| + pub, err := hostfiles.Open(ctx, data, prefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + box := t.TempDir() |
| + filesDir, envDir := filepath.Join(box, "files"), filepath.Join(box, "env") |
| + for _, d := range []string{filesDir, envDir} { |
| + if err := os.MkdirAll(d, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // The session's continuous publisher and the adoption relay. |
| + running := make(chan error, 2) |
| + go func() { |
| + running <- hostfiles.Run(ctx, pub, filesDir, hostfiles.Options{Deadline: 40 * time.Millisecond, Scan: time.Minute, Poll: 40 * time.Millisecond}) |
| + }() |
| + relay := &hostfiles.PlatformRelay{Name: instancekey.AdoptionItem, Stage: pub, Retry: 10 * time.Millisecond} |
| + go func() { running <- relay.Run(ctx) }() |
| + |
| + key := strings.Repeat("K", 43) + "=" |
| + tok := strings.Repeat("f", 64) |
| + launch := activator.AdoptionLaunch{Writer: writer, KeySHA256: instancekey.Digest(key), Source: instancekey.SourcePlatform, Dir: envDir, Publish: relay.Publish} |
| + mux := http.NewServeMux() |
| + (&activator.ExecBackend{}).MountInstanceKeyAdoption(mux, registry.Route{Host: writer.Host, Kind: "instance"}, |
| + func() (string, error) { return tok, nil }, func() (activator.AdoptionLaunch, bool) { return launch, true }) |
| + req := httptest.NewRequest(http.MethodPut, "/instance-key/adoption", strings.NewReader(`{"sha256":"`+instancekey.Digest(key)+`","source":"platform"}`)) |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, req) |
| + if w.Code != http.StatusNoContent { |
| + t.Fatalf("adoption = %d, want 204", w.Code) |
| + } |
| + |
| + // Within the publisher's continuous bound the record is committed. |
| + readRecord := func() ([]byte, error) { |
| + raw, _, err := data.Get(context.Background(), hostfiles.ManifestKey(prefix)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var m hostfiles.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, err |
| + } |
| + return hostfiles.ReadPlatform(context.Background(), data, prefix, m, instancekey.AdoptionItem, nil) |
| + } |
| + deadline := time.Now().Add(2 * time.Second) |
| + for { |
| + if _, err := readRecord(); err == nil { |
| + break |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatal("the adoption was not published within the continuous bound") |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + |
| + // Box loss: the processes stop and the disk is gone. |
| + cancel() |
| + <-running |
| + <-running |
| + if err := os.RemoveAll(box); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := readRecord() |
| + if err != nil { |
| + t.Fatalf("after box loss: %v", err) |
| } |
| - rec, err := os.ReadFile(filepath.Join(provision.InstanceDir(b.DataDir, rt.Host), rt.Host+instancekey.AdoptionSuffix)) |
| - if err != nil || !instancekey.Adopted(rec, env[InstanceKeyVar]) { |
| - t.Fatalf("no adoption record for the instance's key beside the nested env file: %v", err) |
| + a, err := instancekey.ParseAdoption(rec) |
| + if err != nil || !a.Binds(writer) || a.SHA256 != instancekey.Digest(key) || a.Source != instancekey.SourcePlatform { |
| + t.Fatalf("after box loss the bucket holds %+v (%v), want the session's record", a, err) |
| } |
| } |
| ``` |
| - [ ] **Step 2: Run them to verify they fail** |
| -Run: `nice -n 19 go test ./internal/activator/ -run 'Adoption' -count=1` |
| -Expected: build failure, `undefined: mountInstanceKeyAdoption` (and `instanceKeyAdoptionPath`, `adoptionRecordSync`). |
| +Run: `nice -n 19 go test ./internal/hostfiles/ -run TestPlatformRelay -count=1; nice -n 19 go test ./internal/activator/ -run Adoption -count=1; nice -n 19 go test ./internal/host/ -run TestAdoptionSurvivesBoxLoss -count=1` |
| +Expected: build failures: `undefined: PlatformRelay`, `undefined: MountInstanceKeyAdoption` (and `AdoptionLaunch`, `instanceKeyAdoptionPath`, `adoptionRecordSync`). |
| + |
| +- [ ] **Step 3: Write the relay** |
| + |
| +Create `internal/hostfiles/relay.go`: |
| + |
| +```go |
| +package hostfiles |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "sync" |
| + "time" |
| +) |
| + |
| +// PlatformStager is the part of a session's Publisher a PlatformRelay |
| +// needs. *Publisher implements it. |
| +type PlatformStager interface { |
| + StagePlatform(ctx context.Context, name string, body []byte) error |
| +} |
| + |
| +// PlatformRelay hands one platform item to a running session's publisher |
| +// without making its caller wait on the store (instance-key plan ruling |
| +// 22). Publish records the latest body and returns at once; Run stages it |
| +// with StagePlatform, retrying until it is staged, and the publisher's own |
| +// continuous loop (Run in run.go) commits it within its deadline, as it |
| +// commits every staged change. A body staged once is not staged again |
| +// unless Publish is called with it again. |
| +type PlatformRelay struct { |
| + Name string |
| + Stage PlatformStager |
| + Retry time.Duration // between failed stages; 0 is one second |
| + |
| + mu sync.Mutex |
| + pending []byte |
| + wake chan struct{} |
| +} |
| -- [ ] **Step 3: Write the verb** |
| +func (r *PlatformRelay) signal() chan struct{} { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.wake == nil { |
| + r.wake = make(chan struct{}, 1) |
| + } |
| + return r.wake |
| +} |
| + |
| +// Publish queues body as the item's next value. It never blocks. |
| +func (r *PlatformRelay) Publish(body []byte) { |
| + w := r.signal() |
| + r.mu.Lock() |
| + r.pending = append([]byte(nil), body...) |
| + r.mu.Unlock() |
| + select { |
| + case w <- struct{}{}: |
| + default: |
| + } |
| +} |
| + |
| +// Run stages queued bodies until ctx ends, and returns ctx.Err(). |
| +func (r *PlatformRelay) Run(ctx context.Context) error { |
| + retry := r.Retry |
| + if retry <= 0 { |
| + retry = time.Second |
| + } |
| + w := r.signal() |
| + for { |
| + r.mu.Lock() |
| + body := r.pending |
| + r.mu.Unlock() |
| + if body != nil { |
| + if err := r.Stage.StagePlatform(ctx, r.Name, body); err != nil { |
| + log.Printf("hostfiles: stage platform item %s: %v (retrying)", r.Name, err) |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(retry): |
| + } |
| + continue |
| + } |
| + r.mu.Lock() |
| + if string(r.pending) == string(body) { |
| + r.pending = nil |
| + } |
| + r.mu.Unlock() |
| + continue |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-w: |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Write the verb** |
| Create `internal/activator/keyadoption.go`: |
| ```go |
| package activator |
| -// The instance key's adoption record (instance-key plan rev 2, ruling 14). |
| -// A platform key in <host>.env proves only that the platform minted one: an |
| -// app with a key of its own keeps it (rastrillo's step 1), and its data is |
| -// sealed under that key, not the platform's. So relocation accepts the |
| -// platform key only with positive evidence that the app uses it: the app's |
| -// helper, having resolved $CARLOS_INSTANCE_KEY (step 2), tells the agent |
| -// over its control socket, and the agent records that, durably, beside the |
| -// env file. The files manifest publishes the record as a platform item and |
| -// the relocation pinner requires it (obligations O1' and O2'). |
| +// The instance key's adoption record (instance-key plan rev 3, rulings 19 |
| +// to 22). A key in <host>.env or <host>.config.env proves only that one |
| +// was delivered: an app with a key of its own keeps it (rastrillo's step |
| +// 1), and its data is sealed under that key. So relocation accepts a |
| +// delivered key only when the app's helper, having resolved |
| +// $CARLOS_INSTANCE_KEY (step 2), told the agent so, and the agent recorded |
| +// it for the writer the process was admitted as. The record is a |
| +// trusted-helper assertion: it stops accidental loss, and a tenant that |
| +// forges one can lose only its own data (ruling 19). |
| +// |
| +// The verb is mounted only where a launch has a writer session: claim |
| +// launches, once they bind a control socket (obligation O6). A legacy |
| +// launch has no session to bind a record to, so nothing mounts it there and |
| +// the helper's call is a 404 it ignores. |
| import ( |
| "crypto/sha256" |
| "crypto/subtle" |
| "encoding/json" |
| "errors" |
| - "fmt" |
| "net/http" |
| "os" |
| "path/filepath" |
| @@ -2467,27 +3064,45 @@ import ( |
| // instanceKeyAdoptionPath is the verb's path on the control socket. |
| const instanceKeyAdoptionPath = "/instance-key/adoption" |
| -// mountInstanceKeyAdoption adds PUT /instance-key/adoption to an |
| -// instance's control socket. host is fixed by the listener, never read |
| -// from the request, and the bearer is the host's own CARLOS_ADMIN_TOKEN, |
| -// as for the schedule verbs (schedule.MountGated has the long form of what |
| -// that bearer does and does not buy). |
| +// AdoptionLaunch is one process's launch state as the agent holds it: the |
| +// writer it was admitted as, the key its env delivered (by digest) and the |
| +// file that supplied it, where its <host>.env is, and the session's files |
| +// publisher. O6 builds one per claim launch from the admission (holder |
| +// incarnation, session, generation) and ClaimLaunchSnapshot's |
| +// InstanceKeySHA256 and InstanceKeySource. Nothing in it comes from a |
| +// request. |
| +type AdoptionLaunch struct { |
| + Writer instancekey.Binding |
| + KeySHA256 string |
| + Source string // instancekey.SourcePlatform or SourceConfig |
| + Dir string // the directory that holds <host>.env |
| + Beneath bool // Dir is tenant-writable: write through instancefs |
| + // Publish hands the record to the session's files publisher as the |
| + // platform item instancekey.AdoptionItem (hostfiles.PlatformRelay). It |
| + // must not block: publication follows the publisher's continuous bound. |
| + Publish func(record []byte) |
| +} |
| + |
| +// MountInstanceKeyAdoption adds PUT /instance-key/adoption to an |
| +// instance's control socket. host is the listener's, never the request's, |
| +// and the bearer is the host's own CARLOS_ADMIN_TOKEN, as for the schedule |
| +// verbs. launch is the calling process's launch state; false (a launch with |
| +// no writer session) answers 404, as an agent without the verb does. |
| // |
| -// The body is {"sha256": "<hex>"}: the SHA-256 of the key the app |
| -// resolved. The answer: |
| -// - 204 when the key is the one in the instance's own <host>.env and its |
| -// adoption record is on disk and durable, or when the key is the one |
| -// the rendered configuration supplies (the configuration is that |
| -// key's evidence, and nothing is recorded); |
| -// - 409 when no effective key matches the digest (the app holds some |
| -// other key), or for a replica row (replicas do not adopt the platform |
| -// key: open question 1, provisional default). Nothing is recorded; |
| -// - 400 for a malformed body, 401 without the bearer, 500 when a read |
| -// or the durable write fails. |
| +// The body is {"sha256": "<hex>", "source": "platform"|"config"}: the |
| +// SHA-256 of the key the helper resolved at step 2 and where the helper |
| +// says it came from. The answer: |
| +// - 204 once the record for the launch's writer is on disk, 0600, file |
| +// and directory fsynced, and handed to the session's publisher; |
| +// - 409 when the digest or the source is not the launch's (the app holds |
| +// some other key), or for a replica row (ruling 17); nothing recorded; |
| +// - 404 with no launch state, 400 for a malformed body (a session or |
| +// generation in it included), 401 without the bearer, 500 when the |
| +// durable write fails (nothing published). |
| // |
| -// Bodies are http.StatusText only: the app's helper treats every failure |
| -// as non-fatal and the status is all it reads. |
| -func (b *ExecBackend) mountInstanceKeyAdoption(mux *http.ServeMux, rt registry.Route, token func() (string, error)) { |
| +// Bodies are http.StatusText only: the helper treats every failure as |
| +// non-fatal, and the status is all it reads. |
| +func (b *ExecBackend) MountInstanceKeyAdoption(mux *http.ServeMux, rt registry.Route, token func() (string, error), launch func() (AdoptionLaunch, bool)) { |
| fail := func(w http.ResponseWriter, code int) { http.Error(w, http.StatusText(code), code) } |
| mux.HandleFunc("PUT "+instanceKeyAdoptionPath, func(w http.ResponseWriter, r *http.Request) { |
| want, err := token() |
| @@ -2503,128 +3118,90 @@ func (b *ExecBackend) mountInstanceKeyAdoption(mux *http.ServeMux, rt registry.R |
| } |
| var body struct { |
| SHA256 string `json:"sha256"` |
| + Source string `json:"source"` |
| } |
| dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) |
| dec.DisallowUnknownFields() |
| - if err := dec.Decode(&body); err != nil || dec.More() || !instancekey.ValidDigest(body.SHA256) { |
| + if err := dec.Decode(&body); err != nil || dec.More() || !instancekey.ValidDigest(body.SHA256) || |
| + (body.Source != instancekey.SourcePlatform && body.Source != instancekey.SourceConfig) { |
| fail(w, http.StatusBadRequest) |
| return |
| } |
| - if rt.Replica { |
| + l, ok := launch() |
| + switch { |
| + case !ok: |
| + fail(w, http.StatusNotFound) |
| + return |
| + case rt.Replica: |
| fail(w, http.StatusConflict) |
| return |
| - } |
| - switch err := b.recordInstanceKeyAdoption(rt, body.SHA256); { |
| - case errors.Is(err, errAdoptionMismatch): |
| + case l.Writer.Host != rt.Host || l.Publish == nil: |
| + fail(w, http.StatusInternalServerError) |
| + return |
| + case body.SHA256 != l.KeySHA256 || body.Source != l.Source: |
| fail(w, http.StatusConflict) |
| - case err != nil: |
| + return |
| + } |
| + record := instancekey.AdoptionRecord(l.Writer, l.Source, l.KeySHA256) |
| + if err := writeAdoptionRecord(l, rt.Host, record); err != nil { |
| fail(w, http.StatusInternalServerError) |
| - default: |
| - w.WriteHeader(http.StatusNoContent) |
| + return |
| } |
| + l.Publish(record) |
| + w.WriteHeader(http.StatusNoContent) |
| }) |
| } |
| -// errAdoptionMismatch: the digest names no effective key of this host. |
| -var errAdoptionMismatch = errors.New("activator: the adopted key is not this instance's effective key") |
| - |
| -// adoptionRecordSync is the record's directory sync, a seam for tests. |
| -// Read and replaced only under adminTokenMu. |
| +// adoptionRecordSync is the nested record's directory sync, a seam for |
| +// tests. Read and replaced only under adminTokenMu. |
| var adoptionRecordSync = syncDir |
| -// recordInstanceKeyAdoption checks digest against rt's effective key and, |
| -// when that key is the own env file's, writes the adoption record beside |
| -// it: atomically, 0600, the file and its directory fsynced before it |
| -// returns. An identical record already there is made durable and not |
| -// rewritten. It holds adminTokenMu, so it never reads an env file halfway |
| -// through a mint. |
| -func (b *ExecBackend) recordInstanceKeyAdoption(rt registry.Route, digest string) error { |
| - p := b.pathsFor(rt) |
| - beneath := p.layout == LayoutNested |
| - switch p.layout { |
| - case LayoutFenced, LayoutConflict: |
| - return fmt.Errorf("%s: %s", rt.Host, p.reason) |
| - } |
| - envName, recName := rt.Host+".env", rt.Host+instancekey.AdoptionSuffix |
| - read := func(n string) ([]byte, error) { |
| - if beneath { |
| - return instancefs.ReadBeneathLimit(p.dataDir, n, 1<<20) |
| - } |
| - return os.ReadFile(filepath.Join(p.dataDir, n)) |
| - } |
| +// writeAdoptionRecord writes record beside <host>.env: atomically, 0600, |
| +// the file and its directory fsynced before it returns. An identical |
| +// record already there is made durable and not rewritten. It holds |
| +// adminTokenMu, the lock every write beside <host>.env takes. |
| +func writeAdoptionRecord(l AdoptionLaunch, host string, record []byte) error { |
| + name := host + instancekey.AdoptionSuffix |
| adminTokenMu.Lock() |
| defer adminTokenMu.Unlock() |
| - own, err := read(envName) |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return err |
| - } |
| - cfg, err := read(configEnvName(envName)) |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| - return err |
| - } |
| - key, layer := instancekey.Effective(parseEnvBytes(cfg), parseEnvBytes(own)) |
| - if layer == instancekey.LayerNone || instancekey.Digest(key) != digest { |
| - return errAdoptionMismatch |
| - } |
| - if layer == instancekey.LayerConfig { |
| - return nil // the configuration is the evidence (O2' source c) |
| + var cur []byte |
| + var err error |
| + if l.Beneath { |
| + cur, err = instancefs.ReadBeneathLimit(l.Dir, name, 1<<16) |
| + } else { |
| + cur, err = os.ReadFile(filepath.Join(l.Dir, name)) |
| } |
| - record := instancekey.AdoptionRecord(key) |
| - if cur, err := read(recName); err == nil && string(cur) == string(record) { |
| - return adoptionRecordSync(p.dataDir) |
| - } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + switch { |
| + case err == nil && string(cur) == string(record): |
| + return adoptionRecordSync(l.Dir) |
| + case err != nil && !errors.Is(err, os.ErrNotExist): |
| return err |
| } |
| - if beneath { |
| - // WriteAtomic fsyncs the file before its rename; the rename itself |
| - // is made durable by the directory sync. |
| - if err := instancefs.WriteAtomic(p.dataDir, recName, record, 0o600); err != nil { |
| + if l.Beneath { |
| + // WriteAtomic fsyncs the file before its rename; the directory |
| + // sync makes the rename durable. |
| + if err := instancefs.WriteAtomic(l.Dir, name, record, 0o600); err != nil { |
| return err |
| } |
| - return adoptionRecordSync(p.dataDir) |
| + return adoptionRecordSync(l.Dir) |
| } |
| // safefile.WriteFile fsyncs the file, renames it into place and fsyncs |
| // the directory. |
| - return safefile.WriteFile(filepath.Join(p.dataDir, recName), record, 0o600) |
| + return safefile.WriteFile(filepath.Join(l.Dir, name), record, 0o600) |
| } |
| ``` |
| -- [ ] **Step 4: Mount it on both control sockets** |
| - |
| -In `spawnChildFrom`, the mount is no longer conditional on `b.Timers`: |
| - |
| -```go |
| - mount := func(mux *http.ServeMux) { |
| - if b.Timers != nil { |
| - schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n)) |
| - } |
| - b.mountInstanceKeyAdoption(mux, rt, tokenReader) |
| - } |
| - ctl, err := edge.ListenControl(controlSocket, rt.Host, b.Cache, mount) |
| -``` |
| - |
| -In `listenControl` (prepare.go): |
| - |
| -```go |
| - mount := func(mux *http.ServeMux) { |
| - if b.Timers != nil { |
| - schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, gen)) |
| - } |
| - b.mountInstanceKeyAdoption(mux, rt, tokenReader) |
| - } |
| -``` |
| - |
| - [ ] **Step 5: Run the tests** |
| -Run (sandbox off: the nested test dials the socket): `nice -n 19 go test ./internal/activator/ -run 'Adoption|Control|Purge|Schedule' -count=1` |
| -Expected: PASS, the existing control-socket and schedule-verb tests included. |
| +Run: the Step 2 commands. |
| +Expected: PASS. None of them needs a socket; the verb is served through `httptest`. |
| - [ ] **Step 6: Commit** |
| ```bash |
| -gofmt -l internal/activator |
| -git add internal/activator/keyadoption.go internal/activator/keyadoption_test.go internal/activator/keyadoption_linux_test.go internal/activator/backend_exec.go internal/activator/prepare.go |
| -git commit -m "activator: the control socket records an app's adoption of the platform instance key (L21) |
| +gofmt -l internal/activator internal/hostfiles internal/host |
| +git add internal/hostfiles/relay.go internal/hostfiles/relay_test.go internal/activator/keyadoption.go internal/activator/keyadoption_test.go internal/activator/keyadoption_linux_test.go internal/host/instancekey_adoption_test.go |
| +git commit -m "activator: the instance key's adoption verb, bound to the launch's writer and published on the session (L21) |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |
| @@ -2828,17 +3405,16 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| --- |
| -### Task 7: The configured key's evidence, and never overwrite across re-render and moves |
| +### Task 7: Never overwrite across re-render and moves |
| -`ConfiguredInstanceKey` is new. The other three tests pin behaviour the code already has, which this plan depends on (ruling 8). They should pass on first run. A failure means a premise of this plan is false: stop and report it rather than changing the code under test. |
| +These tests pin behaviour the code already has, and this plan depends on it (ruling 8). They should pass on first run. A failure means a premise of this plan is false: stop and report it rather than change the code under test. (Rev 2's `appconfig.ConfiguredInstanceKey` is gone with source (c), ruling 20.) |
| **Files:** |
| -- Create: `internal/appconfig/instancekey.go` |
| - Create: `internal/appconfig/syncexec_instancekey_test.go`, `internal/moves/envreport_instancekey_test.go`, `internal/moves/archive/instancekey_test.go` |
| **Interfaces:** |
| -- Consumes: `LoadMergedHostVersions` (instances.go:222), `RenderEnv` (materialize.go:37), `routeBinding` (sync.go:469), `execKit`, `SetEnv`, `ApplyInstance`, `Seal`, `ParsePrivateKey`, `ParsePublicKey`, `ConfigEnvPath`, `SyncExec` (internal/appconfig); `Class`, `ReportEnv`, `CompareEnv` (internal/moves/envreport.go); `filesFixture`, `mustWrite`, `Build`, `Extract` (internal/moves/archive). |
| -- Produces: `appconfig.ConfiguredInstanceKey(ctx, s, priv, rt) (bool, error)`, for O2''s `ConfigKey`. |
| +- Consumes: `execKit`, `SetEnv`, `ParsePrivateKey`, `SyncExec` (internal/appconfig); `Class`, `ReportEnv`, `CompareEnv` (internal/moves/envreport.go); `filesFixture`, `mustWrite`, `Build`, `Extract` (internal/moves/archive). |
| +- Produces: tests only. |
| - [ ] **Step 1: Write the tests** |
| @@ -2881,56 +3457,6 @@ func TestSyncExecLeavesTheInstanceKeyAlone(t *testing.T) { |
| } |
| } |
| } |
| - |
| -// Finding 5, evidence (c): the pinner asks whether the host's configuration |
| -// renders a non-empty key. A plain variable, a sealed secret and an |
| -// instance overlay count; an empty value, an overlay that empties it, and |
| -// no configuration at all do not. The render is SyncExec's own: the file |
| -// it writes carries the same answer. |
| -func TestConfiguredInstanceKeyReadsTheRenderedConfiguration(t *testing.T) { |
| - ctx, s, reg, dd, priv, pub := execKit(t) |
| - pk, _ := ParsePrivateKey(priv, "test") |
| - pubKey, _ := ParsePublicKey(pub, "test") |
| - rt := registry.Route{Host: "shop.example.com", Kind: "instance", App: "shop", Account: "acct", Status: "ready", Socket: "/run/carlos/shop.sock"} |
| - if err := reg.Upsert(rt); err != nil { |
| - t.Fatal(err) |
| - } |
| - check := func(want bool) { |
| - t.Helper() |
| - got, err := ConfiguredInstanceKey(ctx, s, pk, rt) |
| - if err != nil || got != want { |
| - t.Fatalf("ConfiguredInstanceKey = %v, %v; want %v", got, err, want) |
| - } |
| - } |
| - check(false) // no configuration at all |
| - if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": ""}); err != nil { |
| - t.Fatal(err) |
| - } |
| - check(false) |
| - if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "from-config"}); err != nil { |
| - t.Fatal(err) |
| - } |
| - check(true) |
| - if err := SyncExec(ctx, reg, s, pk, dd, func(string) error { return nil }, "", nil); err != nil { |
| - t.Fatal(err) |
| - } |
| - rendered, err := os.ReadFile(ConfigEnvPath(dd, rt)) |
| - if err != nil || string(rendered) != "CARLOS_INSTANCE_KEY=from-config\n" { |
| - t.Fatalf("SyncExec rendered %q, %v", rendered, err) |
| - } |
| - if _, _, err := ApplyInstance(ctx, s, "acct", "shop", rt.Host, map[string]Var{"CARLOS_INSTANCE_KEY": {Kind: "env", Value: ""}}, nil); err != nil { |
| - t.Fatal(err) |
| - } |
| - check(false) // the host's overlay empties it |
| - sealed, err := Seal(pubKey, Binding{Account: "acct", App: "shop"}, []byte("sealed-key")) |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - if _, _, err := ApplyInstance(ctx, s, "acct", "shop", rt.Host, map[string]Var{"CARLOS_INSTANCE_KEY": {Kind: "secret", Envelope: sealed}}, nil); err != nil { |
| - t.Fatal(err) |
| - } |
| - check(true) |
| -} |
| ``` |
| `internal/moves/envreport_instancekey_test.go`: |
| @@ -3005,62 +3531,15 @@ func TestArchiveCarriesTheInstanceKeyByteForByte(t *testing.T) { |
| - [ ] **Step 2: Run them** |
| -Run: `nice -n 19 go test ./internal/appconfig/ -run 'TestSyncExecLeavesTheInstanceKeyAlone|TestConfiguredInstanceKey' -count=1 && nice -n 19 go test ./internal/moves/ -run TestEnvReportComparesTheInstanceKey -count=1 && nice -n 19 go test ./internal/moves/archive/ -run TestArchiveCarriesTheInstanceKeyByteForByte -count=1` |
| -Expected: build failure in `internal/appconfig`, `undefined: ConfiguredInstanceKey`; the moves tests PASS. |
| - |
| -- [ ] **Step 3: Write `ConfiguredInstanceKey`** |
| - |
| -Create `internal/appconfig/instancekey.go`: |
| - |
| -```go |
| -package appconfig |
| - |
| -import ( |
| - "context" |
| - "crypto/ecdh" |
| - |
| - "github.com/carlosframework/platform/internal/instancekey" |
| - "github.com/carlosframework/platform/internal/registry" |
| - "github.com/carlosframework/platform/internal/release" |
| -) |
| - |
| -// ConfiguredInstanceKey reports whether rt's configuration in the bucket |
| -// renders a non-empty CARLOS_INSTANCE_KEY into <host>.config.env: the |
| -// app's default bundle, its bound environment and the host's instance |
| -// overlay, merged and rendered by the same loader and renderer SyncExec |
| -// and the held render use (LoadMergedHostVersions, RenderEnv). It is the |
| -// relocation pinner's evidence (c) for a key the app takes from its |
| -// configuration (instance-key plan rev 2, obligation O2'): the bucket holds |
| -// the configuration durably, and a relocation target renders the same one. |
| -// |
| -// The value never leaves this function. A secret is opened to tell an |
| -// empty value from a set one; priv may be nil only when no variable is a |
| -// secret, as for RenderEnv. |
| -func ConfiguredInstanceKey(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, rt registry.Route) (bool, error) { |
| - m, _, err := LoadMergedHostVersions(ctx, s, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host) |
| - if err != nil || m == nil { |
| - return false, err |
| - } |
| - rendered, err := RenderEnv(m, priv, routeBinding(rt)) |
| - if err != nil { |
| - return false, err |
| - } |
| - key, _ := instancekey.Effective(instancekey.ParseEnv(rendered), nil) |
| - return key != "", nil |
| -} |
| -``` |
| - |
| -- [ ] **Step 4: Run them again** |
| - |
| -Run: the Step 2 command. |
| +Run: `nice -n 19 go test ./internal/appconfig/ -run TestSyncExecLeavesTheInstanceKeyAlone -count=1 && nice -n 19 go test ./internal/moves/ -run TestEnvReportComparesTheInstanceKey -count=1 && nice -n 19 go test ./internal/moves/archive/ -run TestArchiveCarriesTheInstanceKeyByteForByte -count=1` |
| Expected: PASS. |
| -- [ ] **Step 5: Commit** |
| +- [ ] **Step 3: Commit** |
| ```bash |
| gofmt -l internal/appconfig internal/moves |
| -git add internal/appconfig/instancekey.go internal/appconfig/syncexec_instancekey_test.go internal/moves/envreport_instancekey_test.go internal/moves/archive/instancekey_test.go |
| -git commit -m "appconfig: answer whether a host's configuration supplies its instance key; pin re-render and moves (L21) |
| +git add internal/appconfig/syncexec_instancekey_test.go internal/moves/envreport_instancekey_test.go internal/moves/archive/instancekey_test.go |
| +git commit -m "test: the instance key survives re-render and travels byte for byte through a move (L21) |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |
| @@ -3070,7 +3549,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ### Final verification (once, before handing off) |
| - [ ] `nice -n 19 go build ./... && nice -n 19 go vet ./... && gofmt -l .` (no output) |
| -- [ ] `nice -n 19 go test ./... -count=1` with the sandbox off (all packages pass) |
| +- [ ] `nice -n 19 go test ./... -count=1 -timeout 30m` with the sandbox off (all packages pass) |
| - [ ] `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...` |
| - [ ] `git grep -n 'CARLOS_INSTANCE_KEY\|InstanceKeyVar\|instancekey.Var' -- '*.go' | grep -i 'log\.\|Printf\|Errorf'`: every hit names the variable, never prints a value. |
| - [ ] Push the branch to the hub and read its CI status on the tip (green) before asking Paul for the merge. |
| @@ -3080,15 +3559,15 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| 1. **Merge and release.** Merging is Paul's call, as a squash on the hub, after CI is green on the tip. Cut a platform release by the README's "Releasing the platform" recipe. |
| 2. **Ship to every box, every region.** Use the platform roll recipe (build arm64, upload, `update-platform-binary.sh` per box, `roll-console.sh` after). The console needs nothing new, but rolls with the release as usual. |
| 3. **Verify the backfill on each box.** `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`. For each failure, read the per-host line above it, fix the file, and restart the instance (its start mints the key, or fails loudly with the same error). `never started here` hosts mint at their first start. Claim boxes print no backfill line: their instances mint at their next cold admission. |
| -4. **Post the rastrillo ask** (above) on #66, and wait for a rastrillo release that makes the adoption call. |
| -5. **Only then tell app owners to adopt** that rastrillo release: `carlos.InstanceKey(own)`. Before a box has this build, an adopting app with no key of its own refuses to start on it. An app on e3ea08d without the call runs, but its data does not relocate (O2' holds it `stuck`) until it moves to the release with the call. |
| +4. **Post the rastrillo ask and its addendum** (above) on #66, and wait for a rastrillo release that makes the call with `source`. |
| +5. **Only then tell app owners to adopt** that rastrillo release: `carlos.InstanceKey(own)`. Before a box has this build, an adopting app with no key of its own refuses to start on it. An app on the platform or a configured key runs everywhere, but does not relocate until O6 and O7 are in (step 7). |
| 6. **App owners, before adopting:** |
| - - An app with its own key passes it as `own`: its explicit setting, or the key file it already has, read without minting. It keeps that key, and nothing changes. |
| + - An app with its own key passes it as `own`: its explicit setting, or the key file it already has, read without minting. It keeps that key, and nothing changes. Its relocation evidence is its `instance.key`, source (a). |
| - An app that ran on CARLOS with a **development fallback key** moves to the platform key when it adopts. Anything sealed under the old key stops reading: sessions, sealed cookies, sign-in challenges, sealed columns. Check what is sealed under the old key first. Then either pass the old key as `own` and keep it, or accept the switch knowing what it invalidates. |
| - An app with replica placements passes its own key as `own` (ruling 17, provisional): the platform mints no key on a replica, and an adopting app without its own key refuses to start there. |
| -7. **Region HA.** Do not enable relocation on a region until this plan is rolled there and O1', O2', O3' and O5 are in (stage B Task 10 checklist item 9, as O4 rewrites it). |
| +7. **Region HA.** Do not enable relocation on a region until this plan is rolled there and O1', O2', O3', O5 and O7 are in. Platform-key and configured-key apps relocate only after O6 as well (rulings 23 and 24); until then their relocation refuses, explicitly. This is stage B Task 10's checklist item 9, as O4 rewrites it. |
| -**Rollback:** roll the previous binary. Keys and adoption records already written stay. That is harmless: no app reads the key until it adopts, an older agent answers the adoption call 404 (ignored by the helper), and the next forward roll finds both present. |
| +**Rollback:** roll the previous binary. Keys already written stay. That is harmless: no app reads the key until it adopts, and the next forward roll finds it present. No adoption record is written before O6. |
| ## Open questions for Paul |
| @@ -3103,5 +3582,5 @@ Question 5 (restarts inside a claim session) is withdrawn: its premise was wrong |
| ## Follow-ups this plan does not build |
| -- **Moves and the adoption record.** A move's archive does not carry `<host>.instance-key-adoption.json`, and the source's finalization does not park it (`installArtifactPaths` is a journalled list). The target re-records at the app's first start. The source keeps a stale flat record, which vouches only for the same key but is a host-named file that a later empty-installation census on that box would refuse. The fix adds the name to the moves artifact lists, with a journal version step. |
| -- **Configured keys and adoption.** Ruling 15's residual for (c) (an app with an unpublished own key and a configured key) closes if the pinner also requires an adoption record for configured keys. That needs the verb to record configured keys too, which publishes a digest of a configuration secret. |
| +- **O6 and O7** are other plans' work, in that order (rulings 23 and 24). |
| +- **Low-entropy configured keys.** A `config` record publishes the SHA-256 of a configured key in the data bucket (ruling 9). For a random key that reveals nothing. For a short key an owner typed by hand, a data-bucket reader could test guesses against it. Rastrillo's docs could say that a configured `CARLOS_INSTANCE_KEY` should be random. |