carlos / platform Public
File too large to show in full — download it above (showing a truncated preview below).
| 1 | # Region HA 2b: Joining Mode and Automatic Enrolment Implementation Plan |
| 2 | |
| 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. |
| 4 | |
| 5 | **Goal:** Build spec 2 §5's box side and console side of joining, dormant behind `CARLOS_REGION_HA` and claim mode: the agent chooses hosting or joining at every start, a joining box publishes its identity record and waits behind a 503 health answer, the console confirms the box's group membership itself and then invites and enrolls it, root arms the box's guard, a fresh box installs its channel's release into both protected slots, and `host.env` is assembled from SSM parameters. |
| 6 | |
| 7 | **Architecture:** The mode is chosen once, in `RunAgent`, before anything else starts (`hostingGate`). Joining is its own small run (`internal/host/joining.go`): the identity record loop, a 503-only health listener, the guard-ready report, and a marker that asks root (`carlos system join`, behind a path unit) to arm the guard with the invitation's enrollment. Hosting is main's claim agent, which reports hosting to the health answer only while its claim worker runs. The console is the only membership confirmer (H28): its enrolment pass, `hostactors.AutoEnroller` driven by `internal/console/enrollpass.go`, asks the cloud through `hostactors.MembershipReader`, its own read-only client, and `EnrollAutomatic` and the unexported `checkConfirmed` make that read themselves and build the confirmation inside `hostactors` where no caller can supply one. The pass binds the invitation's deadline to the confirmation `checkConfirmed` returns, and finds an invited box with the read-only `enrollmentNonce`, never `Invite`. The write each read allows runs on a store bound to the read's deadline (`writeBound`, the single enforcement point), so no store call of it is issued 60 seconds or more after the read's anchor, the describe's start, and the console waits at most 15 seconds for any call it issues. An outcome the console did not see is unknown, and a later pass settles it the ordinary way, by reading the roster. A cancelled write can still commit later, on the roster revision it was built against. They stand on main's `BeginEnrollmentAttempt`, `Invite`, `Replace` and `membership.go`, unchanged. A box its region's group launched is referenced by its group reference, `g-` and 20 hex digits of its account and instance's digest (`GroupBoxReference`): it publishes under it, `statusLoop` included (and publishes nothing while the reference cannot be read), and the console derives it again from the instance it proved, never trusting the key the box chose. The status reader drops reports no box has rewritten in a day before its key cap, so replaced boxes' history never hides the running ones. Each AWS part is an adapter named for AWS: the SSM parameter store (`internal/hostenv/awsssm.go`) and the Auto Scaling and EC2 describe calls (`internal/hostactors/awsmembership`, opened by the console in `internal/console/awsmembership.go`). |
| 8 | |
| 9 | **Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `internal/hostenv` (new), `internal/host`, `internal/hostactors`, `internal/console`, the `carlos` CLI (repo root); AWS SDK v2 SSM and Auto Scaling (new, v1.74.0, the versions built against the repo's `aws-sdk-go-v2 v1.44.0`), EC2 (already a dependency). |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 21** (rev 21 records the final review below): §5 (boot and enrolment, host.env's fixed path and checked write, the write deadline and its per-call timeout, unknown outcomes settled by the roster, group references, the status listing and the activation gates), §12 (spec 2's hand-off to spec 3), §4 (the SSM layout and IAM, for 2c's seams), §25 (the provider boundary), rulings H1 to H28 (H28: the console is the only membership confirmer), and platform-1e's census ruling region-ha-2b (RULED by the Plan 14 controller 2026-10-16, with its conditions). Plan 2a (`2026-10-16-region-ha-2a-core.md`, built and merged as 6834659c) for format and the seams it left. |
| 12 | |
| 13 | **Review round 1.** Astra's review of the first draft (`CODEX-REVIEW-REGION-HA-2B-1.md`) found eight things; this revision answers all of them, with the claim owner's conditions on the first (C1 to C4): |
| 14 | |
| 15 | | Finding | What changed | |
| 16 | |---|---| |
| 17 | | 1 (P1): box-written confirmations do not authenticate their writer | H28: no box writes membership evidence. The box-side confirmer, its wiring and the box's create grant and describe grant are gone. The console reads membership itself, and the confirmation exists only inside `hostactors` (Tasks 4, 5, 8; 2c's seams 3 and 5) | |
| 18 | | 2 (P2): install-channel leaves root-owned parents under `/data` | The directories it creates are handed to the service user with bootstrap's own chown, and the test runs the agent's next download after it (Task 3) | |
| 19 | | 3 (P2): 1,600 labels collide | Deferred in round 1 (a legacy-sealed unit); built in round 2 under platform-1e's ruling (below) | |
| 20 | | 4 (P2): racing seeds overwrite shared values | Shared values are written create-only and read back on a conflict (Task 2) | |
| 21 | | 5 (P2): the boot order runs a binary before fetching it | 2c's seam 1 and spec §5 now fetch and check the bootstrap binary first | |
| 22 | | 6 (P2): the console's new signing path escapes its census | Its construction, start and invocation are in the claim-authority census (Task 5) | |
| 23 | | 7 (P3): an unreadable confirmation supersedes a valid attempt | A failed read is unknown: the attempt stands and its wait does not grow (Tasks 4, 5) | |
| 24 | | 8 (P3): the join units' descriptions have no copy rows | RHB-C62 and RHB-C63 (Tasks 1, 7) | |
| 25 | |
| 26 | **Review round 2.** Astra's second review (`CODEX-REVIEW-REGION-HA-2B-2.md`) closed six of round 1's findings, left one partly open and one open, and found two more. platform-1e approved the `statusLoop` change round 1 stopped at (census ruling region-ha-2b, binding): |
| 27 | |
| 28 | | Finding | What changed | |
| 29 | |---|---| |
| 30 | | Round 1 #2, partly open: a failed handoff leaves root-owned parents a retry skips | Every directory on the cache path is reconciled on every run, not only the ones the run created; a symlinked parent is refused (S2, Task 3) | |
| 31 | | Round 1 #3, open: label collisions; activation must wait | Built: group references, under platform-1e's ruling (S3, Tasks 2, 4, 5, 6, 8). Automatic enrolment must not be switched on for a region before a release with them is on its boxes and its console ("Activation gates") | |
| 32 | | New #1 (P2): freshness is checked before intervening I/O, not at the write | Every membership read sets a write deadline 60 seconds after it; the invitation and the enrolling write (with `checkMembership`'s reads and `Replace`'s `Put`) run on a store bound to it, and nothing is issued past it (S1, Tasks 4, 5) | |
| 33 | | New #2 (P2): the seeding contract omits decryption | 2c's seam 4 adds `kms:Decrypt` with the `ViaService` and `PARAMETER_ARN` conditions; RHB-C61 names the read and decrypt permissions (S4) | |
| 34 | | platform-1e's C2 logging condition | Every membership log line carries the attempt id, the instance id and both request ids, also for refusals and failed reads (S5, Task 5) | |
| 35 | |
| 36 | **Review round 3.** Astra's third review (`CODEX-REVIEW-REGION-HA-2B-3.md`) closed round 2's items but the `pendingTime` gate, and found two more; platform-1e ruled the census rows, approving them on conditions (binding); the claim owner added the read-back: |
| 37 | |
| 38 | | Finding or condition | What changed | |
| 39 | |---|---| |
| 40 | | New #1 (P2): an unresolved group reference overwrites another box's status | `statusLoop`'s tick is `groupStatusTick`: a box with the group marker publishes only under its reference, resolved afresh each tick, and nothing at all until it resolves, whatever the claim and HA switches say; never under `status.Label` (T1, Task 6; `statusLoop` re-sealed again) | |
| 41 | | New #2 (P2): replaced boxes' reports exceed the status reader's 200-key cap | The reader drops reports whose object no box has rewritten in a day, from the listing's write times, before any read and before the cap; 2c adds a 30-day expiry on `status/boxes/` as hygiene (T2, Task 6) | |
| 42 | | The two redundant pre-checks | Removed: `enroll`'s callback (enroll.go is main's again) and the check before `Invite`. The bound store is the single enforcement point (T3, Tasks 4, 5) | |
| 43 | | The deadline's anchor and edge | Anchored at the describe's start, never later than its completion; a read 60 seconds old or more is refused; a call in flight at the deadline is cancelled and its outcome is unknown (Tasks 4, 5) | |
| 44 | | The claim owner's read-back | An enrolling write whose outcome is unknown is accepted, once it is seen to have landed, only on a fresh describe; a box no longer a member is logged and paged once (Task 5) | |
| 45 | | `runHostEnv` conditions 1 to 3 | No box write to the parameters (2c's negative check); an allowlist of names, safe values, an atomic write through a checked directory; the group marker and a group box's region cross-checked against the roster at the agent's use (T4, Tasks 2, 6) | |
| 46 | | The join trigger condition | Root's join reads nothing from its trigger file; hostile contents change nothing (Task 7) | |
| 47 | |
| 48 | **Review round 4.** Astra's fourth review (`CODEX-REVIEW-REGION-HA-2B-4.md`) closed round 3's items but the dev check of `pendingTime`, which stays an activation gate, and found four more. The claim owner and platform-1e agreed two simplifications in answer (binding, U1 to U3): |
| 49 | |
| 50 | | Finding or ruling | What changed | |
| 51 | |---|---| |
| 52 | | #1 (P2): a success that returns after the deadline escapes the bound | U2: every store call of a bound write runs under `deadlineStore.call`, which gives it a hard 15-second timeout (`storeCallTimeout`) on top of the 60-second issue bound. A store that ignores its context is cut off by the wrapper itself, and a call that answers, even successfully, only after the deadline is `ErrWriteDeadline`, unknown. Round 5 corrected what this row claimed: the 15 seconds bound how long the console waits, not when a write commits (Tasks 4, 5) | |
| 53 | | #2 (P2): the read-back accepts a stale describe; #3 (P2): a failed page discards the escalation | U1: the read-back, its in-memory table, its fresh describe and its page are removed, and with them RHB-C65 and RHB-C66. An unknown outcome is settled on the next pass by the roster read every pass makes: a box the roster lists is enrolled and done; one it does not list goes on with the same attempt, if it is still valid, and no backoff growth. A detached instance that keeps running is spec 3's: its regional worker reconciles roster membership against the group's (Activation notes, Open question 9) (Task 5) | |
| 54 | | #4 (P2): host.env's directory check leaves the write path open | U3: `host-env write` has no `--out`; the path is fixed at `/etc/carlos/host.env`. `writeHostEnv` (Linux) opens `/etc` and `/etc/carlos` by descriptor, `O_NOFOLLOW\|O_DIRECTORY`, and refuses either unless `fstat` shows a directory root owns that no group or other can write; it writes a temporary file 0600 inside the held directory (`openat`), fsyncs it, renames it within the directory and fsyncs the directory. Off Linux, `write` refuses. RHB-C67 is reworded and RHB-C68 added; no pending row named `--out` (Task 2) | |
| 55 | |
| 56 | **Review round 5.** Astra's fifth review (`CODEX-REVIEW-REGION-HA-2B-5.md`) closed round 4's four findings and found one more; platform-1e accepted revision 4 with three items (binding, V1 to V4): |
| 57 | |
| 58 | | Finding or ruling | What changed | |
| 59 | |---|---| |
| 60 | | New (P2): the timeout does not establish a 75-second commit ceiling | V1: the claim is withdrawn everywhere. The guarantee, stated as it is: every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. No mechanism is added. Task 5's `TestAWriteTheConsoleGaveUpOnCanCommitLater` documents the exposure: a delayed `Put` commits after the next pass found the box absent and refused it on a fresh describe (Tasks 4, 5) | |
| 61 | | platform-1e: the per-call context reaches the SDK | V2: Task 4's `TestTheBoundReachesTheSDKRequest` builds the console's stores as `claim_runtime.go` does, over `release.NewS3Store` with only the network replaced, and records each HTTP request's context deadline: 15 seconds from the call, or the earlier write deadline. A wrapper that drops the context fails it (Task 4; round 6 drives a successful `PUT` through every wrapper) | |
| 62 | | platform-1e: the two host.env rows | V3: `writeHostEnv` and `checkedDir` re-marked `census-ruling-region-ha-2b` (Task 2) | |
| 63 | | platform-1e: late commits land only where they were aimed | V4: every write in the invitation and enrolment paths is conditional, shown from main's code in "Global Constraints: Freshness bound" | |
| 64 | |
| 65 | **Review round 6.** Astra's sixth review (`CODEX-REVIEW-REGION-HA-2B-6.md`) closed round 5's finding and judged the plan ready to build, with one P3 that platform-1e's acceptance of revision 5 also asks for (W1): |
| 66 | |
| 67 | | Finding or ruling | What changed | |
| 68 | |---|---| |
| 69 | | P3: the SDK test misses successful writes through the wrapper stack; W1 (platform-1e) | `TestTheBoundReachesTheSDKRequest`'s fake bucket now honours S3's conditional headers and is seeded with a root-signed roster, so the conditional roster `PUT` succeeds through the deadline store, the mutation boundary, the conditional record store and the S3 store, and the test asserts that `PUT`'s deadline and its `If-Match`. Dropping the context where any wrapper forwards `Put` fails it (Task 4) | |
| 70 | |
| 71 | **Final review of the build.** Astra's whole-branch review of the build (`CODEX-FINAL-REVIEW-REGION-HA-2B.md`) found two P2s and a P3, and plan text the build had overtaken. Answered in one fix wave, under the controller's rulings (binding): |
| 72 | |
| 73 | | Finding | What changed | |
| 74 | |---|---| |
| 75 | | P2: a stalled membership read stops enrolment across all regions | `confirmMembership` gives each `Member` call its own `storeCallTimeout` (15 s) context; a read cut off by it, or by the pass's context, is `ErrMembershipUnknown`: the attempt stands, its wait does not grow, and the pass goes on to the next box. `TestAStalledMembershipReadIsCutOffAndThePassGoesOn` (Tasks 4, 5) | |
| 76 | | P2: accepted status overrides break the group-report contract | `CARLOS_STATUS_BUCKET`, `CARLOS_STATUS_PREFIX` and `CARLOS_STATUS_REGION` leave `hostenv.Optional`, refused up front ("Global Constraints", host.env, says why) (Task 2) | |
| 77 | | P3: the allowlist admits credentials embedded in webhook URLs | `CARLOS_ALERT_WEBHOOK` leaves `hostenv.Optional`, on the DNSimple token's rule. `TestStatusOverridesAndTheAlertWebhookAreRefusedByNameNotValue` refuses all four by name in `Assemble` and `Seed`, never printing a value (Task 2) | |
| 78 | | Plan text: the DNSimple token, `CheckConfirmed` | The `Optional` snippet is the built one, without `CARLOS_DNSIMPLE_TOKEN`. The admission check is the unexported `checkConfirmed`, which returns its confirmation; the pass binds the invitation's deadline to it, and finds an invited box with the read-only `enrollmentNonce` (Tasks 4, 5). The census overlay's comment lines are left as the overlay has them | |
| 79 | | Plan text: the bootstrap binary and 2c | 2c's seams: the bootstrap binary sits outside both protected slots; no launch lifecycle hook on the group; `control/box-enrollment/` leaves `infra/modules/deployment-buckets`; `host-env write` needs `--aws-region` or `AWS_REGION` | |
| 80 | | Before activation | "Activation gates" 3 (the guard's restart transition on dev) and 4 (carloku.com's host.env files against the allowlist); gate 2, stop/start versus reboot, stays | |
| 81 | |
| 82 | The copy record and its gate need no change: no user-facing string changed. The new cut-off's text is in the pass's log line, which is not a row. |
| 83 | |
| 84 | ## Scope |
| 85 | |
| 86 | Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the others: |
| 87 | |
| 88 | | Plan | 2b's relation | |
| 89 | |---|---| |
| 90 | | 2a (merged) | 2b takes over `lbhealth.Default.SetHosting`: 2a's interim `SetHosting(true)` in `ClaimWiring.startRegional` becomes the gate's real decision, cleared when the claim worker ends (Task 8). 2b is the first caller of `EnrollAutomatic`, so 2a's group-role inheritance (Task 3 there) is exercised end to end (Task 5) | |
| 91 | | 2c | Builds the start-up script, the tofu group mode, IAM and alarms. 2b gives it the commands the script runs (the bootstrap binary's `system host-env write`, `bootstrap` and `system install-channel`, then the agent) and the exact permissions they need. See "2c's seams" at the end | |
| 92 | | 2d | The §8 single-box fixes. Nothing in 2b depends on them | |
| 93 | |
| 94 | **In 2b:** spec §5 steps 2 to 5 as code: the SSM layout and the seed command (Task 2), the channel-release install (Task 3), group membership read by the console (Task 4), the console's enrolment pass (Task 5), mode selection (Task 6), root's half of joining (Task 7), joining mode and the hosting flag (Task 8), and the CLI verbs and docs (Task 9). |
| 95 | |
| 96 | **Not in 2b:** the start-up script and hardening (2c), the region-host module's group mode, IAM, KMS and alarms (2c), the §8 fixes (2d). A region's `GroupName` and `GroupARN` reach the signed roster through a `region-group` phase of `carlos system claims run`, a later, separate plan built on `feat/claim-activation-tools` once that merges (Open question 1). |
| 97 | |
| 98 | ## Global Constraints |
| 99 | |
| 100 | - **Dormant.** With `CARLOS_HOST_CLAIM` or `CARLOS_REGION_HA` off, the agent starts exactly as on main: the gate returns "hosting" without reading anything, and nothing joins. The console's enrolment pass, and with it every membership read, starts only on a host-claim console with region HA on, beside 2a's capacity pass. `carlos system host-env`, `install-channel` and `join` are new commands nothing runs until 2c's start-up script does; `carlos-system-join.path` is armed on every box but idle, because only a joining agent touches its marker, and `carlos system join` does nothing with either switch off. |
| 101 | - **Mode selection (A2-4).** Hosting needs all three: `/etc/carlos/host-actor.json` naming this box; an active roster entry for that enrollment and instance; and the guard armed under the roster's floor with this process's binary one it admits (`hostfloor.Disk.Running`). And host.env's authority-selecting values agree with that entry (`crossCheck`): the group marker is set exactly when the console enrolled the box from a group, and a group box's `CARLOS_REGION` is the region it was enrolled in. For a box its region's group launched (the roster entry names a `Group`), one more: the instance is still the launch it was enrolled on (C3, `relaunched`). Anything else is joining, at one of five stages: no invitation, invited, guard installed, guard-ready reported, held. A fact that cannot be read is **unknown** (`errModeUnknown`): the gate waits and asks again, and never treats it as joining or hosting. |
| 102 | - **A stopped group box that is started again** (C3) never resumes hosting on its old enrollment. EC2 sets the identity document's `pendingTime` at every start, not at a reboot; when the current document's `pendingTime` differs from the one in the identity record the roster entry binds (by digest), the box is held, answers 503, and its group replaces it. It cannot prove membership again under the same reference while its roster entry stands; its replacement enrolls under its own reference. |
| 103 | - **Box references (S3; census ruling region-ha-2b).** A box its region's group launched is referenced by its group reference, `GroupBoxReference(account, instance)`: `g-` and the first 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`. Instance ids never repeat in an account, and the reference is a pseudonym, so the public `status/fleet.json` shows it and never an instance id. |
| 104 | - **The box** takes it when its host.env says `CARLOS_BOX_REFERENCE=group`, which only `host-env write` adds: `metricsBoxLabel` answers it, so the identity record, mode selection, root's join and the edges file use it; `statusLoop`'s tick (`groupStatusTick`, one call, re-sealed) publishes under it; the joining report is written under it. An identity IMDS has not given is no reference, never a label in its place: such a box publishes no status report at all, never under `status.Label`, which may be another box's key (`WriteBox` replaces what is there), and asks IMDS again on the next tick. That holds whatever the claim and region HA switches say. |
| 105 | - **The console** never trusts the key a box chose. The pass derives the reference from the instance the identity record proves and refuses a record under any other key; `checkConfirmed` and `EnrollAutomatic` refuse a request whose reference is not the instance's; `PrivateBoxReport` reads a group box's report under the reference derived from its pinned instance (the attempt's before enrolment, the roster's after), so every `VerifyReport` input for a group box comes from the console's own derivation, and a report another box wrote under that key fails on its instance and enrollment. |
| 106 | - **Every other box**, fleet boxes included, keeps exactly today's label and status key. |
| 107 | - **A joining box** publishes its signed identity record, publishes no edges file (so it is never referenced, `references.go:63`), opens no registry, admits and serves nothing, and answers `/.carlos/lb-health` with 503. Only its guard-ready report and its identity record leave the box. |
| 108 | - **The health answer's hosting condition** is the gate's decision, set before the claim worker starts and cleared when it ends (2a deferred this). |
| 109 | - **Enrolment (A2-1, H28).** The pass begins an attempt for each published identity record whose box the roster does not list. It invites only after its own membership read of the attempt's instance (`checkConfirmed`, unexported), on the store bound to the confirmation that read returns, and enrolls only when the box's guard-ready report passes `hostactors.VerifyReport` (the check `membership.go:40` makes), through `EnrollAutomatic`, which reads membership again itself. The attempt is single-use (`Consumed`, unchanged), so a replay is refused. An invited box is found with `enrollmentNonce`, which only reads and verifies the invitation; `Invite`, which is get-or-create, runs only on the bound store after a membership read. The box takes its group's role in the enrolling write (2a). The pass enrolls into the console's own namespace and the `default` pool, and only in a deployment whose host floor is published. |
| 110 | - **Membership (H16, H28; C1, C2).** The console is the only membership confirmer. No box writes or contributes membership evidence, and no box role can describe the group or create under `control/box-enrollment/`. |
| 111 | - `EnrollAutomatic` and `checkConfirmed` (unexported, `hostactors`' own) take a `MembershipReader` (the console's AWS client) and call `Member` themselves, each call cut off at `storeCallTimeout` (15 s) as unknown. The confirmation is built from that answer alone, by the unexported `confirmMembership`, and never leaves `hostactors`; no exported function takes a confirmation or a `Membership`. Tests use a fake reader. |
| 112 | - A member is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account, in its AWS region, with a request id from each of the two calls. |
| 113 | - **Freshness bound: 60 seconds, enforced at the write (S1, T3, U2).** A fresh describe runs immediately before each write, the invitation and the enrolment. **The write deadline is anchored at the describe's start, which is never later than its completion, and a write 60 s or more after that anchor is refused. Each store call issued before it gets a hard timeout of 15 s (`storeCallTimeout`).** What that guarantees (V1): every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. `writeBound` is the single enforcement point. It gives the sealed writers (`Invite`, and `Replace` with the `checkMembership` it makes) a copy of the directory whose store refuses every call at or after the deadline, by the read's clock or the context's, and a context that ends at the deadline. What it guarantees: no store call, the roster `Put` and the invitation's `Put` included, is issued at or after the deadline; a call in flight then is cancelled; a call the store has not answered 15 s after it was issued is cut off by the wrapper itself (`deadlineStore.call` returns on its own and cancels the call's context, which the AWS SDK enforces at the HTTP layer); and a call that answers only after the deadline, even with success, is `ErrWriteDeadline`, unknown. The store is wrapped, not trusted: `release.S3Store` passes its context to the AWS SDK, which honours it, but `release.DirStore` ignores its context. The pass's S3 store is the console's claim data boundary, whose client the console shares; rather than a second client and a second boundary, the wrapper gives each call its own 15-second context, the same hard limit at the HTTP layer, and does not wait past it whatever the store does. Every write in the invitation and enrolment paths is conditional (V4; lines are main's, `6834659c`): the roster write is `Directory.Replace`'s compare-and-swap, `d.Store.Put(ctx, Key, wire, before.ETag)` (`internal/hostactors/roster.go:281`), which refuses an observation with no ETag (`roster.go:216`), and every roster write the pass makes goes through it (`enroll.go:80`, the enrolment `EnrollAutomatic` makes through `enroll`, and `enrollment_confirmation.go:158`, `BeginEnrollmentAttempt`'s); the invitation is created only if absent, `d.Store.Put(ctx, key, raw, "")` (`internal/hostactors/invitation.go:78`). `release.Store`'s contract makes `""` create-only (`If-None-Match: *`) and an ETag replace-only (`If-Match`) (`internal/release/store.go:40`), as `S3Store.Put` sends them (`store.go:795` to `:797`) and `DirStore.Put` checks them (`store.go:430` to `:440`). The pass's store keeps that: the observed mutation boundary re-reads the object and refuses a Put whose ETag no longer matches before forwarding the same condition (`internal/hostops/mutation_boundary.go:147` to `:162`), and the conditional record store passes it through for every key that is not an instance or sidecar record (`internal/hostops/record_writes.go:86` to `:88`), over the console's raw store (`internal/console/claim_runtime.go:42`, `:46`, `:60`). So a write that commits late lands only on the exact roster revision it was built against, or creates an invitation that did not exist; it never overwrites a newer revision or another enrolment. A missed deadline keeps the attempt and its wait. A describe that itself took 60 seconds or more is `ErrMembershipStale`, checked once in `checkConfirmed` after the describe, and counts against the attempt. Nothing else checks freshness: no callback in `enroll` and no check before `Invite`, which would only repeat the bound; between the describe and `Invite` sits nothing that waits. |
| 114 | - **Unknown outcomes are settled by the roster (U1).** An outcome the bound cannot confirm (`ErrWriteDeadline`) is resolved the ordinary way on the next pass, which reads the signed roster as every pass does: a box the roster lists is enrolled, and the pass has nothing more to do for it; one it does not list goes on with the same attempt while it is still valid, and its wait does not grow. There is no read-back, no memory of unknown outcomes and no page. An instance that left its group but still runs after its enrolment landed keeps that enrolment until an operator removes it or spec 3's regional worker reconciles it ("Activation gates", note); a terminated one is kept from hosting and from capacity by main's termination fence (`internal/hostactors/fence_authority.go:14`, honoured at `internal/hostops/executor.go:204`), and a stopped one by the stale-edges rule (`internal/boxchoice/boxchoice.go:131`) and C3's restart hold (Task 6's `relaunched`). |
| 115 | - **Audit (S5).** Main's roster wire has no field for request ids, on the attempt or on the box entry, so none is added: the console logs every membership read, with the box's reference, its attempt id, its instance id, both request ids (or "none" for a call that failed before the cloud answered) and the read's time, and what came of it: invited, enrolled, refused or unknown (`AutoEnroller.Logf`). |
| 116 | - **Unknown is not invalid (#7).** A read that fails, or is not an answer (EC2 does not list the instance, a missing request id), is `ErrMembershipUnknown`: the pass asks again next time with the same attempt, which it neither supersedes nor counts. Only the cloud's positive answer that the instance is not a current member (`ErrNotMember`), or a read that went stale before its write (`ErrMembershipStale`), counts against the attempt: the pass keeps it, asking nothing more, for `Patience`, doubling with each refusal up to 32 times, then begins another. A record whose instance is gone costs one roster write and one describe an hour. |
| 117 | - **Predecessors until spec 3 (C3).** 2a's readers already exclude fenced and terminated boxes: every one goes through `ExecutorChooser.eligible` (`internal/hostops/executor.go:172`), which keeps only `active` roster entries (`:201`) and drops a box whose termination fence validates (`:204`, `TerminationFence`), and `boxchoice.Eligible` then requires a fresh edges file (`internal/boxchoice/boxchoice.go:131`) and drops the claim's lost boxes (`:143`), and `boxchoice.Placeable` drops headroom older than 15 minutes (`internal/boxchoice/policy.go:69`). `PlaceableBoxes` (`executor.go:103`, the create, move and no-room doors: `internal/console/apiinstances.go:1267`, `boxselect.go:204`, `nocapacity.go:171`), `Choose` and `Permits` (`executor.go:61`, `:85`) and `Capacity` (`executor.go:122`, the `EligibleBoxes` metric through `internal/console/regioncapacity.go:108`) all read that set. A terminated box is out as soon as its fence is written, and in any case once its edges file is 15 minutes old; a stopped box drops out by the same freshness rule. 2b adds no exclusion. |
| 118 | - **The channel install (A1-6, A2-2).** `carlos system install-channel` verifies the signed pointer (`verifyPointer`), refuses a release that does not pass the published host floor exactly as guard-arm will check it (`hostfloor.CheckImage`), fetches against the signed sha256, stages through `stageCandidate`, and puts the artifact in both `carlos` and `carlos.prev`. It keeps the release identity for guard-arm, and writes `applied.json` and `seen.json` as an apply would. On every run, each directory on the agent's cache path (`system`, `system/router` and the version's own) is created as bootstrap creates directories (`bootstrapMakeDir`, no symlink followed) and handed, alone, to the service user (`ownByService`: bootstrap's descriptor-anchored open, then `Fchown`), whether this run or an earlier failed one created it; a symlinked parent is refused; nothing else is chowned. The agent's next update can then download and ask. It refuses an armed guard, a box that already runs another release, a missing floor, and any read that fails. |
| 119 | - **host.env (§5 step 2).** SecureString parameters named for their variables, under `/carlos/<deployment>/<region>/` and, for the five shared variables, `/carlos/<deployment>/shared/`. Every variable §5 step 2 names is required. The file is written 0600 by root, sorted `KEY=value` lines, with values restricted to letters, digits and `_ . / : , @ + = % -` so systemd and a shell read it alike; anything else is refused, naming the variable and never the value. A variable in both scopes is refused. Only allowlisted names are accepted (`Required` and `Optional`, census condition 2): any other name, under either scope, is refused by name by `write` and by `seed`, and keys that would select a box's authority from the store alone (fleet or tunnel identity, address overrides, pools, directories, relay settings, tokens) are not on the list. Two more kinds are left off (final review). `CARLOS_DNSIMPLE_TOKEN` and `CARLOS_ALERT_WEBHOOK` are credentials, or can carry one: a webhook URL can embed its token. The status destination overrides, `CARLOS_STATUS_BUCKET`, `CARLOS_STATUS_PREFIX` and `CARLOS_STATUS_REGION`, would split a group box's reports from the console's reads: a group box reports to the deployment bucket, unprefixed, where the console reads its guard-ready report, while joining and once hosting. Each is refused by name, never by value, like any other name off the list. The path is fixed, `/etc/carlos/host.env`; `write` takes no flag to move it (U3). It is written only through `/etc` and `/etc/carlos` held by descriptor: each opened `O_NOFOLLOW|O_DIRECTORY` and refused unless `fstat` shows a directory root owns that no group or other can write (`/etc/carlos` is created 0755 first if missing, then opened and checked the same way); the new file is created 0600 inside the held directory, fsynced, renamed over `host.env` within it, and the directory fsynced, so a reader sees the old file or the new one. That code is Linux's (`write_linux.go`); elsewhere `write` refuses. `write` adds `CARLOS_BOX_REFERENCE=group`, which is never a parameter. The marker and `CARLOS_REGION`, which do select authority, are cross-checked against the signed roster at the agent's use (condition 3, Task 6), not at write: at boot the box has no roster entry to check against, and before enrolment neither grants anything, since the console derives a joining box's reference and region from its proven instance. `seed` writes a region's own values with overwrite and the shared ones create-only (`SetNew`, SSM `Overwrite: false`); when another seed got there first, it reads the value back and goes on only if it is the value it would have written, otherwise it refuses, naming the variable. |
| 120 | - **No roster field is added.** The roster wire is unchanged (`EnrollmentAttempts` and the region's group fields are main's), so 2b has no reader-ordering constraint. The only new objects are box-local (`/opt/carlos/system/join/`, `/opt/carlos/system/router/channel-identity.json`, `/data/system-join-requested`) or main's keys (invitations, status reports, identity records). Nothing is written under `control/box-enrollment/`: main's `ConfirmationKey` and the stored confirmation (`MembershipConfirmation`, `TerminatedEnrollment`) had no reader but `EnrollAutomatic` and its tests, so Task 4 removes them. |
| 121 | - **The provider boundary (§25).** Spec 2's core files import no `github.com/aws/...` package; AWS parts are adapters named for AWS; `region_ha_boundary_test.go` holds both. 2b builds no S3 client. |
| 122 | - **The status listing (T2).** Every replaced group box leaves a status report under its own reference, so `status/boxes/` keeps growing. `loadBoxesReport` (the reader behind `status.LoadBoxes` and `LoadBoxesComplete`) lists the prefix with each object's write time where the store gives one (`release.ModLister`: both real stores do), and drops reports no box has rewritten in a day before any read and before its 200-key cap, so the cap bounds current reports only. It is an optimisation, never authority: every reader still judges each report's own `GeneratedAt` as before (`BoxStale`, the console's `VerifyReport`), and a day keeps every box an operator would still look for as stale. A store without write times (a fleet's prefix store) is listed whole, as before. 2c adds a 30-day expiry on `status/boxes/` as hygiene; nothing depends on it, so a self-hosted store without lifecycle rules works the same. |
| 123 | - **Copy.** Every string a person reads is a pending row in `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`, gated by `approved_region_ha_2b_copy_test.go`. Tasks 2, 3, 7 and 9 write rows and wait for Paul's approval. No em dashes. |
| 124 | - **Go commands:** `export GOFLAGS=-buildvcs=false`, with `GOCACHE` left at its default (`~/.cache/go-build`). Run tests with `nice -n 19` and a `-run` filter; never the whole `internal/host` suite (CI's `make test` is the full run). Tests that bind unix sockets fail inside the sandbox ("socket: operation not permitted"); that is the sandbox, not the change. A sandbox that cannot write the default cache needs the go commands run outside it, not a cache under `$TMPDIR`. |
| 125 | - **Census:** every task that changes a file under a package directory re-pins that directory's `@coverage` row in `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`, loaded last. Each task runs all three censuses: the record-write census (`TestPlan9B8RecordWriteCensus`), the Plan 14 root-read census (`TestPlan14RootReadCensus`) and the console claim-authority source census (`TestClaimAuthoritySourceCensus`). |
| 126 | |
| 127 | ## Activation gates |
| 128 | |
| 129 | 2b ships dormant. Automatic enrolment must not be switched on for any region until all four hold: |
| 130 | |
| 131 | 1. **Group references have shipped** (Astra round 2, #3). A release with all of 2b, Tasks 2, 4, 5, 6 and 8 in particular (the references, the status tick that publishes only under them, and the status listing that keeps replaced boxes' history from hiding the running ones), runs on every box of the region and on the console. Without them labels collide, and a region in its first build has no healthy target, so its group never replaces the box that cannot publish (spec §5's first-build exception); an alarm reports that stall and does not repair it. They ship in 2b, so this gate is that release being rolled. |
| 132 | 2. **`pendingTime` is verified on dev** (Open question 7): verify on dev before the first region that a group box stopped and started is held and replaced, and that one rebooted hosts again. |
| 133 | 3. **The guard's restart transition is seen on dev** (final review). On dev, a fresh group box goes guard-arm, then carlos-system-recover, then guard-exec across the joining-to-hosting restart, and comes up hosting. After the restart the guard drop-in requires `carlos-system-recover.service`, and a recover that fails leaves the edge down, since `Restart=always` does not fire again on a failed dependency. Static inspection cannot establish that systemd transition. |
| 134 | 4. **carloku.com's host.env files are checked against the allowlist** (final review), which now excludes the status destination overrides and the alert webhook ("Global Constraints", host.env), before the first seed. A box that carries an excluded name has to drop it, or its region cannot seed. |
| 135 | |
| 136 | **Activation note: detached instances are spec 3's.** 2b removes nothing from the roster. An instance detached from its group but still running keeps its enrolment until an operator removes it. Spec 3's requirements carry it: **the regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances** (spec §12, Open question 9). |
| 137 | |
| 138 | ## Census items for platform-1e (census ruling region-ha-2b) |
| 139 | |
| 140 | **RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b**, approved on conditions, all built here (round 3). The overlay's first line says so. One legacy-sealed unit changes, as ruled: `statusLoop`. Every other legacy-sealed unit 2b touches is called, never edited: `runAgent`, `edgesFileLoop`, `ExecutorChooser.eligible`, `stageCandidate`, `Directory.Invite`, `Directory.BeginEnrollmentAttempt`, `Directory.checkMembership`, `Directory.Replace`, `readIdentityRecord`, `status.CollectBox`, `status.BuildFleet` and `RunSystemGuardArm`. The gate sits in `RunAgent`, which is not a census unit, so `runAgent` keeps its digest. Rows that changed in round 5 are marked, with their old values; the new values are platform-1e's to confirm. The seal does not move in round 5. |
| 141 | |
| 142 | **Census rows.** |
| 143 | |
| 144 | | Task | Census | Unit | Class (ruled) | Basis, change and the ruling's conditions | Seal | |
| 145 | |---|---|---|---|---|---| |
| 146 | | 2 | root-read | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New; **changed in round 4** (kinds `file:ReadFile@1500`, digest `df447017...`; were `file:Lstat@1495,file:ReadFile@1930`, `b1cdca1b...`). `write` runs as root, takes only allowlisted names with safe values, and hands the file to `writeHostEnv` at its fixed path, so its `Lstat` of the directory goes; `seed`'s read site is its `--from` file. Conditions: no box write to the parameters (2c seam 3), the allowlist and atomic, checked write (built, U3), authority-selecting values cross-checked against the roster at the agent's use (Task 6) | stands | |
| 147 | | 2 | root-read | `internal/hostenv/write_linux.go:writeHostEnv` | authority, A,J4 (`census-ruling-region-ha-2b`; **re-marked in round 5**, was `PROPOSED-region-ha-2b-r4`) | New in round 4 (kinds `file:Open@68,file:Openat@509`, digest `1245fc0f...`). Writes `/etc/carlos/host.env` only through `/etc` and `/etc/carlos` held by descriptor and checked by `checkedDir`: a temporary file created 0600 (`O_EXCL\|O_NOFOLLOW`) inside the held directory, fsynced, renamed over `host.env` within it, the directory fsynced. Its read sites open the root and the temporary file. Ruled with `runHostEnv`, as the checked parent its condition 2 asks for | stands | |
| 148 | | 2 | root-read | `internal/hostenv/write_linux.go:checkedDir` | authority, A,J4 (`census-ruling-region-ha-2b`; **re-marked in round 5**, was `PROPOSED-region-ha-2b-r4`) | New in round 4 (kinds `file:Fstat@576,file:Openat@168,file:Openat@372`, digest `012257ac...`). Opens one directory under one already held, `O_NOFOLLOW\|O_DIRECTORY`, creating it 0755 first only when asked and missing, and refuses it unless `fstat` shows a directory owned by root (this process's user, which `write` requires to be root) that no group or other can write | stands | |
| 149 | | 3 | root-read | `internal/host/installchannel.go:channelInstall.run` | authority, F,J4 | New. Its one read site opens root's apply lock (`systemApplier.lockPath`); the install goes through `verifyPointer`, `hostfloor.CheckImage` and `stageCandidate`, unchanged; cache directories root-created, symlinks refused, as ruled | stands | |
| 150 | | 4 | record-write (B8) | `internal/hostactors/membershipread.go:deadlineStore.Put` | nonrecord (`def:Put,sel:Put`) | New; **digest changed in round 4** (`848f2b52...`, was `e0f34b1d...`): it runs under `deadlineStore.call`, the 15-second hard timeout, and a success that arrives after the deadline is unknown. It forwards `Put` and refuses it at or after the deadline; it names no record key | n/a | |
| 151 | | 5 | root-read | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New, unchanged in rounds 3 and 4 (U1 removed the read-back, not this unit). The signed roster and the host floor, in the console | moves | |
| 152 | | 5 | claim-authority | Sites `enrollpass.go\|startEnrollPass\|newAutoEnroller(s.cfg.ClaimAuthority, members)`, `enrollpass.go\|startEnrollPass\|runEnrollPass(ctx, e, enrollPassInterval, log.Printf)`, `enrollpass.go\|runEnrollPass\|e.EnrollPass(ctx)` | reviewed authority sites | New, unchanged in rounds 3 and 4 | n/a | |
| 153 | | 6 | root-read | `internal/host/agent.go:statusLoop` | accepted-legacy, re-sealed: `accepted-legacy;rev3=census-ruling-region-ha-2b` | Unchanged in round 4. Re-sealed again in round 3 (digest `666ddbc4...`, was `eb4850f8...`; kinds back to main's `whole:Route@6154,@6181,@7423,@9437`): main's body with one call changed, its tick through `groupStatusTick`. A box with the group marker publishes only under its group reference, resolved each tick, and nothing until it resolves; every other box gets `statusTick` with today's label and key. The seal moves to `fc584e11...` (was `78fb616c...`) | moves | |
| 154 | | 7 | root-read | `internal/host/units/carlos-system-join.path` and `carlos-system-join.service` (`external`) | authority, F,J4 | New, unchanged files. Condition: the path unit's file is only a trigger; root's join reads nothing from it and takes no parameter from it, and decides from the signed invitation, the published record, the signed floor and IMDS (tested) | stands | |
| 155 | | 9 | root-read | `system.go:runSystem`, `system.go:systemVerb` | authority, B,C,D,F,J4 (seed overlay, kept) | Re-digest only: three verbs added; class, basis and (empty) kinds unchanged | stands | |
| 156 | |
| 157 | **Changed code no census selects**, listed so the ruling covers everything 2b changes: |
| 158 | |
| 159 | | Task | Unit | Change | Why no census row | |
| 160 | |---|---|---|---| |
| 161 | | 2 | `internal/hostenv` (`Fetch`, `Assemble`, `Seed`, `sameShared`, `Allowed`, `notAllowed`; `write_other.go:writeHostEnv`) | The group-box marker; create-only shared values; the allowlist; off Linux, `write` refuses | No root-read site; `SetNew` and `Set` are not `Put`. Round 4 removed `ownedByEUID` with its files | |
| 162 | | 3 | `channelInstall.cacheDir`, `ownByService`, `ownDir` | Every cache parent made and handed to the service user, each run; symlinks refused | They call `bootstrapMakeDir`, `bootstrapOpenDir` and `bootstrapOwner` (main's, unchanged) | |
| 163 | | 4 | `Directory.EnrollAutomatic`, `checkConfirmed`, `confirmMembership`, `writeBound`, `deadlineStore.call`, `deadlineStore`'s other methods | The console's own read; the write bound, the single enforcement point, with each call's 15-second hard timeout; the group-reference check | Not root-read units; `deadlineStore.Put` is the B8 row above. `Directory.enroll` is main's again (round 3) | |
| 164 | | 4 | `Directory.PrivateBoxReport` (`private.go`) | A group box's report is read under the reference derived from its pinned instance | Not a census unit. It feeds `VerifyReport` (unchanged) for `checkMembership`, `Census` and activation; non-group boxes unchanged | |
| 165 | | 4 | `GroupBoxReference` (`groupref.go`) | New: the reference function | No I/O | |
| 166 | | 5 | `AutoEnroller.EnrollPass`, `step`, `confirmAndWrite`, `logRead`, `logOutcome` | The pass, its write bound, its reference check and its log lines (round 4 removed `readBack` and `pageNotMember`) | Not root-read units; its construction and invocation are the claim-authority sites above | |
| 167 | | 6 | `metricsBoxLabel`, `boxRecordLoop`, `productionModeFacts`, `crossCheck`, `groupReferenced`, `groupBoxReference`, `groupStatusTick` | A group box names itself by its group reference and publishes status only under it; the marker and region cross-checked against the roster | Not census units; `statusLoop` is the row above, and `statusTick` (sealed) is called unchanged | |
| 168 | | 6 | `internal/status/fleet.go:loadBoxesReport`, `listBoxKeys` | Reports no box has rewritten in a day dropped from the listing before the key cap | Not census units (no read site of the census's kinds); `BuildFleet` (sealed) is unchanged and calls it as before | |
| 169 | | 8 | `productionJoining`'s report | The guard-ready report under the group reference | Not a census unit | |
| 170 | |
| 171 | **Record-write census (B8): one entry, `deadlineStore.Put` (Task 4).** Invitations are still written by main's `Invite`, roster writes by main's `BeginEnrollmentAttempt` and `Replace` (through `EnrollAutomatic`), and the parameter store's write methods are `Set` and `SetNew`. **Console claim-authority census: three sites added (Task 5).** The census would go red if the construction, the start or the invocation were removed or duplicated; the dry run proved each through `go test -overlay`. |
| 172 | |
| 173 | **Build base.** The values below were computed on main `6834659c`. If main moves before the build, recompute every `@coverage` row, digest, `@legacy` fingerprint and seal there with the tooling below; the rows to write stay the same. |
| 174 | |
| 175 | ## Census tooling |
| 176 | |
| 177 | Use plan 2a's tooling (its "Census tooling" section: `coverage.sh`, `unitdigest`, `legacyrow.py`), created under `$TMPDIR/census` and deleted afterwards, with two additions: |
| 178 | |
| 179 | - **Embedded unit files.** `coverage.sh` counts only the census's file extensions, but `internal/host`'s `//go:embed` also brings in `units/*.mount` and `units/*.slice`, which the census counts. For `internal/host/units` (Task 7) compute the row over every file in the directory: |
| 180 | |
| 181 | ````bash |
| 182 | # coverage-all.sh <dir>: the @coverage row of a directory whose every file is embedded. |
| 183 | dir=$1 |
| 184 | hash=$(for f in "$dir"/*; do [ -f "$f" ] && printf '%s\0%s\n' "$f" "$(sha256sum "$f" | cut -d' ' -f1)"; done | LC_ALL=C sort | head -c -1 | sha256sum | cut -d' ' -f1) |
| 185 | printf '@coverage\t%s\t%s\n' "$dir" "$hash" |
| 186 | ```` |
| 187 | |
| 188 | - **The seal.** `seal.py` prints the seal over every overlay `TestPlan14RootReadCensus` loads, in its order, plus the ones named: |
| 189 | |
| 190 | ````python |
| 191 | import hashlib, re, sys, os |
| 192 | # seal.py <repo root> [extra overlay ...]: the Plan 14 legacy seal after every |
| 193 | # overlay TestPlan14RootReadCensus loads, in its order. |
| 194 | root=sys.argv[1] |
| 195 | t=open(os.path.join(root,'internal/provision/plan14_root_reads_test.go')).read() |
| 196 | body=t[t.index('func TestPlan14RootReadCensus('):t.index('func TestPlan14RootReadCensusRejectsDrift')] |
| 197 | files=re.findall(r'"internal/provision/testdata/(plan14-root-reads[^"]*\.tsv)"',body) |
| 198 | if not os.path.exists(os.path.join(root,'internal/host/movesheldrelease_production.go')): |
| 199 | files=[f for f in files if f!='plan14-root-reads-moves.tsv'] |
| 200 | files+= [f for f in sys.argv[2:] if f not in files] |
| 201 | fps=set() |
| 202 | for f in files: |
| 203 | for line in open(os.path.join(root,'internal/provision/testdata',f)): |
| 204 | c=line.rstrip('\n').split('\t') |
| 205 | if c[0]=='@legacy': |
| 206 | fps.add(c[1]) |
| 207 | print(hashlib.sha256('\n'.join(sorted(fps)).encode()).hexdigest()) |
| 208 | ```` |
| 209 | |
| 210 | A new unit's kinds are the ones `TestPlan14RootReadCensus` prints for it as "unclassified root read"; its digest is `unitdigest`'s (for an `external` unit, the file's sha256). The overlay is tab-separated: keep the tabs in every row this plan gives, or the loader refuses the row. |
| 211 | |
| 212 | The census checks every task runs: |
| 213 | |
| 214 | ````bash |
| 215 | nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 |
| 216 | nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -count=1 |
| 217 | ```` |
| 218 | |
| 219 | ## Review Focus |
| 220 | |
| 221 | 1. **An enrolled box whose roster cannot be read at start must not fall back to joining**, or a bucket blip would take a hosting box out of service and send it through enrolment again. Task 6's `TestSelectModeIsUnknownWhenAFactCannotBeRead` and Task 8's `TestDecideAgentMode` (a roster that fails twice, then reads: the gate waits, logs once, and hosts). |
| 222 | 2. **A joining box must never look healthy or referenced.** Task 8's `TestJoiningServes503UntilItCanHostAndPublishesNoEdgesFile`: 503 naming `hosting` throughout, nothing but the health path served, no `control/edges/` object written, and a stale `hosting` flag cleared at the start. |
| 223 | 3. **The health answer must drop when the claim worker stops.** Task 8's `TestClaimWiringHostingFollowsTheModeAndTheWorker`. |
| 224 | 4. **No enrolment without the guard-ready report.** Task 5's `TestAutomaticEnrollmentFromRecordToRoster` holds an invitation and an unready report together and expects no enrolment and no membership read; with the `VerifyReport` gate removed the pass enrolls and the test fails. |
| 225 | 5. **Only the console's own read makes a box a member (H28, C1).** Task 4's `TestEnrollAutomaticReadsMembershipItself`, `TestEnrollAutomaticRefusesANonMember` and `TestNoExportedPathTakesAConfirmation`, and Task 5's `TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled`: an instance in the deployment's account that the cloud places in another group, or none, is never invited or enrolled however long it waits, and one that leaves the group after its invitation is not enrolled on its guard-ready report. |
| 226 | 6. **No store call of a write is issued 60 seconds or more after its read (C2, S1, T3).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a describe of exactly 60 seconds, and a read that reaches its deadline before the roster CAS), and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`, `TestASlowReadInsideInviteWritesNothing` and `TestASlowReadInsideTheRosterCheckWritesNothing` (a bucket read inside the sealed `Invite` or `checkMembership` that answers exactly at the deadline leaves no `Put` issued), and `TestAPutInFlightAtTheDeadlineIsUnknown` (a `Put` still in flight at the deadline is cancelled; the outcome is unknown, the attempt and its wait stand, and a write that did land is found by the next pass's ordinary read). |
| 227 | 7. **A failed read is unknown (#7).** Task 4's `TestAMembershipReadThatIsNotAnAnswerIsUnknown` and Task 5's `TestAMembershipReadThatFailsKeepsTheAttempt`: ten hours of failed reads keep the same attempt and the same wait. |
| 228 | 8. **Root arms the guard only for this box's own invitation and record.** Task 7's `TestRootJoinRefusesAnInvitationThatIsNotThisBoxs` (another instance, another record, another release key, another deployment, an unreadable store). |
| 229 | 9. **Both slots hold the channel's artifact, nothing below the floor is installed, and the agent can still update.** Task 3's tests; a store that cannot be read is a refusal, never "no floor"; the directories install-channel creates go to the service user, and the agent then downloads and requests the next release. |
| 230 | 10. **Records of boxes that are gone must not churn the roster or the cloud.** Task 5's `TestARefusedAttemptIsBegunAgainWithADoublingWait` pins the doubling wait, its cap, and no describe while an attempt waits. |
| 231 | 11. **host.env never assembles from a partial read, never prints a secret, and two seeds cannot both win.** Task 2's `TestFetchRefusesAFailedReadAndAConflict`, `TestRenderRefusesAnUnsafeValueWithoutPrintingIt` and `TestSeedRacesForASharedValueAndOnlyOneWins`. |
| 232 | 12. **A group box started again never hosts on its old enrolment (C3).** Task 6's `TestSelectModeHoldsAGroupBoxStartedAgain` and `TestRelaunchedComparesTheLaunchTheRosterBinds`. |
| 233 | 13. **Group references (S3, platform-1e's four tests).** Two group boxes never collide (Task 4's `TestGroupBoxReferencesDoNotCollide`, 200,000 instances); a box can't satisfy another box's report check or enrol under a key it chose (Task 5's `TestABoxCannotStandInForAnother`, Task 4's `TestEnrollAutomaticRefusesAReferenceTheBoxChose` and `TestPrivateBoxReportDerivesAGroupBoxsKey`); an existing non-group box keeps its label and status key byte for byte (Task 6's `TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey`); and a group box names itself by its reference everywhere, `statusLoop` included (Task 6's `TestAGroupBoxTakesItsGroupReference`). |
| 234 | 14. **Ownership is reconciled on a retry (S2).** Task 3's `TestInstallChannelReconcilesOwnershipOnARetry` and `TestInstallChannelRefusesASymlinkedParent`. |
| 235 | 15. **A group box never publishes status under a key it doesn't own (T1).** Task 6's `TestAGroupBoxPublishesOnlyUnderItsReference`, through `statusLoop`'s own tick and `WriteBox`: while IMDS fails, another box's report under the colliding label is untouched and nothing is written, with the switches on or off; the reference is asked again each tick, and on recovery the box publishes under it. `TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey` compares every other box's keys with `statusTick`'s. |
| 236 | 16. **Replaced boxes' history never hides the running ones (T2).** Task 6's `TestStaleReportsDoNotHideLiveBoxes`: 250 day-old reports and two live boxes: both live boxes found, and the reading complete. |
| 237 | 17. **No store call is issued past its bound, the console waits no longer than its timeout, and an unknown outcome is settled by the roster (U1, U2, V1, V2).** Task 4's `TestTheBoundReachesTheSDKRequest`: through the console's production stores, a successful conditional roster `PUT` and every other SDK request carry 15 seconds from their call, or the earlier write deadline. Task 5's `TestAWriteTheConsoleGaveUpOnCanCommitLater`, the accepted exposure: a `Put` the console gave up on commits after the next pass found the box absent and refused it on a fresh describe without writing, and the pass after that has nothing to do. Task 5's `TestAStoreThatIgnoresItsContextIsCutOffAtTheHardTimeout`: a store that ignores its context and never answers is cut off at the hard timeout, the outcome unknown, the attempt and its wait unchanged. `TestAnUnknownEnrolmentIsSettledByTheRoster`: an enrolling write that did not land goes on with the same attempt and enrols next pass; one that landed, whether cut off in flight or answering success only after the deadline, is unknown, and the next pass, reading the roster, has nothing left to do: no step and no describe. |
| 238 | 18. **host.env takes nothing that could change a box's authority unchecked (runHostEnv conditions, U3).** Task 2's `TestWriteTakesOnlyAllowedNamesAndSafeValues`, `TestHostEnvCommand` (no flag moves the path) and `TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite` (a group-writable `etc/carlos`, a world-writable `etc`, an `etc/carlos` another user owns, a symlinked `etc/carlos` and a symlinked `etc` each refused with nothing written; a normal write lands 0600, replaces the old file by rename, never in place, and leaves no temporary file); Task 6's `TestSelectModeHoldsAStoreValueTheRosterDisagreesWith`; Task 7's `TestRootJoinTakesNothingFromItsTrigger`. |
| 239 | |
| 240 | ## Dry run |
| 241 | |
| 242 | Every code block below was written into this branch on main `6834659c` (plan 2a merged), built and vetted for linux, darwin and windows, and its tests run as each task's steps say, with `nice -n 19` and a `-run` filter; the whole `internal/host` suite was never run. The plan was then replayed from a clean base, task by task in order, by applying its own code blocks: every intermediate state built on all three platforms, vetted, passed its task's tests and all three censuses, and the census values here are those runs'. The copy gate was checked without approving anything: with every row pending, `TestRegionHA2bCopyRecordIsComplete`, `TestRegionHA2bCopyHasNoEmDash` and `TestRegionHA2bBuiltCopyIsApplied` pass, and `TestRegionHA2bBuiltCopyIsApproved` fails naming exactly the built ids (21 after Task 2, 66 after Task 9). With a scratch record that marks every row approved, the applied check passes for all 66 rows, and fails for a row whose text was edited. |
| 243 | |
| 244 | Rounds 1 to 5 were dry-run the same way, each from a clean base for every task. Round 6 changed only Task 4's `TestTheBoundReachesTheSDKRequest`, and was dry-run from a clean base through Task 4. Round 4 ran 51 mutations through `go test -overlay` from `$TMPDIR`, never editing source in place, and every one was caught by the test named: 37 kept from rounds 1 to 3 and run again on round 4's code (the group check and `EnrollAutomatic`'s own read; a failed read counted against the attempt or reported as a non-member; no backoff; enrolment without the guard-ready report; seed overwriting shared values or not reading one back; install-channel not handing its directories over, handing over only the ones it created, or following a symlinked parent; the relaunch hold, or `relaunched` ignoring `pendingTime`; joining marking the box hosting; the bound store never refusing; `Invite` or `enroll` run unbound; a missed deadline counted against the attempt; a group box keeping its label, or every box taken for one; `PrivateBoxReport`, the pass or `checkConfirmed` trusting a chosen key; the reference without the account; `Fetch` without the marker; a log line without the attempt id; the report read under another key; a group box's status tick falling back to its label, or resolving its reference once; the status listing ignoring write times, or keeping history; the bound with no deadline context; a read of exactly 60 seconds taken as fresh, and a store call exactly at the deadline issued; no allowlist; no cross-check against the roster; and root's join reading its trigger), and 14 for U1 to U3 (no hard per-call timeout; a success after the deadline accepted; an unanswered call reported as success; a cut-off call taken at the store's word; an unknown counted against the attempt, or the attempt forgotten; and for host.env, symlinks followed, `etc` opened unchecked, a group- or world-writable directory accepted, a directory another user owns accepted, the file rewritten in place, the temporary file left behind, `--out` accepted again, and a write that bypasses the held directory). Round 5 changed only a comment in production code, so those stand; it added 9 mutations, all caught. For V1, `TestAWriteTheConsoleGaveUpOnCanCommitLater` catches a cut-off write reported as success and an absent box enrolled without a fresh describe. For V2, `TestTheBoundReachesTheSDKRequest` catches the bound passing its own context instead of the per-call one, the bound dropping the context, `S3Store.Get` or `S3Store.Put` dropping it, the mutation boundary's `Get` or its `Put` pre-read dropping it, and the conditional record store's `Get` dropping it. Round 6 (W1) re-ran it with the successful conditional `PUT` through every wrapper, and 8 more mutations were caught: the deadline store's `Put`, the mutation boundary's `Put` forwarding and the conditional record store's `Put` forwarding each dropping the context, the bound passing its own context, the boundary's pre-read, `S3Store.Put` and `S3Store.Get` dropping it, and the roster `PUT` sent without `If-Match`. The fsyncs are not observable in a test and were not mutated. The claim-authority census reads source with `go/parser`, which an overlay does not reach, so in round 4 its four mutations overlaid the census test instead, to parse a mutated `enrollpass.go`: with the pass's start removed, its construction duplicated, its invocation removed, or its start duplicated, the census went red each time, and unmutated it stayed green; round 5 does not touch that file. With a scratch record marking every row approved, the copy gate passed, and went red with RHB-C67's text edited. `internal/hostenv` and `internal/hostactors/...` passed in full on the final tree, the new tests also under `-race`, and the console's enrolment and authority-census tests on their own. The code was then removed, leaving the plan, its copy record and the spec. |
| 245 | |
| 246 | --- |
| 247 | |
| 248 | ### Task 1: The copy record and its gate |
| 249 | |
| 250 | **Files:** |
| 251 | - Create: `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md` |
| 252 | - Create: `approved_region_ha_2b_copy_test.go` |
| 253 | |
| 254 | **Interfaces:** |
| 255 | - Consumes: the run and moves gates' helpers at the repo root (`parseCopyTable`, `checkMovesInventory`, `checkMovesNoEmDash`, `checkRunBuilt`, `checkRunApplied`, `builtRows`, `reportAll`, `copyRow`), as plan 2a's gate does. |
| 256 | - Produces: `regionHA2bCopyBuilt []string`, which Tasks 2, 3, 7 and 9 extend; the record's ids `RHB-C1` to `RHB-C64`, `RHB-C67` and `RHB-C68` (RHB-C62 to RHB-C64 were added in review round 1, RHB-C67 in round 3 and RHB-C68 in round 4, each in its task's table; RHB-C65 and RHB-C66, round 3's operator alert, were withdrawn in round 4 with the read-back, and their ids are not reused). |
| 257 | |
| 258 | Every user-facing string 2b writes is drafted here first, all `pending`: the output and refusals of the three new commands, host.env's first line, the descriptions systemd shows for the two join units, the `carlos system` usage lines and the docs. Log lines are not rows (2a's rule). The gate is plan 2a's over this record: an id in `regionHA2bCopyBuilt` must be an approved row whose text is in the file it names. |
| 259 | |
| 260 | - [ ] **Step 1: Write the record** |
| 261 | |
| 262 | `docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md`: |
| 263 | |
| 264 | ````markdown |
| 265 | # Region HA plan 2b: copy record (pending Paul's review) |
| 266 | |
| 267 | Every user-facing string plan 2b (`2026-10-17-region-ha-2b-joining.md`) |
| 268 | writes: the output and refusals of `carlos system host-env`, |
| 269 | `install-channel` and `join`, the first line of the host.env file it |
| 270 | writes, the operator alert the console's enrolment pass sends, the |
| 271 | descriptions systemd shows for the two join units, the |
| 272 | `carlos system` usage lines, and the docs prose. The gate is |
| 273 | `approved_region_ha_2b_copy_test.go` at the repo root, which reuses the |
| 274 | moves gate's parser, so the format is the moves record's: one table per |
| 275 | heading, columns id, file, text, status. |
| 276 | |
| 277 | The rules: |
| 278 | |
| 279 | - A row is `pending` until Paul approves it in the copy review (the |
| 280 | `copy-review` skill). His edits are applied to the text here verbatim |
| 281 | and the row becomes `approved`. |
| 282 | - No pending string reaches code, CLI help or docs. The task that writes |
| 283 | a row's text adds its id to `regionHA2bCopyBuilt` in the gate in the |
| 284 | same commit, and from then on the gate fails unless the row is |
| 285 | `approved` and its text is in the file it names. Tasks 2, 3, 7 and 9 |
| 286 | therefore wait for their rows' approval. |
| 287 | - In a Go file the text is one string literal, or one `fmt.Sprintf`, |
| 288 | `fmt.Errorf` or `errors.New` format, equal to the row with a format |
| 289 | verb where the row has a `<placeholder>`. A row whose file cell says |
| 290 | "a fragment" is a piece of a larger literal (a usage text). |
| 291 | - Command syntax is exempt: the `usage:` lines, flag names and the |
| 292 | commands in the docs' code blocks are not rows. |
| 293 | - Log lines are not rows, as in plan 2a: the joining agent's and the |
| 294 | console enrollment pass's. |
| 295 | - Ids are not renumbered: a row added in review takes the next free id |
| 296 | and goes in its task's table (RHB-C62 to RHB-C64, round 1; RHB-C65 to |
| 297 | RHB-C67, round 3; RHB-C68, round 4). A withdrawn row's id is not |
| 298 | reused: RHB-C65 and RHB-C66, the operator alert of round 3, went with |
| 299 | the read-back they belonged to in round 4. |
| 300 | - No em dashes. A full stop and a new sentence instead. Refusals start |
| 301 | lower case, as the CLI's others do. |
| 302 | |
| 303 | Placeholders: `<reason>` the text of the error underneath; `<path>` a |
| 304 | file; `<region>` a placement code; `<deployment>` a deployment's name; |
| 305 | `<scope>` a region or `shared`; `<prefix>` a parameter path such as |
| 306 | `/carlos/prod/ie/`; `<n>` a count; `<name>` and `<names>` one or more |
| 307 | variable names; `<variable>` and `<value>` a variable and its value; |
| 308 | `<version>` a platform release such as v0.31.0; `<tag>` its release tag; |
| 309 | `<release>` the host floor's minimum release; `<channel>` a channel such |
| 310 | as stable; `<platform>` such as linux-arm64; `<box>` a box's reference. |
| 311 | |
| 312 | ## host.env from the deployment's parameters (Task 2) |
| 313 | |
| 314 | | id | file | text | status | |
| 315 | |---|---|---|---| |
| 316 | | RHB-C1 | internal/hostenv/hostenv.go | # Written by carlos system host-env write from the deployment's parameters. Do not edit by hand. | pending | |
| 317 | | RHB-C2 | internal/hostenv/hostenv.go | required values are missing | pending | |
| 318 | | RHB-C3 | internal/hostenv/hostenv.go | values conflict | pending | |
| 319 | | RHB-C4 | internal/hostenv/hostenv.go | a name or value host.env cannot hold | pending | |
| 320 | | RHB-C5 | internal/hostenv/hostenv.go | <reason>: <deployment> and <scope> must be lower-case labels | pending | |
| 321 | | RHB-C6 | internal/hostenv/hostenv.go | <reason>: <names> set both for the region and shared | pending | |
| 322 | | RHB-C7 | internal/hostenv/hostenv.go | <reason>: line <n> is not KEY=value | pending | |
| 323 | | RHB-C8 | internal/hostenv/hostenv.go | <reason>: <name> is set twice | pending | |
| 324 | | RHB-C9 | internal/hostenv/hostenv.go | <reason>: <region> is not a region | pending | |
| 325 | | RHB-C10 | internal/hostenv/hostenv.go | parameters under <prefix>: <reason> | pending | |
| 326 | | RHB-C11 | internal/hostenv/hostenv.go | <reason>: the shared <names> already differ | pending | |
| 327 | | RHB-C12 | internal/hostenv/cli.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending | |
| 328 | | RHB-C13 | internal/hostenv/cli.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending | |
| 329 | | RHB-C14 | internal/hostenv/cli.go | carlos system host-env write runs as root: it writes a root-owned file | pending | |
| 330 | | RHB-C15 | internal/hostenv/cli.go | host.env was not written: <reason> | pending | |
| 331 | | RHB-C16 | internal/hostenv/cli.go | wrote <path> from the parameters for <region> | pending | |
| 332 | | RHB-C17 | internal/hostenv/cli.go | the parameters were not all written: <reason> | pending | |
| 333 | | RHB-C18 | internal/hostenv/cli.go | wrote <n> parameters under <prefix> and <n> under <prefix> | pending | |
| 334 | | RHB-C64 | internal/hostenv/hostenv.go | already set by another seed | pending | |
| 335 | | RHB-C67 | internal/hostenv/write_linux.go | <path> is not a directory only root can write | pending | |
| 336 | | RHB-C68 | internal/hostenv/write_other.go | carlos system host-env write runs only on Linux | pending | |
| 337 | |
| 338 | ## carlos system install-channel (Task 3) |
| 339 | |
| 340 | | id | file | text | status | |
| 341 | |---|---|---|---| |
| 342 | | RHB-C19 | internal/host/installchannel.go (a fragment of the usage) | Install the release this box's channel names, as root, on a box that has none yet. | pending | |
| 343 | | RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | pending | |
| 344 | | RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | pending | |
| 345 | | RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | pending | |
| 346 | | RHB-C23 | internal/host/installchannel.go | installed <version> (<tag>) from the <channel> channel as carlos and carlos.prev | pending | |
| 347 | | RHB-C24 | internal/host/installchannel.go | an update is running on this box, so nothing was installed: <reason> | pending | |
| 348 | | RHB-C25 | internal/host/installchannel.go | the <channel> channel could not be read, so nothing was installed: <reason> | pending | |
| 349 | | RHB-C26 | internal/host/installchannel.go | the <channel> channel names <version> with no artifact for <platform>, so nothing was installed | pending | |
| 350 | | RHB-C27 | internal/host/installchannel.go | this box's host lifecycle guard is armed, so carlos system update installs its releases | pending | |
| 351 | | RHB-C28 | internal/host/installchannel.go | this box's host lifecycle guard could not be read, so nothing was installed: <reason> | pending | |
| 352 | | RHB-C29 | internal/host/installchannel.go | this box's install record could not be read, so nothing was installed: <reason> | pending | |
| 353 | | RHB-C30 | internal/host/installchannel.go | this box already runs <version>, so carlos system update installs its releases | pending | |
| 354 | | RHB-C31 | internal/host/installchannel.go | <version> could not be fetched, so nothing was installed: <reason> | pending | |
| 355 | | RHB-C32 | internal/host/installchannel.go | <version> did not verify, so nothing was installed: <reason> | pending | |
| 356 | | RHB-C33 | internal/host/installchannel.go | <path> does not hold <version> after the copy | pending | |
| 357 | | RHB-C34 | internal/host/installchannel.go | no host floor is published, so this box could not arm its guard and nothing was installed | pending | |
| 358 | | RHB-C35 | internal/host/installchannel.go | the host floor could not be read, so nothing was installed: <reason> | pending | |
| 359 | | RHB-C36 | internal/host/installchannel.go | the host floor does not verify, so nothing was installed: <reason> | pending | |
| 360 | | RHB-C37 | internal/host/installchannel.go | the <channel> channel names <version> with no release tag, so nothing was installed | pending | |
| 361 | | RHB-C38 | internal/host/installchannel.go | the release identity for <tag> could not be read, so nothing was installed: <reason> | pending | |
| 362 | | RHB-C39 | internal/host/installchannel.go | <version> (<tag>) is below the host floor <release> or is not the release its identity names, so nothing was installed | pending | |
| 363 | |
| 364 | ## carlos system join (Task 7) |
| 365 | |
| 366 | | id | file | text | status | |
| 367 | |---|---|---|---| |
| 368 | | RHB-C40 | internal/host/joinroot.go (a fragment of the usage) | Arm the host lifecycle guard of a joining box, as root, once the console has invited it. | pending | |
| 369 | | RHB-C41 | internal/host/joinroot.go | carlos system join runs as root: it arms the host lifecycle guard | pending | |
| 370 | | RHB-C42 | internal/host/joinroot.go | CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join | pending | |
| 371 | | RHB-C43 | internal/host/joinroot.go | the invitation for <box> could not be read: <reason> | pending | |
| 372 | | RHB-C44 | internal/host/joinroot.go | the invitation for <box> does not verify, so the guard was not armed | pending | |
| 373 | | RHB-C45 | internal/host/joinroot.go | the identity record for <box> could not be read: <reason> | pending | |
| 374 | | RHB-C46 | internal/host/joinroot.go | the invitation for <box> is for another identity record, so the guard was not armed | pending | |
| 375 | | RHB-C47 | internal/host/joinroot.go | the identity record for <box> is not this instance's, so the guard was not armed | pending | |
| 376 | | RHB-C48 | internal/host/joinroot.go | the host floor could not be read: <reason> | pending | |
| 377 | | RHB-C49 | internal/host/joinroot.go | the host floor does not verify, so the guard was not armed: <reason> | pending | |
| 378 | | RHB-C62 | internal/host/units/carlos-system-join.path | carlos joining: arm the host lifecycle guard on agent request | pending | |
| 379 | | RHB-C63 | internal/host/units/carlos-system-join.service | carlos joining (root: arm the host lifecycle guard) | pending | |
| 380 | |
| 381 | ## The carlos system usage and the docs (Task 9) |
| 382 | |
| 383 | | id | file | text | status | |
| 384 | |---|---|---|---| |
| 385 | | RHB-C50 | system.go (a fragment of the usage) | A box a region's group launches (region HA): | pending | |
| 386 | | RHB-C51 | system.go (a fragment of the usage) | build host.env from the deployment's parameters (root) | pending | |
| 387 | | RHB-C52 | system.go (a fragment of the usage) | copy a box's host.env into the deployment's parameters | pending | |
| 388 | | RHB-C53 | system.go (a fragment of the usage) | install the release this box's channel names (root) | pending | |
| 389 | | RHB-C54 | system.go (a fragment of the usage) | (root oneshot behind carlos-system-join.service; not for operators) | pending | |
| 390 | | RHB-C55 | docs/site/cli.md | Boxes a region's group launches | pending | |
| 391 | | RHB-C56 | docs/site/cli.md | In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent. | pending | |
| 392 | | RHB-C57 | docs/site/cli.md | carlos system host-env write builds /etc/carlos/host.env from the deployment's parameters: SecureStrings in AWS SSM under /carlos/<deployment>/<region>/ and, for the values every region shares, /carlos/<deployment>/shared/. If a required value is missing, it stops and writes nothing. | pending | |
| 393 | | RHB-C58 | docs/site/cli.md | carlos system install-channel installs the release the box's channel names, as both carlos and carlos.prev, once it passes the published host floor. A box that already runs a release is refused: carlos system update moves it. | pending | |
| 394 | | RHB-C59 | docs/site/cli.md | The agent then joins. It publishes the box's identity record and hosts nothing. Once the console has checked with AWS that the new box belongs to the region's group, it invites the box. The box arms its host lifecycle guard, the console checks the group again and enrolls it, and the box restarts as a hosting box. Only then does the load balancer send it traffic. | pending | |
| 395 | | RHB-C60 | docs/site/cli.md | carlos system host-env seed copies a box's existing host.env into those parameters, once per region. It writes the region's own values and any shared value not yet set. It refuses to change a shared value another region has set. | pending | |
| 396 | | RHB-C61 | docs/site/cli.md | Seeding needs ssm:GetParametersByPath and ssm:PutParameter on the deployment's two parameter paths, and kms:Encrypt and kms:Decrypt on its configuration key. | pending | |
| 397 | ```` |
| 398 | |
| 399 | - [ ] **Step 2: Write the gate** |
| 400 | |
| 401 | `approved_region_ha_2b_copy_test.go`: |
| 402 | |
| 403 | ````go |
| 404 | // approved_region_ha_2b_copy_test.go |
| 405 | package main |
| 406 | |
| 407 | import ( |
| 408 | "os" |
| 409 | "regexp" |
| 410 | "strconv" |
| 411 | "testing" |
| 412 | ) |
| 413 | |
| 414 | // The copy gate of region HA plan 2b (Task 1), plan 2a's gate over plan |
| 415 | // 2b's record. The record is a markdown table in the moves record's |
| 416 | // format, read with the moves gate's parser and checked with the run |
| 417 | // gate's helpers, so "approved" and "applied" mean what they mean there: |
| 418 | // |
| 419 | // - TestRegionHA2bCopyRecordIsComplete: the record names exactly |
| 420 | // regionHA2bCopyIDs; |
| 421 | // - TestRegionHA2bCopyHasNoEmDash; |
| 422 | // - TestRegionHA2bBuiltCopyIsApproved: an id in regionHA2bCopyBuilt is an |
| 423 | // approved row. No pending string reaches code, CLI help or docs; |
| 424 | // - TestRegionHA2bBuiltCopyIsApplied: a built row's text is in the file |
| 425 | // it names. |
| 426 | const regionHA2bCopyRecordPath = "docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md" |
| 427 | |
| 428 | var regionHA2bCopyIDPattern = regexp.MustCompile(`^RHB-C[0-9]+$`) |
| 429 | |
| 430 | // regionHA2bCopyIDs is the record's inventory, pinned: RHB-C1 to RHB-C64, |
| 431 | // RHB-C67 and RHB-C68. RHB-C65 and RHB-C66 were withdrawn in review and |
| 432 | // are not reused. |
| 433 | var regionHA2bCopyIDs = func() []string { |
| 434 | ids := make([]string, 0, 66) |
| 435 | for i := 1; i <= 64; i++ { |
| 436 | ids = append(ids, "RHB-C"+strconv.Itoa(i)) |
| 437 | } |
| 438 | return append(ids, "RHB-C67", "RHB-C68") |
| 439 | }() |
| 440 | |
| 441 | // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| 442 | // task that writes a row's text adds its ids here in the same commit, once |
| 443 | // Paul has approved the rows. |
| 444 | var regionHA2bCopyBuilt = []string{} |
| 445 | |
| 446 | func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| 447 | t.Helper() |
| 448 | raw, err := os.ReadFile(regionHA2bCopyRecordPath) |
| 449 | if err != nil { |
| 450 | t.Fatal(err) |
| 451 | } |
| 452 | rows, err := parseCopyTable(string(raw), regionHA2bCopyRecordPath, regionHA2bCopyIDPattern, "an RHB-C<n>") |
| 453 | if err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | return rows |
| 457 | } |
| 458 | |
| 459 | func TestRegionHA2bCopyRecordIsComplete(t *testing.T) { |
| 460 | reportAll(t, checkMovesInventory(mustParseRegionHA2bRecord(t), regionHA2bCopyIDs)) |
| 461 | } |
| 462 | |
| 463 | func TestRegionHA2bCopyHasNoEmDash(t *testing.T) { |
| 464 | reportAll(t, checkMovesNoEmDash(mustParseRegionHA2bRecord(t))) |
| 465 | } |
| 466 | |
| 467 | func TestRegionHA2bBuiltCopyIsApproved(t *testing.T) { |
| 468 | reportAll(t, checkRunBuilt(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt)) |
| 469 | } |
| 470 | |
| 471 | func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) { |
| 472 | reportAll(t, checkRunApplied(builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt), ".")) |
| 473 | } |
| 474 | ```` |
| 475 | |
| 476 | - [ ] **Step 3: Run it** |
| 477 | |
| 478 | Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1` |
| 479 | Expected: PASS (nothing is built yet). |
| 480 | |
| 481 | - [ ] **Step 4: Put the record in front of Paul** |
| 482 | |
| 483 | Run the `copy-review` skill over the record's rows. Apply his edits verbatim to the record, mark each row he approves `approved`, and commit that on its own. Tasks 2, 3, 7 and 9 wire their rows only once they are approved, with Paul's text. Tasks 4, 5, 6 and 8 do not wait. |
| 484 | |
| 485 | - [ ] **Step 5: Commit** |
| 486 | |
| 487 | ```bash |
| 488 | git add docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md approved_region_ha_2b_copy_test.go |
| 489 | git commit -m "region HA 2b: the copy record and its gate" |
| 490 | ``` |
| 491 | |
| 492 | No package envelope changes: neither file is a non-test Go file. |
| 493 | |
| 494 | --- |
| 495 | |
| 496 | ### Task 2: host.env from the deployment's parameters (`internal/hostenv`) |
| 497 | |
| 498 | **Files:** |
| 499 | - Create: `internal/hostenv/hostenv.go`, `internal/hostenv/cli.go`, `internal/hostenv/write_linux.go`, `internal/hostenv/write_other.go`, `internal/hostenv/awsssm.go`, `internal/hostenv/hostenv_test.go`, `internal/hostenv/write_linux_test.go`, `internal/hostenv/write_other_test.go` |
| 500 | - Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0`) |
| 501 | - Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go` |
| 502 | - Create: `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`; Modify: `internal/provision/plan14_root_reads_test.go` (load it last) |
| 503 | |
| 504 | **Interfaces:** |
| 505 | - Consumes: `golang.org/x/sys/unix` (already required) for the descriptor-held write. |
| 506 | - Produces (`internal/hostenv`): `const Path = "/etc/carlos/host.env"`, `const SharedScope = "shared"`; `const BoxReference = "CARLOS_BOX_REFERENCE"`, `const GroupReference = "group"` (Task 6 reads them); `var Required, Optional, SharedKeys []string`; `Allowed(name string) bool`; `type Params interface { ByPath(ctx, prefix string) (map[string]string, error); Set(ctx, name, value string) error; SetNew(ctx, name, value string) error }`; `ErrMissing`, `ErrConflict`, `ErrValue`, `ErrExists`; `Prefix(deployment, scope string) (string, error)`; `ValidKey(string) bool`; `Assemble(shared, regional map[string]string) (map[string]string, error)`; `Missing(map[string]string) []string`; `Render(map[string]string) ([]byte, error)`; `Parse([]byte) (map[string]string, error)`; `Split(map[string]string) (shared, regional map[string]string)`; `Fetch(ctx, Params, deployment, region string) ([]byte, error)`; `type Seeded`; `Seed(ctx, Params, deployment, region string, env map[string]string) (Seeded, error)`; `RunSystemHostEnv([]string)` (wired into `carlos system` in Task 9). Unexported: `writeHostEnv(root string, body []byte) error` and `checkedDir` (Linux, `write_linux.go`; elsewhere `writeHostEnv` refuses, `write_other.go`); the test seams `var hostEnvRoot = "/"` (the root the fixed path is under) and `var hostEnvOwner = os.Geteuid`. |
| 507 | - Produces (adapter, `awsssm.go`): `openSSMParams(ctx, awsRegion, kmsKey string) (Params, error)` over SSM `GetParametersByPath` (decrypted, paged; any failed page fails the read) and `PutParameter` (SecureString, the deployment's KMS key): `Set` with `Overwrite: true`, `SetNew` with `Overwrite: false`, SSM's `ParameterAlreadyExists` mapped to `ErrExists`. |
| 508 | - Produces: the census overlay every later task edits, and its load in `TestPlan14RootReadCensus`. |
| 509 | |
| 510 | This task writes rows RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `internal/hostenv` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text. |
| 511 | |
| 512 | The core treats host.env as a file and names no provider (§25): `Fetch` assembles it from a `Params`, `Seed` writes a box's existing file into one. The SSM adapter is the only file that imports AWS. The parameter store's write methods are `Set` and `SetNew`, not `Put`, so the record-write census (which matches the name `Put`) has nothing to say about them; `SetNew` is not `Create` either, which the root-read census reads as a file open. |
| 513 | |
| 514 | Shared values are written create-only (R4). Two seeds can both read a shared variable as unset and both try to write it; `SetNew` lets only the first succeed, and the second reads the value back and goes on only when it is the value it would have written, otherwise it refuses with `ErrConflict`, naming the variable and never a value. A region's own values keep `Set`, which overwrites. |
| 515 | |
| 516 | `Fetch` adds `CARLOS_BOX_REFERENCE=group` (S3, read in Task 6): `write` runs only in a group box's start-up, so a box whose host.env it wrote is a group box, and takes its group reference. `Seed` never writes that line as a parameter, and `Assemble` ignores a parameter of that name. |
| 517 | |
| 518 | **The ruling's conditions on `runHostEnv` (census ruling region-ha-2b).** |
| 519 | 1. No box writes the parameters it reads: the box role has no `ssm:PutParameter` or `ssm:DeleteParameter` on either path; only the operator's (and the console's) principal writes. That is 2c's to grant and to prove with a negative check (2c's seam 3). |
| 520 | 2. `write` takes only allowlisted names (`Required` and `Optional`; `Allowed`): any other name, under either scope, is refused by name, and `seed` refuses a file that carries one. Values are restricted to the safe set, so a newline, a carriage return, a NUL, whitespace, quotes or a `$` (anything that could start a second key or change how systemd reads the line) is refused, naming the variable and never the value. The path is fixed at `/etc/carlos/host.env` (U3): `write` has no `--out`, and tests reach the code through the unexported `hostEnvRoot`, never a flag. `writeHostEnv` holds `/etc` and then `/etc/carlos` by descriptor, each opened `O_NOFOLLOW|O_DIRECTORY` under the one before (`checkedDir`), so a symlink at either is refused and neither is looked up by name again; each must be a directory, owned by root (`write` already refuses any other user), and writable by no group or other, or `write` refuses it (RHB-C67). A missing `/etc/carlos` is created 0755, then opened and checked like the rest. The file is created 0600, `O_EXCL|O_NOFOLLOW`, under a random name inside the held directory (`openat`), written and fsynced, renamed over `host.env` within the same directory, and the directory fsynced: a reader sees the old file or the new one, never part of either, and no other user can swap the file or the directory under it. The descriptor calls are Linux's (`//go:build linux`); elsewhere `writeHostEnv` refuses (`write_other.go`, RHB-C68), so darwin and windows build. |
| 521 | 3. The values that select authority, the group marker and `CARLOS_REGION`, are cross-checked against the signed roster at the agent's use, in mode selection (Task 6), not here: at boot there is no roster entry for the box to check against, and before enrolment neither value grants anything. Keys that would select authority with no roster to check them by are kept off the allowlist (fleet and tunnel identity, address overrides, pools, directories, relay settings, tokens), and so are the alert webhook and the status destination overrides ("Global Constraints", host.env, says why). |
| 522 | |
| 523 | - [ ] **Step 1: Write the failing tests** |
| 524 | |
| 525 | `internal/hostenv/hostenv_test.go`: |
| 526 | |
| 527 | ````go |
| 528 | package hostenv |
| 529 | |
| 530 | import ( |
| 531 | "bytes" |
| 532 | "context" |
| 533 | "errors" |
| 534 | "os" |
| 535 | "path/filepath" |
| 536 | "reflect" |
| 537 | "runtime" |
| 538 | "slices" |
| 539 | "strings" |
| 540 | "testing" |
| 541 | ) |
| 542 | |
| 543 | // fakeParams is a parameter store in memory. failPrefix makes ByPath fail |
| 544 | // for one prefix, as an unreachable or refused SSM call would. staleOnce |
| 545 | // makes the next ByPath of that prefix answer as it stood before every |
| 546 | // value now under it was written: a seed that read just before another |
| 547 | // seed wrote. |
| 548 | type fakeParams struct { |
| 549 | vals map[string]string |
| 550 | sets []string |
| 551 | failPrefix string |
| 552 | staleOnce string |
| 553 | } |
| 554 | |
| 555 | func (f *fakeParams) ByPath(_ context.Context, prefix string) (map[string]string, error) { |
| 556 | if prefix == f.failPrefix { |
| 557 | return nil, errors.New("AccessDeniedException") |
| 558 | } |
| 559 | if prefix == f.staleOnce { |
| 560 | f.staleOnce = "" |
| 561 | return map[string]string{}, nil |
| 562 | } |
| 563 | out := map[string]string{} |
| 564 | for name, v := range f.vals { |
| 565 | if rest, ok := strings.CutPrefix(name, prefix); ok && !strings.Contains(rest, "/") { |
| 566 | out[rest] = v |
| 567 | } |
| 568 | } |
| 569 | return out, nil |
| 570 | } |
| 571 | |
| 572 | func (f *fakeParams) Set(_ context.Context, name, value string) error { |
| 573 | f.vals[name] = value |
| 574 | f.sets = append(f.sets, name) |
| 575 | return nil |
| 576 | } |
| 577 | |
| 578 | func (f *fakeParams) SetNew(_ context.Context, name, value string) error { |
| 579 | if _, ok := f.vals[name]; ok { |
| 580 | return ErrExists |
| 581 | } |
| 582 | f.vals[name] = value |
| 583 | f.sets = append(f.sets, name) |
| 584 | return nil |
| 585 | } |
| 586 | |
| 587 | // today is a hosting box's host.env as an operator has it today. |
| 588 | func today() map[string]string { |
| 589 | return map[string]string{ |
| 590 | "CARLOS_ALERT_SNS_TOPIC_ARN": "arn:aws:sns:eu-west-1:123456789012:carlos-alerts", |
| 591 | "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket", |
| 592 | "CARLOS_HIBERNATE_BUCKET": "data-ie", |
| 593 | "CARLOS_HIBERNATE_REGION": "eu-west-1", |
| 594 | "CARLOS_HOST_CLAIM": "1", |
| 595 | "CARLOS_HOST_CLAIM_ORIGINS": "1", |
| 596 | "CARLOS_REGION": "ie", |
| 597 | "CARLOS_REGION_HA": "1", |
| 598 | "CARLOS_RELEASE_PUBKEY": "q3J0bXlwdWJsaWNrZXk+/=", |
| 599 | "CARLOS_SECRETS_KEY": "c2VjcmV0c2tleQ==", |
| 600 | "CARLOS_SYSTEM_CHANNEL": "stable", |
| 601 | "CARLOS_HIBERNATE_PREFIX": "fleet", |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | func TestSeedThenFetchIsTodaysFile(t *testing.T) { |
| 606 | ctx := context.Background() |
| 607 | p := &fakeParams{vals: map[string]string{}} |
| 608 | s, err := Seed(ctx, p, "flagship", "ie", today()) |
| 609 | if err != nil { |
| 610 | t.Fatal(err) |
| 611 | } |
| 612 | if s.Shared != len(SharedKeys) || s.Regional != len(today())-len(SharedKeys) || s.SharedPrefix != "/carlos/flagship/shared/" || s.RegionPrefix != "/carlos/flagship/ie/" { |
| 613 | t.Fatalf("seeded %+v", s) |
| 614 | } |
| 615 | if p.vals["/carlos/flagship/shared/CARLOS_RELEASE_PUBKEY"] != today()["CARLOS_RELEASE_PUBKEY"] || p.vals["/carlos/flagship/ie/CARLOS_REGION"] != "ie" { |
| 616 | t.Fatalf("layout %v", p.vals) |
| 617 | } |
| 618 | // Another region's parameter, and a non-variable under shared, are not |
| 619 | // this region's host.env. |
| 620 | p.vals["/carlos/flagship/uk/CARLOS_REGION"] = "uk" |
| 621 | p.vals["/carlos/flagship/shared/bootstrap-sha256"] = strings.Repeat("a", 64) |
| 622 | body, err := Fetch(ctx, p, "flagship", "ie") |
| 623 | if err != nil { |
| 624 | t.Fatal(err) |
| 625 | } |
| 626 | got, err := Parse(body) |
| 627 | if err != nil { |
| 628 | t.Fatal(err) |
| 629 | } |
| 630 | // A box write assembles host.env for is one its group launched: it |
| 631 | // takes its group reference (BoxReference), which is never a parameter. |
| 632 | want := today() |
| 633 | want[BoxReference] = GroupReference |
| 634 | if !reflect.DeepEqual(got, want) { |
| 635 | t.Fatalf("round trip:\n got %v\nwant %v", got, want) |
| 636 | } |
| 637 | if !bytes.HasPrefix(body, []byte("# ")) || !bytes.Contains(body, []byte("\nCARLOS_ALERT_SNS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:carlos-alerts\nCARLOS_BOX_REFERENCE=group\nCARLOS_DEPLOYMENT_BUCKET=deploy-bucket\n")) { |
| 638 | t.Fatalf("not sorted KEY=value lines:\n%s", body) |
| 639 | } |
| 640 | } |
| 641 | |
| 642 | func TestFetchRefusesAMissingRequiredValue(t *testing.T) { |
| 643 | ctx := context.Background() |
| 644 | for _, k := range Required { |
| 645 | p := &fakeParams{vals: map[string]string{}} |
| 646 | env := today() |
| 647 | if _, err := Seed(ctx, p, "flagship", "ie", env); err != nil { |
| 648 | t.Fatal(err) |
| 649 | } |
| 650 | for name := range p.vals { |
| 651 | if strings.HasSuffix(name, "/"+k) { |
| 652 | delete(p.vals, name) |
| 653 | } |
| 654 | } |
| 655 | _, err := Fetch(ctx, p, "flagship", "ie") |
| 656 | if !errors.Is(err, ErrMissing) || !strings.Contains(err.Error(), k) { |
| 657 | t.Fatalf("without %s: %v", k, err) |
| 658 | } |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | func TestFetchRefusesAFailedReadAndAConflict(t *testing.T) { |
| 663 | ctx := context.Background() |
| 664 | p := &fakeParams{vals: map[string]string{}} |
| 665 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 666 | t.Fatal(err) |
| 667 | } |
| 668 | // A refused read is never an empty scope: nothing is assembled. |
| 669 | p.failPrefix = "/carlos/flagship/shared/" |
| 670 | if _, err := Fetch(ctx, p, "flagship", "ie"); err == nil || errors.Is(err, ErrMissing) { |
| 671 | t.Fatalf("failed read: %v", err) |
| 672 | } |
| 673 | p.failPrefix = "" |
| 674 | // A variable in both scopes is one of them wrong. |
| 675 | p.vals["/carlos/flagship/ie/CARLOS_RELEASE_PUBKEY"] = "other" |
| 676 | if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_RELEASE_PUBKEY") { |
| 677 | t.Fatalf("conflict: %v", err) |
| 678 | } |
| 679 | } |
| 680 | |
| 681 | func TestRenderRefusesAnUnsafeValueWithoutPrintingIt(t *testing.T) { |
| 682 | env := today() |
| 683 | env["CARLOS_SECRETS_KEY"] = "abc$(reboot)" |
| 684 | _, err := Render(env) |
| 685 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "reboot") { |
| 686 | t.Fatalf("unsafe value: %v", err) |
| 687 | } |
| 688 | for _, v := range []string{"a b", "a'b", `a"b`, "a\\b", "a\nb", "a#b", "a;b"} { |
| 689 | if _, err := Render(map[string]string{"CARLOS_X": v}); !errors.Is(err, ErrValue) { |
| 690 | t.Fatalf("%q rendered: %v", v, err) |
| 691 | } |
| 692 | } |
| 693 | } |
| 694 | |
| 695 | func TestSeedNeverChangesASharedValue(t *testing.T) { |
| 696 | ctx := context.Background() |
| 697 | p := &fakeParams{vals: map[string]string{}} |
| 698 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 699 | t.Fatal(err) |
| 700 | } |
| 701 | // A second region with the same shared values writes only its own. |
| 702 | uk := today() |
| 703 | uk["CARLOS_REGION"], uk["CARLOS_HIBERNATE_BUCKET"] = "uk", "data-uk" |
| 704 | p.sets = nil |
| 705 | s, err := Seed(ctx, p, "flagship", "uk", uk) |
| 706 | if err != nil { |
| 707 | t.Fatal(err) |
| 708 | } |
| 709 | if s.Shared != 0 { |
| 710 | t.Fatalf("rewrote shared values: %v", p.sets) |
| 711 | } |
| 712 | // A third region whose file disagrees on a shared value writes nothing. |
| 713 | au := today() |
| 714 | au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI=" |
| 715 | p.sets = nil |
| 716 | if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 { |
| 717 | t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets) |
| 718 | } |
| 719 | // A group box's own host.env seeds no BoxReference. |
| 720 | grp := today() |
| 721 | grp[BoxReference] = GroupReference |
| 722 | p.sets = nil |
| 723 | if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) { |
| 724 | t.Fatalf("seeded %v: %v", p.sets, err) |
| 725 | } |
| 726 | // An incomplete file writes nothing either. |
| 727 | partial := today() |
| 728 | delete(partial, "CARLOS_REGION_HA") |
| 729 | p.sets = nil |
| 730 | if _, err := Seed(ctx, p, "flagship", "au", partial); !errors.Is(err, ErrMissing) || len(p.sets) != 0 { |
| 731 | t.Fatalf("incomplete file: %v, wrote %v", err, p.sets) |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | // R4: two seeds racing for a shared value. The second read before the |
| 736 | // first wrote, so both try to create it; the second's SetNew is refused, |
| 737 | // it reads the value back, and it goes on only if the value is its own. |
| 738 | func TestSeedRacesForASharedValueAndOnlyOneWins(t *testing.T) { |
| 739 | ctx := context.Background() |
| 740 | p := &fakeParams{vals: map[string]string{}} |
| 741 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 742 | t.Fatal(err) |
| 743 | } |
| 744 | // The same shared values: nothing shared is written, the region's are. |
| 745 | uk := today() |
| 746 | uk["CARLOS_REGION"] = "uk" |
| 747 | p.staleOnce, p.sets = "/carlos/flagship/shared/", nil |
| 748 | s, err := Seed(ctx, p, "flagship", "uk", uk) |
| 749 | if err != nil || s.Shared != 0 || s.Regional != len(today())-len(SharedKeys) { |
| 750 | t.Fatalf("same values: %+v %v, wrote %v", s, err, p.sets) |
| 751 | } |
| 752 | // A different secrets key: refused, naming the variable and never |
| 753 | // either value, and the first seed's value stands. |
| 754 | au := today() |
| 755 | au["CARLOS_REGION"], au["CARLOS_SECRETS_KEY"] = "au", "b3RoZXI=" |
| 756 | p.staleOnce, p.sets = "/carlos/flagship/shared/", nil |
| 757 | _, err = Seed(ctx, p, "flagship", "au", au) |
| 758 | if !errors.Is(err, ErrConflict) || !strings.Contains(err.Error(), "CARLOS_SECRETS_KEY") || strings.Contains(err.Error(), "b3RoZXI=") || strings.Contains(err.Error(), today()["CARLOS_SECRETS_KEY"]) { |
| 759 | t.Fatalf("racing seed: %v", err) |
| 760 | } |
| 761 | if p.vals["/carlos/flagship/shared/CARLOS_SECRETS_KEY"] != today()["CARLOS_SECRETS_KEY"] || len(p.sets) != 0 { |
| 762 | t.Fatalf("the second seed changed or wrote %v", p.sets) |
| 763 | } |
| 764 | } |
| 765 | |
| 766 | func TestPrefixAndParse(t *testing.T) { |
| 767 | if _, err := Prefix("flagship", "../x"); !errors.Is(err, ErrValue) { |
| 768 | t.Fatalf("bad scope: %v", err) |
| 769 | } |
| 770 | if _, err := Fetch(context.Background(), &fakeParams{}, "flagship", SharedScope); !errors.Is(err, ErrValue) { |
| 771 | t.Fatalf("shared as a region: %v", err) |
| 772 | } |
| 773 | got, err := Parse([]byte("# comment\n\nexport CARLOS_REGION=\"ie\"\nCARLOS_HOST_CLAIM='1'\n")) |
| 774 | if err != nil || !reflect.DeepEqual(got, map[string]string{"CARLOS_REGION": "ie", "CARLOS_HOST_CLAIM": "1"}) { |
| 775 | t.Fatalf("parse: %v %v", got, err) |
| 776 | } |
| 777 | for _, body := range []string{"CARLOS_REGION=ie\nCARLOS_REGION=uk\n", "not an assignment\n", "lower=x\n"} { |
| 778 | if _, err := Parse([]byte(body)); !errors.Is(err, ErrValue) { |
| 779 | t.Fatalf("%q parsed: %v", body, err) |
| 780 | } |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | // The command: write refuses without root and leaves no file on a failed |
| 785 | // assembly; with root it writes 0600; seed reads the named file. |
| 786 | func TestHostEnvCommand(t *testing.T) { |
| 787 | ctx := context.Background() |
| 788 | dir := withHostEnvRoot(t) |
| 789 | out := filepath.Join(dir, "etc", "carlos", "host.env") |
| 790 | p := &fakeParams{vals: map[string]string{}} |
| 791 | open := func(context.Context, string, string) (Params, error) { return p, nil } |
| 792 | var stdout bytes.Buffer |
| 793 | args := []string{"write", "--deployment", "flagship", "--region", "ie"} |
| 794 | if err := runHostEnv(ctx, args, &stdout, 1000, open); err == nil || !strings.Contains(err.Error(), "root") { |
| 795 | t.Fatalf("non-root write: %v", err) |
| 796 | } |
| 797 | if err := runHostEnv(ctx, args, &stdout, 0, open); !errors.Is(err, ErrMissing) { |
| 798 | t.Fatalf("write with no parameters: %v", err) |
| 799 | } |
| 800 | if _, err := os.Stat(out); !os.IsNotExist(err) { |
| 801 | t.Fatalf("a failed write left a file: %v", err) |
| 802 | } |
| 803 | from := filepath.Join(dir, "today.env") |
| 804 | body, _ := Render(today()) |
| 805 | if err := os.WriteFile(from, body, 0o600); err != nil { |
| 806 | t.Fatal(err) |
| 807 | } |
| 808 | seed := []string{"seed", "--deployment", "flagship", "--region", "ie", "--kms-key", "alias/carlos-flagship-config", "--from", from} |
| 809 | if err := runHostEnv(ctx, seed, &stdout, 1000, open); err != nil { |
| 810 | t.Fatal(err) |
| 811 | } |
| 812 | if !strings.Contains(stdout.String(), "wrote 7 parameters under /carlos/flagship/ie/ and 5 under /carlos/flagship/shared/\n") { |
| 813 | t.Fatalf("output %q", stdout.String()) |
| 814 | } |
| 815 | err := runHostEnv(ctx, args, &stdout, 0, open) |
| 816 | if runtime.GOOS != "linux" { |
| 817 | if err == nil || !strings.Contains(err.Error(), "runs only on Linux") { |
| 818 | t.Fatalf("write off Linux: %v", err) |
| 819 | } |
| 820 | } else { |
| 821 | if err != nil { |
| 822 | t.Fatal(err) |
| 823 | } |
| 824 | fi, err := os.Stat(out) |
| 825 | if err != nil || fi.Mode().Perm() != 0o600 { |
| 826 | t.Fatalf("host.env: %v %v", fi, err) |
| 827 | } |
| 828 | if !strings.Contains(stdout.String(), "wrote "+out+" from the parameters for ie\n") { |
| 829 | t.Fatalf("output %q", stdout.String()) |
| 830 | } |
| 831 | } |
| 832 | // The path is fixed: there is no flag to move it. |
| 833 | for _, bad := range [][]string{nil, {"seed", "--deployment", "flagship", "--region", "ie"}, {"write", "--region", "ie"}, {"read"}, append(args, "--out", filepath.Join(dir, "elsewhere"))} { |
| 834 | if err := runHostEnv(ctx, bad, &stdout, 0, open); err == nil || !strings.HasPrefix(err.Error(), "usage: carlos system host-env") { |
| 835 | t.Fatalf("%v: %v", bad, err) |
| 836 | } |
| 837 | } |
| 838 | } |
| 839 | |
| 840 | // runHostEnv's census conditions (platform-1e, region-ha-2b): write takes |
| 841 | // only allowlisted names, refuses values that could start a second line, |
| 842 | // end the file or change how systemd reads it, and writes through no |
| 843 | // symlinked directory. A refusal names the variable, never the value. |
| 844 | func TestWriteTakesOnlyAllowedNamesAndSafeValues(t *testing.T) { |
| 845 | ctx := context.Background() |
| 846 | p := &fakeParams{vals: map[string]string{}} |
| 847 | if _, err := Seed(ctx, p, "flagship", "ie", today()); err != nil { |
| 848 | t.Fatal(err) |
| 849 | } |
| 850 | // A name off the allowlist, under either scope, is refused by name. |
| 851 | for _, name := range []string{"/carlos/flagship/ie/CARLOS_PRIVATE_IP", "/carlos/flagship/shared/CARLOS_FLEET"} { |
| 852 | p.vals[name] = "10.0.0.9" |
| 853 | _, err := Fetch(ctx, p, "flagship", "ie") |
| 854 | short := name[strings.LastIndex(name, "/")+1:] |
| 855 | if !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), short) || strings.Contains(err.Error(), "10.0.0.9") { |
| 856 | t.Fatalf("%s: %v", name, err) |
| 857 | } |
| 858 | delete(p.vals, name) |
| 859 | } |
| 860 | // Seeding a file with such a name writes nothing. |
| 861 | odd := today() |
| 862 | odd["CARLOS_POOLS"] = "gold" |
| 863 | p.sets = nil |
| 864 | if _, err := Seed(ctx, p, "flagship", "uk", odd); !errors.Is(err, ErrValue) || !strings.Contains(err.Error(), "CARLOS_POOLS") || len(p.sets) != 0 { |
| 865 | t.Fatalf("seed with an unknown name: %v, wrote %v", err, p.sets) |
| 866 | } |
| 867 | // Values that would break a line, end the file early or change how |
| 868 | // systemd reads it. |
| 869 | for _, v := range []string{"a\nCARLOS_REGION=uk", "a\x00b", "a\rb", "$(id)", "a b", "\"quoted\""} { |
| 870 | p.vals["/carlos/flagship/ie/CARLOS_HIBERNATE_PREFIX"] = v |
| 871 | if _, err := Fetch(ctx, p, "flagship", "ie"); !errors.Is(err, ErrValue) || strings.Contains(err.Error(), v) { |
| 872 | t.Fatalf("%q: %v", v, err) |
| 873 | } |
| 874 | } |
| 875 | } |
| 876 | |
| 877 | // withHostEnvRoot points write's fixed path at a directory of the test's |
| 878 | // own, with an etc in it as a box has one. |
| 879 | func withHostEnvRoot(t *testing.T) string { |
| 880 | t.Helper() |
| 881 | root := t.TempDir() |
| 882 | if err := os.Mkdir(filepath.Join(root, "etc"), 0o755); err != nil { |
| 883 | t.Fatal(err) |
| 884 | } |
| 885 | if err := os.Chmod(filepath.Join(root, "etc"), 0o755); err != nil { |
| 886 | t.Fatal(err) |
| 887 | } |
| 888 | was := hostEnvRoot |
| 889 | hostEnvRoot = root |
| 890 | t.Cleanup(func() { hostEnvRoot = was }) |
| 891 | return root |
| 892 | } |
| 893 | ```` |
| 894 | |
| 895 | The checked write's own tests, Linux's and the refusal elsewhere: |
| 896 | |
| 897 | `internal/hostenv/write_linux_test.go`: |
| 898 | |
| 899 | ````go |
| 900 | //go:build linux |
| 901 | |
| 902 | package hostenv |
| 903 | |
| 904 | import ( |
| 905 | "os" |
| 906 | "path/filepath" |
| 907 | "strings" |
| 908 | "testing" |
| 909 | ) |
| 910 | |
| 911 | // U3 (census condition 2 on runHostEnv): write's path is fixed, and the |
| 912 | // file is written only through etc and etc/carlos held by descriptor, |
| 913 | // each a real directory this user owns and nobody else can write. |
| 914 | func TestWriteGoesOnlyThroughDirectoriesRootAloneCanWrite(t *testing.T) { |
| 915 | read := func(root string) string { |
| 916 | b, _ := os.ReadFile(filepath.Join(root, "etc", "carlos", "host.env")) |
| 917 | return string(b) |
| 918 | } |
| 919 | t.Run("lands whole, and replaces whole", func(t *testing.T) { |
| 920 | root := withHostEnvRoot(t) |
| 921 | if err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")); err != nil { |
| 922 | t.Fatal(err) |
| 923 | } |
| 924 | fi, err := os.Stat(filepath.Join(root, "etc", "carlos")) |
| 925 | if err != nil || fi.Mode().Perm()&0o022 != 0 || !fi.IsDir() { |
| 926 | t.Fatalf("created directory %v %v", fi, err) |
| 927 | } |
| 928 | first, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env")) |
| 929 | if err != nil { |
| 930 | t.Fatal(err) |
| 931 | } |
| 932 | if err := writeHostEnv(root, []byte("CARLOS_REGION=uk\n")); err != nil { |
| 933 | t.Fatal(err) |
| 934 | } |
| 935 | f, err := os.Stat(filepath.Join(root, "etc", "carlos", "host.env")) |
| 936 | if err != nil || f.Mode().Perm() != 0o600 || read(root) != "CARLOS_REGION=uk\n" { |
| 937 | t.Fatalf("host.env %v %v %q", f, err, read(root)) |
| 938 | } |
| 939 | // Replaced by rename, not rewritten in place: a reader holding the |
| 940 | // old file never sees part of the new one. |
| 941 | if os.SameFile(first, f) { |
| 942 | t.Fatal("host.env was rewritten in place") |
| 943 | } |
| 944 | ents, _ := os.ReadDir(filepath.Join(root, "etc", "carlos")) |
| 945 | if len(ents) != 1 { |
| 946 | t.Fatalf("left behind: %v", ents) |
| 947 | } |
| 948 | }) |
| 949 | for name, spoil := range map[string]func(t *testing.T, root string){ |
| 950 | "a group-writable etc/carlos": func(t *testing.T, root string) { |
| 951 | dir := filepath.Join(root, "etc", "carlos") |
| 952 | if err := os.Mkdir(dir, 0o755); err != nil { |
| 953 | t.Fatal(err) |
| 954 | } |
| 955 | if err := os.Chmod(dir, 0o775); err != nil { |
| 956 | t.Fatal(err) |
| 957 | } |
| 958 | }, |
| 959 | "a world-writable etc": func(t *testing.T, root string) { |
| 960 | if err := os.Chmod(filepath.Join(root, "etc"), 0o757); err != nil { |
| 961 | t.Fatal(err) |
| 962 | } |
| 963 | }, |
| 964 | "an etc/carlos another user owns": func(t *testing.T, root string) { |
| 965 | if err := os.Mkdir(filepath.Join(root, "etc", "carlos"), 0o755); err != nil { |
| 966 | t.Fatal(err) |
| 967 | } |
| 968 | was := hostEnvOwner |
| 969 | hostEnvOwner = func() int { return was() + 1 } |
| 970 | t.Cleanup(func() { hostEnvOwner = was }) |
| 971 | }, |
| 972 | "a symlinked etc/carlos": func(t *testing.T, root string) { |
| 973 | elsewhere := safeDir(t) |
| 974 | if err := os.Symlink(elsewhere, filepath.Join(root, "etc", "carlos")); err != nil { |
| 975 | t.Fatal(err) |
| 976 | } |
| 977 | }, |
| 978 | "a symlinked etc, an ancestor": func(t *testing.T, root string) { |
| 979 | elsewhere := safeDir(t) |
| 980 | if err := os.Mkdir(filepath.Join(elsewhere, "carlos"), 0o755); err != nil { |
| 981 | t.Fatal(err) |
| 982 | } |
| 983 | if err := os.Remove(filepath.Join(root, "etc")); err != nil { |
| 984 | t.Fatal(err) |
| 985 | } |
| 986 | if err := os.Symlink(elsewhere, filepath.Join(root, "etc")); err != nil { |
| 987 | t.Fatal(err) |
| 988 | } |
| 989 | }, |
| 990 | } { |
| 991 | t.Run(name, func(t *testing.T) { |
| 992 | root := withHostEnvRoot(t) |
| 993 | spoil(t, root) |
| 994 | err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")) |
| 995 | if err == nil || !strings.Contains(err.Error(), "is not a directory only root can write") { |
| 996 | t.Fatalf("%v", err) |
| 997 | } |
| 998 | if got := read(root); got != "" { |
| 999 | t.Fatalf("wrote %q", got) |
| 1000 | } |
| 1001 | }) |
| 1002 | } |
| 1003 | } |
| 1004 | |
| 1005 | // safeDir is a directory that passes every check but the one under test: |
| 1006 | // this user's, 0755 whatever the umask, so a symlink to it is refused only |
| 1007 | // for being a symlink. |
| 1008 | func safeDir(t *testing.T) string { |
| 1009 | t.Helper() |
| 1010 | dir := t.TempDir() |
| 1011 | if err := os.Chmod(dir, 0o755); err != nil { |
| 1012 | t.Fatal(err) |
| 1013 | } |
| 1014 | return dir |
| 1015 | } |
| 1016 | ```` |
| 1017 | |
| 1018 | `internal/hostenv/write_other_test.go`: |
| 1019 | |
| 1020 | ````go |
| 1021 | //go:build !linux |
| 1022 | |
| 1023 | package hostenv |
| 1024 | |
| 1025 | import "testing" |
| 1026 | |
| 1027 | // U3: off Linux there is no checked write, so write refuses and leaves |
| 1028 | // nothing behind. |
| 1029 | func TestWriteRefusesOffLinux(t *testing.T) { |
| 1030 | root := t.TempDir() |
| 1031 | err := writeHostEnv(root, []byte("CARLOS_REGION=ie\n")) |
| 1032 | if err == nil || err.Error() != "carlos system host-env write runs only on Linux" { |
| 1033 | t.Fatalf("%v", err) |
| 1034 | } |
| 1035 | } |
| 1036 | ```` |
| 1037 | |
| 1038 | - [ ] **Step 2: Run them to see them fail** |
| 1039 | |
| 1040 | Run: `nice -n 19 go test ./internal/hostenv/ -count=1` |
| 1041 | Expected: FAIL, the package does not build (`undefined: Seed`, `undefined: Params`, ...). |
| 1042 | |
| 1043 | - [ ] **Step 3: Add the SSM SDK** |
| 1044 | |
| 1045 | The module's AWS core is v1.44.0; ssm v1.74.0 is the release built against it, so nothing else moves. Its sums match sum.golang.org. |
| 1046 | |
| 1047 | `go.mod`: |
| 1048 | |
| 1049 | ````diff |
| 1050 | --- a/go.mod |
| 1051 | +++ b/go.mod |
| 1052 | @@ -15,6 +15,7 @@ |
| 1053 | github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 |
| 1054 | github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 |
| 1055 | github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6 |
| 1056 | + github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0 |
| 1057 | github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 |
| 1058 | github.com/aws/smithy-go v1.28.1 |
| 1059 | github.com/benbjohnson/litestream v0.5.14 |
| 1060 | ```` |
| 1061 | |
| 1062 | `go.sum`: |
| 1063 | |
| 1064 | ````diff |
| 1065 | --- a/go.sum |
| 1066 | +++ b/go.sum |
| 1067 | @@ -68,6 +68,8 @@ |
| 1068 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= |
| 1069 | github.com/aws/aws-sdk-go-v2/service/sns v1.43.0 h1:VPYjwn0BoX34hb44OT8T+Ikgn4NzsN7fHetaHaevsDc= |
| 1070 | github.com/aws/aws-sdk-go-v2/service/sns v1.43.0/go.mod h1:I1vnLPvvi9KBqxddu8nJ4vktoPJvaIG05UmjBD9sqm8= |
| 1071 | +github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0 h1:11o/Z2uQatZTTWrkwN4MMPjhcVGnVQtLra5Md1tSSq0= |
| 1072 | +github.com/aws/aws-sdk-go-v2/service/ssm v1.74.0/go.mod h1:uF4ifVWSlieuvF0ZSy9m6flJJojvWuEu80pJIO3UJ0U= |
| 1073 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= |
| 1074 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= |
| 1075 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= |
| 1076 | ```` |
| 1077 | |
| 1078 | - [ ] **Step 4: Write the package** |
| 1079 | |
| 1080 | `internal/hostenv/hostenv.go`: |
| 1081 | |
| 1082 | ````go |
| 1083 | // Package hostenv is a box's /etc/carlos/host.env as a parameter layout |
| 1084 | // (region HA spec 2 §5 step 2, §4). Every value a hosting box reads from |
| 1085 | // host.env is one parameter, named for its variable, under |
| 1086 | // /carlos/<deployment>/<region>/, or under /carlos/<deployment>/shared/ |
| 1087 | // for the values every region of the deployment holds alike. A box's |
| 1088 | // start-up assembles host.env from the two; an operator seeds them from a |
| 1089 | // box's existing file. |
| 1090 | // |
| 1091 | // Where the parameters live is an adapter's business (awsssm.go, AWS SSM |
| 1092 | // SecureStrings). This file names no provider (§25): host.env is a file, |
| 1093 | // and Params is all the core asks of a parameter store. |
| 1094 | package hostenv |
| 1095 | |
| 1096 | import ( |
| 1097 | "bufio" |
| 1098 | "bytes" |
| 1099 | "context" |
| 1100 | "errors" |
| 1101 | "fmt" |
| 1102 | "regexp" |
| 1103 | "slices" |
| 1104 | "sort" |
| 1105 | "strings" |
| 1106 | ) |
| 1107 | |
| 1108 | // Path is the file a box's units read their environment from. |
| 1109 | const Path = "/etc/carlos/host.env" |
| 1110 | |
| 1111 | // SharedScope is the scope of the values every region holds alike. |
| 1112 | const SharedScope = "shared" |
| 1113 | |
| 1114 | // Required are the variables a hosting box cannot run without (§5 step 2): |
| 1115 | // the buckets and their region, the release and secrets keys, the box's |
| 1116 | // region and channel, the alert topic, and the claim and region HA |
| 1117 | // switches. Assembling host.env refuses without any of them. |
| 1118 | var Required = []string{ |
| 1119 | "CARLOS_ALERT_SNS_TOPIC_ARN", |
| 1120 | "CARLOS_DEPLOYMENT_BUCKET", |
| 1121 | "CARLOS_HIBERNATE_BUCKET", |
| 1122 | "CARLOS_HIBERNATE_REGION", |
| 1123 | "CARLOS_HOST_CLAIM", |
| 1124 | "CARLOS_HOST_CLAIM_ORIGINS", |
| 1125 | "CARLOS_REGION", |
| 1126 | "CARLOS_REGION_HA", |
| 1127 | "CARLOS_RELEASE_PUBKEY", |
| 1128 | "CARLOS_SECRETS_KEY", |
| 1129 | "CARLOS_SYSTEM_CHANNEL", |
| 1130 | } |
| 1131 | |
| 1132 | // Optional are the other variables a box its region's group launched may |
| 1133 | // carry, from the agent's config surface (README's host.env list and the |
| 1134 | // variables the agent reads). Together with Required they are the |
| 1135 | // allowlist: any other name, under either scope, is refused by name, |
| 1136 | // never by value, by write (Assemble) and by seed. The list leaves out |
| 1137 | // the keys that select a box's authority (a fleet's or tunnel's identity, |
| 1138 | // an address override, a pool, the data, opt and deployment directories, |
| 1139 | // the relay's settings) and every token or API credential, except the |
| 1140 | // deployment's own required secrets, CARLOS_SECRETS_KEY and |
| 1141 | // CARLOS_RELEASE_PUBKEY. CARLOS_ALERT_WEBHOOK is left out on the same |
| 1142 | // rule: a webhook URL can carry a credential. So are the status |
| 1143 | // destination overrides, CARLOS_STATUS_BUCKET, CARLOS_STATUS_PREFIX and |
| 1144 | // CARLOS_STATUS_REGION: a group box reports to the deployment bucket, |
| 1145 | // unprefixed, where the console reads its guard-ready report, while it is |
| 1146 | // joining and once it is hosting. An endpoint override such as |
| 1147 | // CARLOS_S3_ENDPOINT stays, because it selects no box authority and only |
| 1148 | // operators can write the parameter paths. CARLOS_REGION and the group |
| 1149 | // marker, which do select, are cross-checked against the signed roster |
| 1150 | // where the agent uses them (internal/host's mode selection). Adding a |
| 1151 | // variable is a code change, reviewed. |
| 1152 | var Optional = []string{ |
| 1153 | "CARLOS_ACME_DELEGATION_ZONE", |
| 1154 | "CARLOS_ACME_DIRECTORY", |
| 1155 | "CARLOS_ALERT_CPU_PCT", |
| 1156 | "CARLOS_ALERT_DISK_PCT", |
| 1157 | "CARLOS_ALERT_MEM_PCT", |
| 1158 | "CARLOS_AWS_REGION", |
| 1159 | "CARLOS_BACKHAUL_MAX_INFLIGHT", |
| 1160 | "CARLOS_DNS_PROVIDER", |
| 1161 | "CARLOS_EDGE_GZIP", |
| 1162 | "CARLOS_HIBERNATE_IDLE", |
| 1163 | "CARLOS_HIBERNATE_PREFIX", |
| 1164 | "CARLOS_HOST_CLAIM_RETIRED_LOCATIONS", |
| 1165 | "CARLOS_HOST_METRICS", |
| 1166 | "CARLOS_LAYOUT_DEFAULT", |
| 1167 | "CARLOS_MOVES_PHASE1", |
| 1168 | "CARLOS_MOVE_READY_DELTA_BYTES", |
| 1169 | "CARLOS_REQUIRE_POINTER_DIGEST", |
| 1170 | "CARLOS_S3_ENDPOINT", |
| 1171 | "CARLOS_S3_QUIRKS", |
| 1172 | "CARLOS_STEERING_ZONE_ID", |
| 1173 | "CARLOS_SWAP", |
| 1174 | "CARLOS_UNIT_SETTLE", |
| 1175 | "CARLOS_VERSIONS_GRACE", |
| 1176 | "CARLOS_WILDCARD_DOMAINS", |
| 1177 | "STATUS_BOX_REGION", |
| 1178 | } |
| 1179 | |
| 1180 | // Allowed reports whether name is a variable host.env may carry from the |
| 1181 | // parameters: Required or Optional. |
| 1182 | func Allowed(name string) bool { |
| 1183 | return slices.Contains(Required, name) || slices.Contains(Optional, name) |
| 1184 | } |
| 1185 | |
| 1186 | // notAllowed lists, sorted, the names in env that are not Allowed. |
| 1187 | func notAllowed(env map[string]string) []string { |
| 1188 | var out []string |
| 1189 | for k := range env { |
| 1190 | if k != BoxReference && !Allowed(k) { |
| 1191 | out = append(out, k) |
| 1192 | } |
| 1193 | } |
| 1194 | sort.Strings(out) |
| 1195 | return out |
| 1196 | } |
| 1197 | |
| 1198 | // SharedKeys are the variables that are the same in every region of a |
| 1199 | // deployment, so they are kept once, under the shared scope. Everything |
| 1200 | // else is the region's own. |
| 1201 | var SharedKeys = []string{ |
| 1202 | "CARLOS_DEPLOYMENT_BUCKET", |
| 1203 | "CARLOS_HOST_CLAIM", |
| 1204 | "CARLOS_HOST_CLAIM_ORIGINS", |
| 1205 | "CARLOS_RELEASE_PUBKEY", |
| 1206 | "CARLOS_SECRETS_KEY", |
| 1207 | } |
| 1208 | |
| 1209 | // BoxReference and GroupReference are the one line host.env carries that |
| 1210 | // is not a parameter. write runs only in the start-up of a box its |
| 1211 | // region's group launched, so a host.env it wrote marks such a box, and |
| 1212 | // the box takes the reference its account and instance derive |
| 1213 | // (hostactors.GroupBoxReference) instead of a status label. Every other |
| 1214 | // box's host.env lacks it and keeps its label. |
| 1215 | const ( |
| 1216 | BoxReference = "CARLOS_BOX_REFERENCE" |
| 1217 | GroupReference = "group" |
| 1218 | ) |
| 1219 | |
| 1220 | // Params is a store of named, encrypted parameters. |
| 1221 | type Params interface { |
| 1222 | // ByPath returns every parameter directly under prefix, keyed by its |
| 1223 | // name after the prefix, decrypted. A read that fails or stops part |
| 1224 | // way is an error, never a partial map. |
| 1225 | ByPath(ctx context.Context, prefix string) (map[string]string, error) |
| 1226 | // Set writes one parameter, encrypted, replacing any value it had. |
| 1227 | Set(ctx context.Context, name, value string) error |
| 1228 | // SetNew writes one parameter, encrypted, only if it does not exist: |
| 1229 | // ErrExists when it does, and the value is not changed. |
| 1230 | SetNew(ctx context.Context, name, value string) error |
| 1231 | } |
| 1232 | |
| 1233 | var ( |
| 1234 | // ErrMissing is a required variable with no value. |
| 1235 | ErrMissing = errors.New("required values are missing") |
| 1236 | // ErrConflict is a variable held both by the region and shared, or a |
| 1237 | // shared value seeding would change. |
| 1238 | ErrConflict = errors.New("values conflict") |
| 1239 | // ErrValue is a name or value host.env cannot hold safely. |
| 1240 | ErrValue = errors.New("a name or value host.env cannot hold") |
| 1241 | // ErrExists is SetNew's answer for a parameter that is already set: |
| 1242 | // another seed wrote it first. |
| 1243 | ErrExists = errors.New("already set by another seed") |
| 1244 | ) |
| 1245 | |
| 1246 | var ( |
| 1247 | scopeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) |
| 1248 | keyName = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) |
| 1249 | safeValue = regexp.MustCompile(`^[A-Za-z0-9_./:,@+=%-]*$`) |
| 1250 | ) |
| 1251 | |
| 1252 | // Prefix is the parameter path of one scope: a region's placement code or |
| 1253 | // SharedScope. Both names are refused unless they are lower-case labels. |
| 1254 | func Prefix(deployment, scope string) (string, error) { |
| 1255 | if !scopeName.MatchString(deployment) || !scopeName.MatchString(scope) { |
| 1256 | return "", fmt.Errorf("%w: %q and %q must be lower-case labels", ErrValue, deployment, scope) |
| 1257 | } |
| 1258 | return "/carlos/" + deployment + "/" + scope + "/", nil |
| 1259 | } |
| 1260 | |
| 1261 | // ValidKey reports whether name is a variable host.env carries. Other |
| 1262 | // parameters under a scope (the bootstrap object's sha256, for one) are |
| 1263 | // not variables and are skipped. |
| 1264 | func ValidKey(name string) bool { return keyName.MatchString(name) } |
| 1265 | |
| 1266 | // Assemble merges the shared and the region's values. A variable in both |
| 1267 | // is a conflict, never resolved by precedence: one of the two is wrong. |
| 1268 | // Every Required variable must have a value. |
| 1269 | func Assemble(shared, regional map[string]string) (map[string]string, error) { |
| 1270 | env := map[string]string{} |
| 1271 | var both []string |
| 1272 | for _, src := range []map[string]string{shared, regional} { |
| 1273 | for k, v := range src { |
| 1274 | if !ValidKey(k) || k == BoxReference { |
| 1275 | continue |
| 1276 | } |
| 1277 | if _, dup := env[k]; dup { |
| 1278 | both = append(both, k) |
| 1279 | continue |
| 1280 | } |
| 1281 | env[k] = v |
| 1282 | } |
| 1283 | } |
| 1284 | if len(both) > 0 { |
| 1285 | sort.Strings(both) |
| 1286 | return nil, fmt.Errorf("%w: %s set both for the region and shared", ErrConflict, strings.Join(both, ", ")) |
| 1287 | } |
| 1288 | if bad := notAllowed(env); len(bad) > 0 { |
| 1289 | return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) |
| 1290 | } |
| 1291 | if missing := Missing(env); len(missing) > 0 { |
| 1292 | return nil, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", ")) |
| 1293 | } |
| 1294 | return env, nil |
| 1295 | } |
| 1296 | |
| 1297 | // Missing lists the Required variables env has no value for, sorted. |
| 1298 | func Missing(env map[string]string) []string { |
| 1299 | var out []string |
| 1300 | for _, k := range Required { |
| 1301 | if strings.TrimSpace(env[k]) == "" { |
| 1302 | out = append(out, k) |
| 1303 | } |
| 1304 | } |
| 1305 | return out |
| 1306 | } |
| 1307 | |
| 1308 | // renderedHeader is the first line of a host.env Render writes. |
| 1309 | const renderedHeader = "# Written by carlos system host-env write from the deployment's parameters. Do not edit by hand." |
| 1310 | |
| 1311 | // Render is host.env's bytes: one KEY=value line per variable, sorted. |
| 1312 | // Values are written bare, so the file means the same to systemd's |
| 1313 | // EnvironmentFile and to a shell that sources it. A value outside the safe |
| 1314 | // set (letters, digits and _ . / : , @ + = % -) is refused rather than |
| 1315 | // quoted; the error names the variables, never their values, which may be |
| 1316 | // secrets. |
| 1317 | func Render(env map[string]string) ([]byte, error) { |
| 1318 | keys := make([]string, 0, len(env)) |
| 1319 | var bad []string |
| 1320 | for k, v := range env { |
| 1321 | if !ValidKey(k) || !safeValue.MatchString(v) { |
| 1322 | bad = append(bad, k) |
| 1323 | continue |
| 1324 | } |
| 1325 | keys = append(keys, k) |
| 1326 | } |
| 1327 | if len(bad) > 0 { |
| 1328 | sort.Strings(bad) |
| 1329 | return nil, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) |
| 1330 | } |
| 1331 | sort.Strings(keys) |
| 1332 | var b bytes.Buffer |
| 1333 | b.WriteString(renderedHeader + "\n") |
| 1334 | for _, k := range keys { |
| 1335 | b.WriteString(k + "=" + env[k] + "\n") |
| 1336 | } |
| 1337 | return b.Bytes(), nil |
| 1338 | } |
| 1339 | |
| 1340 | // Parse reads an existing host.env: KEY=value lines, with blank lines, |
| 1341 | // comments and a leading "export " ignored, and one layer of matching |
| 1342 | // quotes removed from a value. A variable set twice, or a line that is |
| 1343 | // not an assignment, is refused. |
| 1344 | func Parse(body []byte) (map[string]string, error) { |
| 1345 | env := map[string]string{} |
| 1346 | sc := bufio.NewScanner(bytes.NewReader(body)) |
| 1347 | for n := 1; sc.Scan(); n++ { |
| 1348 | line := strings.TrimSpace(sc.Text()) |
| 1349 | if line == "" || strings.HasPrefix(line, "#") { |
| 1350 | continue |
| 1351 | } |
| 1352 | line = strings.TrimPrefix(line, "export ") |
| 1353 | k, v, ok := strings.Cut(line, "=") |
| 1354 | k = strings.TrimSpace(k) |
| 1355 | if !ok || !ValidKey(k) { |
| 1356 | return nil, fmt.Errorf("%w: line %d is not KEY=value", ErrValue, n) |
| 1357 | } |
| 1358 | v = strings.TrimSpace(v) |
| 1359 | if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { |
| 1360 | v = v[1 : len(v)-1] |
| 1361 | } |
| 1362 | if _, dup := env[k]; dup { |
| 1363 | return nil, fmt.Errorf("%w: %s is set twice", ErrValue, k) |
| 1364 | } |
| 1365 | env[k] = v |
| 1366 | } |
| 1367 | if err := sc.Err(); err != nil { |
| 1368 | return nil, err |
| 1369 | } |
| 1370 | return env, nil |
| 1371 | } |
| 1372 | |
| 1373 | // Split divides env into the shared variables and the region's own. |
| 1374 | func Split(env map[string]string) (shared, regional map[string]string) { |
| 1375 | shared, regional = map[string]string{}, map[string]string{} |
| 1376 | for k, v := range env { |
| 1377 | if slices.Contains(SharedKeys, k) { |
| 1378 | shared[k] = v |
| 1379 | } else { |
| 1380 | regional[k] = v |
| 1381 | } |
| 1382 | } |
| 1383 | return shared, regional |
| 1384 | } |
| 1385 | |
| 1386 | // Fetch assembles host.env for one region of a deployment from p, for a |
| 1387 | // box the region's group launched: it adds BoxReference. |
| 1388 | func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) { |
| 1389 | if region == SharedScope { |
| 1390 | return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region) |
| 1391 | } |
| 1392 | sharedPrefix, err := Prefix(deployment, SharedScope) |
| 1393 | if err != nil { |
| 1394 | return nil, err |
| 1395 | } |
| 1396 | regionPrefix, err := Prefix(deployment, region) |
| 1397 | if err != nil { |
| 1398 | return nil, err |
| 1399 | } |
| 1400 | shared, err := p.ByPath(ctx, sharedPrefix) |
| 1401 | if err != nil { |
| 1402 | return nil, fmt.Errorf("parameters under %s: %w", sharedPrefix, err) |
| 1403 | } |
| 1404 | regional, err := p.ByPath(ctx, regionPrefix) |
| 1405 | if err != nil { |
| 1406 | return nil, fmt.Errorf("parameters under %s: %w", regionPrefix, err) |
| 1407 | } |
| 1408 | env, err := Assemble(shared, regional) |
| 1409 | if err != nil { |
| 1410 | return nil, err |
| 1411 | } |
| 1412 | env[BoxReference] = GroupReference |
| 1413 | return Render(env) |
| 1414 | } |
| 1415 | |
| 1416 | // Seeded is what Seed wrote: the parameters under each prefix. |
| 1417 | type Seeded struct { |
| 1418 | SharedPrefix, RegionPrefix string |
| 1419 | Shared, Regional int |
| 1420 | } |
| 1421 | |
| 1422 | // Seed writes env, a box's existing host.env, into the layout for region. |
| 1423 | // The region's variables are written over whatever is there. A shared |
| 1424 | // variable is written only when it is not yet set, and create-only |
| 1425 | // (SetNew): one region's file never changes a value every region reads. |
| 1426 | // A shared value that differs is a conflict, and nothing at all is |
| 1427 | // written. Two seeds racing for the same shared variable cannot both |
| 1428 | // win: the one SetNew refuses reads the value back and goes on only if |
| 1429 | // it is the value it would have written. env must hold every Required |
| 1430 | // variable, in a form Render accepts. BoxReference is never seeded: it |
| 1431 | // is write's. |
| 1432 | func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) { |
| 1433 | if region == SharedScope { |
| 1434 | return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region) |
| 1435 | } |
| 1436 | sharedPrefix, err := Prefix(deployment, SharedScope) |
| 1437 | if err != nil { |
| 1438 | return Seeded{}, err |
| 1439 | } |
| 1440 | regionPrefix, err := Prefix(deployment, region) |
| 1441 | if err != nil { |
| 1442 | return Seeded{}, err |
| 1443 | } |
| 1444 | if missing := Missing(env); len(missing) > 0 { |
| 1445 | return Seeded{}, fmt.Errorf("%w: %s", ErrMissing, strings.Join(missing, ", ")) |
| 1446 | } |
| 1447 | if bad := notAllowed(env); len(bad) > 0 { |
| 1448 | return Seeded{}, fmt.Errorf("%w: %s", ErrValue, strings.Join(bad, ", ")) |
| 1449 | } |
| 1450 | if _, err := Render(env); err != nil { |
| 1451 | return Seeded{}, err |
| 1452 | } |
| 1453 | shared, regional := Split(env) |
| 1454 | delete(regional, BoxReference) |
| 1455 | existing, err := p.ByPath(ctx, sharedPrefix) |
| 1456 | if err != nil { |
| 1457 | return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err) |
| 1458 | } |
| 1459 | var differ []string |
| 1460 | for k, v := range shared { |
| 1461 | if have, ok := existing[k]; ok && have != v { |
| 1462 | differ = append(differ, k) |
| 1463 | } |
| 1464 | } |
| 1465 | if len(differ) > 0 { |
| 1466 | sort.Strings(differ) |
| 1467 | return Seeded{}, fmt.Errorf("%w: the shared %s already differ", ErrConflict, strings.Join(differ, ", ")) |
| 1468 | } |
| 1469 | out := Seeded{SharedPrefix: sharedPrefix, RegionPrefix: regionPrefix} |
| 1470 | for _, k := range sortedKeys(shared) { |
| 1471 | if _, ok := existing[k]; ok { |
| 1472 | continue |
| 1473 | } |
| 1474 | err := p.SetNew(ctx, sharedPrefix+k, shared[k]) |
| 1475 | if errors.Is(err, ErrExists) { |
| 1476 | // Another seed set it since the read above: go on only if it |
| 1477 | // set what this one would have. |
| 1478 | if err := sameShared(ctx, p, sharedPrefix, k, shared[k]); err != nil { |
| 1479 | return out, err |
| 1480 | } |
| 1481 | continue |
| 1482 | } |
| 1483 | if err != nil { |
| 1484 | return out, fmt.Errorf("%s%s: %w", sharedPrefix, k, err) |
| 1485 | } |
| 1486 | out.Shared++ |
| 1487 | } |
| 1488 | for _, k := range sortedKeys(regional) { |
| 1489 | if err := p.Set(ctx, regionPrefix+k, regional[k]); err != nil { |
| 1490 | return out, fmt.Errorf("%s%s: %w", regionPrefix, k, err) |
| 1491 | } |
| 1492 | out.Regional++ |
| 1493 | } |
| 1494 | return out, nil |
| 1495 | } |
| 1496 | |
| 1497 | // sameShared reads the shared variable k back after SetNew found it set, |
| 1498 | // and refuses unless it holds want. The error names the variable, never |
| 1499 | // either value. |
| 1500 | func sameShared(ctx context.Context, p Params, sharedPrefix, k, want string) error { |
| 1501 | now, err := p.ByPath(ctx, sharedPrefix) |
| 1502 | if err != nil { |
| 1503 | return fmt.Errorf("parameters under %s: %w", sharedPrefix, err) |
| 1504 | } |
| 1505 | have, ok := now[k] |
| 1506 | if !ok { |
| 1507 | return fmt.Errorf("%s%s: %w", sharedPrefix, k, ErrExists) |
| 1508 | } |
| 1509 | if have != want { |
| 1510 | return fmt.Errorf("%w: the shared %s already differ", ErrConflict, k) |
| 1511 | } |
| 1512 | return nil |
| 1513 | } |
| 1514 | |
| 1515 | func sortedKeys(m map[string]string) []string { |
| 1516 | keys := make([]string, 0, len(m)) |
| 1517 | for k := range m { |
| 1518 | keys = append(keys, k) |
| 1519 | } |
| 1520 | sort.Strings(keys) |
| 1521 | return keys |
| 1522 | } |
| 1523 | ```` |
| 1524 | |
| 1525 | `internal/hostenv/cli.go`: |
| 1526 | |
| 1527 | ````go |
| 1528 | package hostenv |
| 1529 | |
| 1530 | import ( |
| 1531 | "context" |
| 1532 | "errors" |
| 1533 | "flag" |
| 1534 | "fmt" |
| 1535 | "io" |
| 1536 | "os" |
| 1537 | "path/filepath" |
| 1538 | ) |
| 1539 | |
| 1540 | // carlos system host-env write|seed (region HA spec 2 §5 step 2). |
| 1541 | // |
| 1542 | // - write is the start-up script's step: as root, it assembles host.env |
| 1543 | // from the deployment's parameters and writes it 0600. A missing |
| 1544 | // required value stops it, and the file is left as it was. |
| 1545 | // - seed is the operator's: it writes a box's existing host.env into |
| 1546 | // the parameter layout, once per region. |
| 1547 | |
| 1548 | const hostEnvUsage = `usage: carlos system host-env write --deployment <name> --region <code> [--aws-region <region>] |
| 1549 | carlos system host-env seed --deployment <name> --region <code> --kms-key <key> [--aws-region <region>] [--from <path>] |
| 1550 | |
| 1551 | write build host.env from the deployment's parameters (root) |
| 1552 | seed copy a box's host.env into the deployment's parameters |
| 1553 | ` |
| 1554 | |
| 1555 | // hostEnvRoot is the root write's fixed path is under: "/" on a box. |
| 1556 | // Tests point it at a directory of their own; there is no flag. |
| 1557 | var hostEnvRoot = "/" |
| 1558 | |
| 1559 | // openParams is the parameter store: the AWS adapter's (awsssm.go). |
| 1560 | // kmsKey is the key seed encrypts with; write passes "". |
| 1561 | var openParams = openSSMParams |
| 1562 | |
| 1563 | // RunSystemHostEnv is `carlos system host-env`. |
| 1564 | func RunSystemHostEnv(args []string) { |
| 1565 | if err := runHostEnv(context.Background(), args, os.Stdout, os.Geteuid(), openParams); err != nil { |
| 1566 | fmt.Fprintln(os.Stderr, err) |
| 1567 | os.Exit(1) |
| 1568 | } |
| 1569 | } |
| 1570 | |
| 1571 | func runHostEnv(ctx context.Context, args []string, out io.Writer, euid int, open func(ctx context.Context, awsRegion, kmsKey string) (Params, error)) error { |
| 1572 | if len(args) == 0 || (args[0] != "write" && args[0] != "seed") { |
| 1573 | return usageError() |
| 1574 | } |
| 1575 | verb := args[0] |
| 1576 | fs := flag.NewFlagSet("system host-env "+verb, flag.ContinueOnError) |
| 1577 | fs.SetOutput(io.Discard) |
| 1578 | deployment := fs.String("deployment", "", "") |
| 1579 | region := fs.String("region", "", "") |
| 1580 | awsRegion := fs.String("aws-region", "", "") |
| 1581 | kmsKey := fs.String("kms-key", "", "") |
| 1582 | from := fs.String("from", Path, "") |
| 1583 | if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 0 || *deployment == "" || *region == "" { |
| 1584 | return usageError() |
| 1585 | } |
| 1586 | switch verb { |
| 1587 | case "write": |
| 1588 | if euid != 0 { |
| 1589 | return errors.New("carlos system host-env write runs as root: it writes a root-owned file") |
| 1590 | } |
| 1591 | p, err := open(ctx, *awsRegion, "") |
| 1592 | if err != nil { |
| 1593 | return err |
| 1594 | } |
| 1595 | body, err := Fetch(ctx, p, *deployment, *region) |
| 1596 | if err != nil { |
| 1597 | return fmt.Errorf("host.env was not written: %w", err) |
| 1598 | } |
| 1599 | // The path is fixed, /etc/carlos/host.env, and the file is written |
| 1600 | // only through directories proved to be root's alone, then |
| 1601 | // replaced whole (writeHostEnv). |
| 1602 | if err := writeHostEnv(hostEnvRoot, body); err != nil { |
| 1603 | return fmt.Errorf("host.env was not written: %w", err) |
| 1604 | } |
| 1605 | fmt.Fprintln(out, wroteFileLine(filepath.Join(hostEnvRoot, Path), *region)) |
| 1606 | return nil |
| 1607 | default: |
| 1608 | if *kmsKey == "" { |
| 1609 | return usageError() |
| 1610 | } |
| 1611 | body, err := os.ReadFile(*from) |
| 1612 | if err != nil { |
| 1613 | return err |
| 1614 | } |
| 1615 | env, err := Parse(body) |
| 1616 | if err != nil { |
| 1617 | return fmt.Errorf("%s: %w", *from, err) |
| 1618 | } |
| 1619 | p, err := open(ctx, *awsRegion, *kmsKey) |
| 1620 | if err != nil { |
| 1621 | return err |
| 1622 | } |
| 1623 | s, err := Seed(ctx, p, *deployment, *region, env) |
| 1624 | if err != nil { |
| 1625 | return fmt.Errorf("the parameters were not all written: %w", err) |
| 1626 | } |
| 1627 | fmt.Fprintln(out, seededLine(s)) |
| 1628 | return nil |
| 1629 | } |
| 1630 | } |
| 1631 | |
| 1632 | func usageError() error { return errors.New(hostEnvUsage) } |
| 1633 | |
| 1634 | // wroteFileLine is what write says once host.env is in place. |
| 1635 | func wroteFileLine(path, region string) string { |
| 1636 | return fmt.Sprintf("wrote %s from the parameters for %s", path, region) |
| 1637 | } |
| 1638 | |
| 1639 | // seededLine is what seed says once the parameters are written. |
| 1640 | func seededLine(s Seeded) string { |
| 1641 | return fmt.Sprintf("wrote %d parameters under %s and %d under %s", s.Regional, s.RegionPrefix, s.Shared, s.SharedPrefix) |
| 1642 | } |
| 1643 | ```` |
| 1644 | |
| 1645 | `internal/hostenv/write_linux.go`: |
| 1646 | |
| 1647 | ````go |
| 1648 | //go:build linux |
| 1649 | |
| 1650 | package hostenv |
| 1651 | |
| 1652 | import ( |
| 1653 | "crypto/rand" |
| 1654 | "errors" |
| 1655 | "fmt" |
| 1656 | "os" |
| 1657 | "path/filepath" |
| 1658 | |
| 1659 | "golang.org/x/sys/unix" |
| 1660 | ) |
| 1661 | |
| 1662 | // writeHostEnv writes body as <root>/etc/carlos/host.env, 0600, owned by |
| 1663 | // the process's user (root on a box). Every directory on the way is held |
| 1664 | // by descriptor, never looked up again by name: etc and etc/carlos are |
| 1665 | // opened with O_NOFOLLOW|O_DIRECTORY, so a symlink at either is refused, |
| 1666 | // and each must be owned by this user and writable by no one else |
| 1667 | // (checkedDir), so no other user can swap the file or the directory under |
| 1668 | // it. etc/carlos is created 0755 if it is missing, then opened and |
| 1669 | // checked like the rest. The new file is written beside the old one in |
| 1670 | // that directory, synced, renamed over it, and the directory synced, so a |
| 1671 | // reader sees the old file or the new one, never part of either. |
| 1672 | func writeHostEnv(root string, body []byte) error { |
| 1673 | rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) |
| 1674 | if err != nil { |
| 1675 | return err |
| 1676 | } |
| 1677 | defer unix.Close(rootFD) |
| 1678 | etc, err := checkedDir(rootFD, filepath.Join(root, "etc"), "etc", false) |
| 1679 | if err != nil { |
| 1680 | return err |
| 1681 | } |
| 1682 | defer unix.Close(etc) |
| 1683 | dir, err := checkedDir(etc, filepath.Join(root, "etc", "carlos"), "carlos", true) |
| 1684 | if err != nil { |
| 1685 | return err |
| 1686 | } |
| 1687 | defer unix.Close(dir) |
| 1688 | tmp := ".host.env." + rand.Text() |
| 1689 | fd, err := unix.Openat(dir, tmp, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) |
| 1690 | if err != nil { |
| 1691 | return err |
| 1692 | } |
| 1693 | defer unix.Unlinkat(dir, tmp, 0) // gone once renamed |
| 1694 | f := os.NewFile(uintptr(fd), tmp) |
| 1695 | if err := f.Chmod(0o600); err != nil { |
| 1696 | f.Close() |
| 1697 | return err |
| 1698 | } |
| 1699 | if _, err := f.Write(body); err != nil { |
| 1700 | f.Close() |
| 1701 | return err |
| 1702 | } |
| 1703 | if err := f.Sync(); err != nil { |
| 1704 | f.Close() |
| 1705 | return err |
| 1706 | } |
| 1707 | if err := f.Close(); err != nil { |
| 1708 | return err |
| 1709 | } |
| 1710 | if err := unix.Renameat(dir, tmp, dir, "host.env"); err != nil { |
| 1711 | return err |
| 1712 | } |
| 1713 | return unix.Fsync(dir) |
| 1714 | } |
| 1715 | |
| 1716 | // checkedDir opens name under the directory parent as a real directory, |
| 1717 | // creating it 0755 first when create is set and it is missing, and refuses |
| 1718 | // one that is a symlink, is not a directory, is not owned by this |
| 1719 | // process's user (hostEnvOwner), or is writable by group or others. path |
| 1720 | // is its name for the refusal. |
| 1721 | func checkedDir(parent int, path, name string, create bool) (int, error) { |
| 1722 | const how = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC |
| 1723 | fd, err := unix.Openat(parent, name, how, 0) |
| 1724 | if errors.Is(err, unix.ENOENT) && create { |
| 1725 | if err := unix.Mkdirat(parent, name, 0o755); err != nil && !errors.Is(err, unix.EEXIST) { |
| 1726 | return -1, err |
| 1727 | } |
| 1728 | fd, err = unix.Openat(parent, name, how, 0) |
| 1729 | } |
| 1730 | if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) { |
| 1731 | return -1, notRootsDir(path) |
| 1732 | } |
| 1733 | if err != nil { |
| 1734 | return -1, err |
| 1735 | } |
| 1736 | var st unix.Stat_t |
| 1737 | if err := unix.Fstat(fd, &st); err != nil { |
| 1738 | unix.Close(fd) |
| 1739 | return -1, err |
| 1740 | } |
| 1741 | if st.Mode&unix.S_IFMT != unix.S_IFDIR || int(st.Uid) != hostEnvOwner() || st.Mode&0o022 != 0 { |
| 1742 | unix.Close(fd) |
| 1743 | return -1, notRootsDir(path) |
| 1744 | } |
| 1745 | return fd, nil |
| 1746 | } |
| 1747 | |
| 1748 | // hostEnvOwner is the user etc and etc/carlos must belong to: this |
| 1749 | // process's, which runHostEnv has already required to be root. A var so a |
| 1750 | // test can stand for a directory another user owns. |
| 1751 | var hostEnvOwner = os.Geteuid |
| 1752 | |
| 1753 | func notRootsDir(path string) error { |
| 1754 | return fmt.Errorf("%s is not a directory only root can write", path) |
| 1755 | } |
| 1756 | ```` |
| 1757 | |
| 1758 | `internal/hostenv/write_other.go`: |
| 1759 | |
| 1760 | ````go |
| 1761 | //go:build !linux |
| 1762 | |
| 1763 | package hostenv |
| 1764 | |
| 1765 | import "errors" |
| 1766 | |
| 1767 | // writeHostEnv is Linux's: a box that writes host.env runs Linux, and only |
| 1768 | // there are its directories opened and checked by descriptor. |
| 1769 | func writeHostEnv(root string, body []byte) error { |
| 1770 | return errors.New("carlos system host-env write runs only on Linux") |
| 1771 | } |
| 1772 | ```` |
| 1773 | |
| 1774 | `internal/hostenv/awsssm.go`: |
| 1775 | |
| 1776 | ````go |
| 1777 | package hostenv |
| 1778 | |
| 1779 | import ( |
| 1780 | "context" |
| 1781 | "errors" |
| 1782 | "strings" |
| 1783 | |
| 1784 | "github.com/aws/aws-sdk-go-v2/aws" |
| 1785 | "github.com/aws/aws-sdk-go-v2/config" |
| 1786 | "github.com/aws/aws-sdk-go-v2/service/ssm" |
| 1787 | ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" |
| 1788 | ) |
| 1789 | |
| 1790 | // The AWS adapter for Params (region HA spec 2 §25): SSM Parameter Store |
| 1791 | // SecureStrings, encrypted with the deployment's configuration key (§4). |
| 1792 | // A box reads its two paths with the role's ssm:GetParametersByPath and |
| 1793 | // kms:Decrypt; an operator seeding them needs ssm:PutParameter and |
| 1794 | // kms:Encrypt on that key. |
| 1795 | |
| 1796 | type ssmParams struct { |
| 1797 | client *ssm.Client |
| 1798 | kmsKey string |
| 1799 | } |
| 1800 | |
| 1801 | // openSSMParams opens SSM in awsRegion ("" is the SDK's default region). |
| 1802 | // kmsKey is the key Set encrypts with. |
| 1803 | func openSSMParams(ctx context.Context, awsRegion, kmsKey string) (Params, error) { |
| 1804 | var opts []func(*config.LoadOptions) error |
| 1805 | if awsRegion != "" { |
| 1806 | opts = append(opts, config.WithRegion(awsRegion)) |
| 1807 | } |
| 1808 | cfg, err := config.LoadDefaultConfig(ctx, opts...) |
| 1809 | if err != nil { |
| 1810 | return nil, err |
| 1811 | } |
| 1812 | return &ssmParams{client: ssm.NewFromConfig(cfg), kmsKey: kmsKey}, nil |
| 1813 | } |
| 1814 | |
| 1815 | // ByPath reads every parameter one level under prefix, decrypted, page by |
| 1816 | // page. Any failed page fails the whole read. |
| 1817 | func (p *ssmParams) ByPath(ctx context.Context, prefix string) (map[string]string, error) { |
| 1818 | out := map[string]string{} |
| 1819 | pages := ssm.NewGetParametersByPathPaginator(p.client, &ssm.GetParametersByPathInput{ |
| 1820 | Path: aws.String(strings.TrimSuffix(prefix, "/")), |
| 1821 | Recursive: aws.Bool(false), |
| 1822 | WithDecryption: aws.Bool(true), |
| 1823 | }) |
| 1824 | for pages.HasMorePages() { |
| 1825 | page, err := pages.NextPage(ctx) |
| 1826 | if err != nil { |
| 1827 | return nil, err |
| 1828 | } |
| 1829 | for _, param := range page.Parameters { |
| 1830 | name, ok := strings.CutPrefix(aws.ToString(param.Name), prefix) |
| 1831 | if !ok || name == "" || strings.Contains(name, "/") { |
| 1832 | continue |
| 1833 | } |
| 1834 | out[name] = aws.ToString(param.Value) |
| 1835 | } |
| 1836 | } |
| 1837 | return out, nil |
| 1838 | } |
| 1839 | |
| 1840 | // Set writes one SecureString with the deployment's key, replacing it: |
| 1841 | // a region's own values. |
| 1842 | func (p *ssmParams) Set(ctx context.Context, name, value string) error { |
| 1843 | in := &ssm.PutParameterInput{ |
| 1844 | Name: aws.String(name), |
| 1845 | Value: aws.String(value), |
| 1846 | Type: ssmtypes.ParameterTypeSecureString, |
| 1847 | Overwrite: aws.Bool(true), |
| 1848 | } |
| 1849 | if p.kmsKey != "" { |
| 1850 | in.KeyId = aws.String(p.kmsKey) |
| 1851 | } |
| 1852 | _, err := p.client.PutParameter(ctx, in) |
| 1853 | return err |
| 1854 | } |
| 1855 | |
| 1856 | // SetNew writes one SecureString with the deployment's key only if it does |
| 1857 | // not exist (Overwrite false). SSM's ParameterAlreadyExists is ErrExists. |
| 1858 | func (p *ssmParams) SetNew(ctx context.Context, name, value string) error { |
| 1859 | in := &ssm.PutParameterInput{ |
| 1860 | Name: aws.String(name), |
| 1861 | Value: aws.String(value), |
| 1862 | Type: ssmtypes.ParameterTypeSecureString, |
| 1863 | Overwrite: aws.Bool(false), |
| 1864 | } |
| 1865 | if p.kmsKey != "" { |
| 1866 | in.KeyId = aws.String(p.kmsKey) |
| 1867 | } |
| 1868 | _, err := p.client.PutParameter(ctx, in) |
| 1869 | var exists *ssmtypes.ParameterAlreadyExists |
| 1870 | if errors.As(err, &exists) { |
| 1871 | return ErrExists |
| 1872 | } |
| 1873 | return err |
| 1874 | } |
| 1875 | ```` |
| 1876 | |
| 1877 | - [ ] **Step 5: Run the tests** |
| 1878 | |
| 1879 | Run: `nice -n 19 go test ./internal/hostenv/ -count=1` |
| 1880 | Expected: PASS. |
| 1881 | |
| 1882 | - [ ] **Step 6: The provider boundary** |
| 1883 | |
| 1884 | List the core files, the adapter and the package: |
| 1885 | |
| 1886 | `region_ha_boundary_test.go`: |
| 1887 | |
| 1888 | ````diff |
| 1889 | --- a/region_ha_boundary_test.go |
| 1890 | +++ b/region_ha_boundary_test.go |
| 1891 | @@ -30,6 +30,10 @@ |
| 1892 | "internal/console/nocapacity.go", |
| 1893 | "internal/hostops/chosen.go", |
| 1894 | "internal/console/regioncapacity.go", |
| 1895 | + "internal/hostenv/hostenv.go", |
| 1896 | + "internal/hostenv/cli.go", |
| 1897 | + "internal/hostenv/write_linux.go", |
| 1898 | + "internal/hostenv/write_other.go", |
| 1899 | } |
| 1900 | |
| 1901 | // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| 1902 | @@ -37,6 +41,7 @@ |
| 1903 | var regionHAAdapterFiles = []string{ |
| 1904 | "internal/host/awsaddr.go", |
| 1905 | "internal/console/awsregionmetric.go", |
| 1906 | + "internal/hostenv/awsssm.go", |
| 1907 | } |
| 1908 | |
| 1909 | // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| 1910 | @@ -49,6 +54,7 @@ |
| 1911 | "internal/edgesfile", |
| 1912 | "internal/host", |
| 1913 | "internal/hostactors", |
| 1914 | + "internal/hostenv", |
| 1915 | "internal/hostops", |
| 1916 | "internal/lbhealth", |
| 1917 | } |
| 1918 | ```` |
| 1919 | |
| 1920 | Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1` |
| 1921 | Expected: PASS. |
| 1922 | |
| 1923 | - [ ] **Step 7: Mark the rows built** |
| 1924 | |
| 1925 | Add RHB-C1 to RHB-C18, RHB-C64, RHB-C67 and RHB-C68 to `regionHA2bCopyBuilt`, in the same commit as their text: |
| 1926 | |
| 1927 | `approved_region_ha_2b_copy_test.go`: |
| 1928 | |
| 1929 | ````diff |
| 1930 | --- a/approved_region_ha_2b_copy_test.go |
| 1931 | +++ b/approved_region_ha_2b_copy_test.go |
| 1932 | @@ -38,7 +38,7 @@ |
| 1933 | // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| 1934 | // task that writes a row's text adds its ids here in the same commit, once |
| 1935 | // Paul has approved the rows. |
| 1936 | -var regionHA2bCopyBuilt = []string{} |
| 1937 | +var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"} |
| 1938 | |
| 1939 | func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| 1940 | t.Helper() |
| 1941 | ```` |
| 1942 | |
| 1943 | Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1` |
| 1944 | Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails. |
| 1945 | |
| 1946 | - [ ] **Step 8: Census** |
| 1947 | |
| 1948 | Create the overlay and load it last. `runHostEnv` is a new root-read unit (its `seed` path reads the `--from` file), ruled A,J4 by census ruling region-ha-2b. `writeHostEnv` and `checkedDir` are new in round 4 (their read sites open the root, `etc`, `etc/carlos` and the temporary file); platform-1e ruled them A,J4 with `runHostEnv` (`census-ruling-region-ha-2b`, round 5). No legacy row is added, so the seal stands. |
| 1949 | |
| 1950 | `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| 1951 | |
| 1952 | ````text |
| 1953 | # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| 1954 | # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands. |
| 1955 | @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| 1956 | @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| 1957 | internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| 1958 | internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| 1959 | internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| 1960 | ```` |
| 1961 | |
| 1962 | `internal/provision/plan14_root_reads_test.go`: |
| 1963 | |
| 1964 | ````diff |
| 1965 | --- a/internal/provision/plan14_root_reads_test.go |
| 1966 | +++ b/internal/provision/plan14_root_reads_test.go |
| 1967 | @@ -806,6 +806,17 @@ |
| 1968 | t.Fatal(err) |
| 1969 | } |
| 1970 | seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d" |
| 1971 | + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| 1972 | + t.Fatal(errs) |
| 1973 | + } |
| 1974 | + // Census overlay region-ha-2b (region HA spec 2, plan 2b), loaded last. |
| 1975 | + // Its header says what each plan task added. The rows it adds are |
| 1976 | + // census ruling region-ha-2b's (platform-1e); its accepted-legacy rows |
| 1977 | + // move the seal. |
| 1978 | + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil { |
| 1979 | + t.Fatal(err) |
| 1980 | + } |
| 1981 | + seal = "8198674d8809ef90ecebac55324bfd75e69fb3d73bc27b942f29ca2f43e3fe8d" |
| 1982 | errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| 1983 | errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| 1984 | if len(errs) > 0 { |
| 1985 | ```` |
| 1986 | |
| 1987 | Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1` |
| 1988 | Expected: PASS. |
| 1989 | |
| 1990 | - [ ] **Step 9: Commit** |
| 1991 | |
| 1992 | ```bash |
| 1993 | git add internal/hostenv go.mod go.sum region_ha_boundary_test.go approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go |
| 1994 | git commit -m "region HA 2b: host.env from the deployment's parameters" |
| 1995 | ``` |
| 1996 | |
| 1997 | --- |
| 1998 | |
| 1999 | ### Task 3: `carlos system install-channel` |
| 2000 | |
| 2001 | **Files:** |
| 2002 | - Create: `internal/host/installchannel.go`, `internal/host/installchannel_linux.go`, `internal/host/installchannel_other.go`, `internal/host/installchannel_test.go` |
| 2003 | - Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay |
| 2004 | |
| 2005 | **Interfaces:** |
| 2006 | - Consumes: `systemApplier.verifyPointer` and `stageCandidate` (unchanged), `systemCacheHolds`, `copyFileDurable`, `writeJSONDurable`, `readSystemApplied`, `readSystemSeen`, `flockExclusiveNonblock`, `systemGuardDir`, `systemCachePath`, `systemCacheParts`, `systemArtifactLimit`; bootstrap's `bootstrapMakeDir`, `bootstrapOpenDir`, `bootstrapOwner` and `serviceUser`; the agent's `systemLeg.fetch` and `writeRequest` (in the test); `hostfloor.Verify`, `CheckImage`, `Durable`, `Digest`; `release.FetchToFile`, `IdentityKey`. |
| 2007 | - Produces: `RunSystemInstallChannel([]string)` (wired in Task 9); `channelIdentityPath(optDir string) string` (`/opt/carlos/system/router/channel-identity.json`), which Task 7 hands guard-arm; `channelInstall{a *systemApplier; bucket string; fetchTo func(...); own func(path string) error}` and its `run` and `cacheDir`; `ownByService` and `ownDir` (linux; elsewhere `ownByService` refuses); `channelInstalled`; `installedLine`. |
| 2008 | |
| 2009 | This task writes rows RHB-C19 to RHB-C39 of the copy record. Build it only once Paul has approved them, and where his text differs from a draft, use his in `installchannel.go` too: the strings in the code below are the drafts. Adding the ids to `regionHA2bCopyBuilt` makes the gate hold the code to the approved text. |
| 2010 | |
| 2011 | A group box boots from whatever build the deployment last uploaded as its bootstrap object. This puts its neighbours' release in place instead, the one its signed channel pointer names, through `carlos system update`'s own path: `verifyPointer` (the signature, with `CARLOS_RELEASE_PUBKEY`), the artifact fetched against the signed sha256 into the agent's cache, and `stageCandidate` (copied into root's staging, hashed again, asked its version). Before anything is fetched, the release must pass the published host floor exactly as guard-arm will check it (`hostfloor.CheckImage` over the release identity), so a release below the floor never lands. Both `carlos` and `carlos.prev` become the artifact (A2-2), `.prev` first, and the identity is kept for guard-arm. `applied.json` and `seen.json` are written as an apply writes them, so the first real update treats the binary as known good. The bootstrap binary itself is the start-up script's to delete (2c). |
| 2012 | |
| 2013 | Install-channel runs as root after `carlos bootstrap` has handed `/data` to the service user. The cache entry it fetches into is the agent's (`/data/system/router/<version>/carlos`). On every run it makes each directory on that path, `system`, `system/router` and the version's own, as bootstrap makes directories (`bootstrapMakeDir`: every component opened with no symlink followed, so a symlinked parent is refused), and hands each one, alone, to the service user (`ownByService`: bootstrap's `bootstrapOwner`, then the directory opened as `bootstrapOpenDir` opens one and changed with `Fchown` on that descriptor). It does that whether this run created the directory or an earlier run did, so a run whose handoff failed after it made a parent leaves nothing a retry skips (S2, Astra round 2). Nothing else is chowned. Only the version directory is removed afterwards; `/data/system` and `/data/system/router` stay, owned by the agent, which downloads its next release and writes `requested.json` there. The test's `own` seam records each call; the tests fail one handoff and retry, refuse a symlinked parent, and then run the agent's own `systemLeg.fetch` and `writeRequest` for the next release in what install-channel left. |
| 2014 | |
| 2015 | - [ ] **Step 1: Write the failing tests** |
| 2016 | |
| 2017 | The fixture is the apply tests' `newApplyFixture` with its binary removed: a fresh box. |
| 2018 | |
| 2019 | `internal/host/installchannel_test.go`: |
| 2020 | |
| 2021 | ````go |
| 2022 | package host |
| 2023 | |
| 2024 | import ( |
| 2025 | "bytes" |
| 2026 | "context" |
| 2027 | "crypto/ed25519" |
| 2028 | "crypto/sha256" |
| 2029 | "encoding/hex" |
| 2030 | "encoding/json" |
| 2031 | "errors" |
| 2032 | "os" |
| 2033 | "path/filepath" |
| 2034 | "reflect" |
| 2035 | "strings" |
| 2036 | "testing" |
| 2037 | |
| 2038 | "github.com/carlosframework/platform/internal/hostfloor" |
| 2039 | "github.com/carlosframework/platform/internal/release" |
| 2040 | ) |
| 2041 | |
| 2042 | // channelFixture is a fresh box (applyFixture with no binary on it) whose |
| 2043 | // stable channel names v1.4.0, whose identity is published, and whose |
| 2044 | // deployment has a host floor. |
| 2045 | type channelFixture struct { |
| 2046 | *applyFixture |
| 2047 | in channelInstall |
| 2048 | body []byte |
| 2049 | fetches int |
| 2050 | owned []string |
| 2051 | } |
| 2052 | |
| 2053 | func newChannelFixture(t *testing.T, floor string) *channelFixture { |
| 2054 | t.Helper() |
| 2055 | f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "bootstrap")} |
| 2056 | if err := os.Remove(systemBinaryPath(f.a.optDir)); err != nil { |
| 2057 | t.Fatal(err) |
| 2058 | } |
| 2059 | f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| 2060 | f.publish(t, "v1.4.0", f.body) |
| 2061 | if floor != "" { |
| 2062 | if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: floor}); err != nil { |
| 2063 | t.Fatal(err) |
| 2064 | } |
| 2065 | } |
| 2066 | f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| 2067 | f.fetches++ |
| 2068 | return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| 2069 | }, own: func(path string) error { |
| 2070 | f.owned = append(f.owned, path) |
| 2071 | return nil |
| 2072 | }} |
| 2073 | return f |
| 2074 | } |
| 2075 | |
| 2076 | // publish promotes version on stable with body as its linux-arm64 |
| 2077 | // artifact and publishes its release identity. |
| 2078 | func (f *channelFixture) publish(t *testing.T, version string, body []byte) { |
| 2079 | t.Helper() |
| 2080 | ctx := context.Background() |
| 2081 | sum := sha256.Sum256(body) |
| 2082 | rel := release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{ |
| 2083 | "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}} |
| 2084 | if _, err := f.store.Put(ctx, rel.Artifacts["linux-arm64"].Key, body, ""); err != nil { |
| 2085 | t.Fatal(err) |
| 2086 | } |
| 2087 | if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| 2088 | t.Fatal(err) |
| 2089 | } |
| 2090 | wire, _, err := f.store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| 2091 | if err != nil { |
| 2092 | t.Fatal(err) |
| 2093 | } |
| 2094 | if err := release.PublishSystemIdentity(ctx, f.store, f.pub, wire); err != nil { |
| 2095 | t.Fatal(err) |
| 2096 | } |
| 2097 | } |
| 2098 | |
| 2099 | func (f *channelFixture) slots(t *testing.T) (cur, prev []byte) { |
| 2100 | t.Helper() |
| 2101 | cur, _ = os.ReadFile(systemBinaryPath(f.a.optDir)) |
| 2102 | prev, _ = os.ReadFile(systemPrevPath(f.a.optDir)) |
| 2103 | return cur, prev |
| 2104 | } |
| 2105 | |
| 2106 | func TestInstallChannelPutsTheChannelReleaseInBothSlots(t *testing.T) { |
| 2107 | f := newChannelFixture(t, "v1.2.0") |
| 2108 | got, err := f.in.run(context.Background()) |
| 2109 | if err != nil { |
| 2110 | t.Fatal(err) |
| 2111 | } |
| 2112 | if got.Version != "v1.4.0" || got.Tag != "v1.4.0" || got.Already { |
| 2113 | t.Fatalf("installed %+v", got) |
| 2114 | } |
| 2115 | cur, prev := f.slots(t) |
| 2116 | if !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) { |
| 2117 | t.Fatalf("slots hold %q and %q, not the channel's artifact", cur, prev) |
| 2118 | } |
| 2119 | // The identity guard-arm will be given is the channel release's. |
| 2120 | held, err := os.ReadFile(channelIdentityPath(f.a.optDir)) |
| 2121 | if err != nil { |
| 2122 | t.Fatal(err) |
| 2123 | } |
| 2124 | p, err := release.VerifySystemIdentity(f.pub, held) |
| 2125 | if err != nil || p.Current.Version != "v1.4.0" { |
| 2126 | t.Fatalf("identity %v %v", p, err) |
| 2127 | } |
| 2128 | // An update later finds the binary on disk known good, and has seen |
| 2129 | // this promote. |
| 2130 | applied, err := readSystemApplied(f.a.optDir) |
| 2131 | if err != nil || applied == nil || applied.Version != "v1.4.0" || applied.Channel != "stable" { |
| 2132 | t.Fatalf("applied %+v %v", applied, err) |
| 2133 | } |
| 2134 | seen, err := readSystemSeen(f.a.optDir) |
| 2135 | if err != nil || seen["stable"].IsZero() { |
| 2136 | t.Fatalf("seen %v %v", seen, err) |
| 2137 | } |
| 2138 | // Nothing is left in staging or the agent's cache. |
| 2139 | if _, err := os.Stat(f.a.stagingDir()); !os.IsNotExist(err) { |
| 2140 | t.Fatalf("staging left: %v", err) |
| 2141 | } |
| 2142 | if _, err := os.Stat(filepath.Dir(systemCachePath(f.a.dataDir, "v1.4.0"))); !os.IsNotExist(err) { |
| 2143 | t.Fatalf("cache left: %v", err) |
| 2144 | } |
| 2145 | // The candidate was asked its version from staging before it was put |
| 2146 | // in place. |
| 2147 | if !f.called("run carlos version") { |
| 2148 | t.Fatalf("calls %v", f.calls) |
| 2149 | } |
| 2150 | // R2, S2: every directory on the cache's path is handed to the |
| 2151 | // service user, and nothing else, so the agent's own update can |
| 2152 | // download the next release beside them and ask for it. |
| 2153 | if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) { |
| 2154 | t.Fatalf("handed to the service user: %v, want %v", f.owned, want) |
| 2155 | } |
| 2156 | // Run again: nothing to do, nothing fetched. |
| 2157 | again, err := f.in.run(context.Background()) |
| 2158 | if err != nil || !again.Already || f.fetches != 1 { |
| 2159 | t.Fatalf("second run %+v %v, %d fetches", again, err, f.fetches) |
| 2160 | } |
| 2161 | if line := installedLine(again); line != "v1.4.0 (v1.4.0) from the stable channel is already installed" { |
| 2162 | t.Fatalf("line %q", line) |
| 2163 | } |
| 2164 | // The agent, its next release promoted, downloads it and asks for it |
| 2165 | // in the directories install-channel left. |
| 2166 | leg := &systemLeg{dataDir: f.a.dataDir, fetchTo: func(ctx context.Context, key, dst, sha string) error { |
| 2167 | return release.FetchToFile(ctx, f.store, key, dst, systemArtifactLimit, sha) |
| 2168 | }} |
| 2169 | next := []byte("#!/bin/sh\necho carlos v1.5.0\n") |
| 2170 | f.publish(t, "v1.5.0", next) |
| 2171 | sum := sha256.Sum256(next) |
| 2172 | pending := &systemPending{version: "v1.5.0", artifact: release.SystemArtifact{Key: release.SystemArtifactKey("v1.5.0", "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}} |
| 2173 | if err := leg.fetch(context.Background(), pending); err != nil { |
| 2174 | t.Fatalf("the agent's next download: %v", err) |
| 2175 | } |
| 2176 | if err := leg.writeRequest(pending, f.now); err != nil { |
| 2177 | t.Fatalf("the agent's next request: %v", err) |
| 2178 | } |
| 2179 | } |
| 2180 | |
| 2181 | // cachePathDirs are the directories install-channel hands the service |
| 2182 | // user for version: system, system/router and the version's own. |
| 2183 | func cachePathDirs(dataDir, version string) []string { |
| 2184 | return []string{filepath.Join(dataDir, "system"), filepath.Join(dataDir, "system", "router"), filepath.Join(dataDir, "system", "router", version)} |
| 2185 | } |
| 2186 | |
| 2187 | // S2: a run whose ownership handoff fails installs nothing; the next run |
| 2188 | // reconciles every directory on the path, including the ones the failed |
| 2189 | // run created, not only the ones it creates itself. |
| 2190 | func TestInstallChannelReconcilesOwnershipOnARetry(t *testing.T) { |
| 2191 | f := newChannelFixture(t, "v1.2.0") |
| 2192 | own := f.in.own |
| 2193 | f.in.own = func(string) error { return errors.New("chown: operation not permitted") } |
| 2194 | if _, err := f.in.run(context.Background()); err == nil { |
| 2195 | t.Fatal("a failed handoff installed") |
| 2196 | } |
| 2197 | if cur, prev := f.slots(t); cur != nil || prev != nil || f.fetches != 0 { |
| 2198 | t.Fatalf("a failed handoff wrote %q %q after %d fetches", cur, prev, f.fetches) |
| 2199 | } |
| 2200 | // The failed run created system, as root, and never handed it over. |
| 2201 | if _, err := os.Stat(filepath.Join(f.a.dataDir, "system")); err != nil { |
| 2202 | t.Fatalf("the failed run's parent: %v", err) |
| 2203 | } |
| 2204 | f.in.own = own |
| 2205 | if _, err := f.in.run(context.Background()); err != nil { |
| 2206 | t.Fatal(err) |
| 2207 | } |
| 2208 | if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) { |
| 2209 | t.Fatalf("the retry handed %v, want %v", f.owned, want) |
| 2210 | } |
| 2211 | } |
| 2212 | |
| 2213 | // S2: a symlinked parent is refused before anything is fetched, and never |
| 2214 | // followed; the real handoff opens each directory without following one. |
| 2215 | func TestInstallChannelRefusesASymlinkedParent(t *testing.T) { |
| 2216 | f := newChannelFixture(t, "v1.2.0") |
| 2217 | elsewhere := t.TempDir() |
| 2218 | if err := os.Symlink(elsewhere, filepath.Join(f.a.dataDir, "system")); err != nil { |
| 2219 | t.Fatal(err) |
| 2220 | } |
| 2221 | if _, err := f.in.run(context.Background()); err == nil || f.fetches != 0 || len(f.owned) != 0 { |
| 2222 | t.Fatalf("symlinked parent: %v, %d fetches, owned %v", err, f.fetches, f.owned) |
| 2223 | } |
| 2224 | if ents, _ := os.ReadDir(elsewhere); len(ents) != 0 { |
| 2225 | t.Fatalf("followed the symlink: %v", ents) |
| 2226 | } |
| 2227 | if err := ownDir(filepath.Join(f.a.dataDir, "system"), os.Getuid(), os.Getgid()); err == nil { |
| 2228 | t.Fatal("the handoff followed a symlink") |
| 2229 | } |
| 2230 | if err := ownDir(elsewhere, os.Getuid(), os.Getgid()); err != nil { |
| 2231 | t.Fatalf("the handoff of a real directory: %v", err) |
| 2232 | } |
| 2233 | } |
| 2234 | |
| 2235 | func TestInstallChannelRefusesBelowTheFloorAndWithoutOne(t *testing.T) { |
| 2236 | for _, c := range []struct{ name, floor, want string }{ |
| 2237 | {"below the floor", "v2.0.0", "below the host floor v2.0.0"}, |
| 2238 | {"no floor", "", "no host floor is published"}, |
| 2239 | } { |
| 2240 | t.Run(c.name, func(t *testing.T) { |
| 2241 | f := newChannelFixture(t, c.floor) |
| 2242 | _, err := f.in.run(context.Background()) |
| 2243 | if err == nil || !strings.Contains(err.Error(), c.want) { |
| 2244 | t.Fatalf("err %v", err) |
| 2245 | } |
| 2246 | cur, prev := f.slots(t) |
| 2247 | if cur != nil || prev != nil || f.fetches != 0 { |
| 2248 | t.Fatalf("refused install wrote %q %q after %d fetches", cur, prev, f.fetches) |
| 2249 | } |
| 2250 | }) |
| 2251 | } |
| 2252 | } |
| 2253 | |
| 2254 | func TestInstallChannelRefusesAnUnreadableStoreAndABadArtifact(t *testing.T) { |
| 2255 | f := newChannelFixture(t, "v1.2.0") |
| 2256 | f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { |
| 2257 | return nil, nil, errors.New("no route to bucket") |
| 2258 | } |
| 2259 | if _, err := f.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be read") { |
| 2260 | t.Fatalf("unreachable store: %v", err) |
| 2261 | } |
| 2262 | g := newChannelFixture(t, "v1.2.0") |
| 2263 | // The object no longer matches the signed sha256. |
| 2264 | _, etag, _ := g.store.Get(context.Background(), release.SystemArtifactKey("v1.4.0", "linux-arm64")) |
| 2265 | if _, err := g.store.Put(context.Background(), release.SystemArtifactKey("v1.4.0", "linux-arm64"), []byte("#!/bin/sh\necho carlos v0.0.1\n"), etag); err != nil { |
| 2266 | t.Fatal(err) |
| 2267 | } |
| 2268 | if _, err := g.in.run(context.Background()); err == nil || !strings.Contains(err.Error(), "could not be fetched") { |
| 2269 | t.Fatalf("swapped artifact: %v", err) |
| 2270 | } |
| 2271 | if cur, prev := g.slots(t); cur != nil || prev != nil { |
| 2272 | t.Fatalf("swapped artifact installed %q %q", cur, prev) |
| 2273 | } |
| 2274 | } |
| 2275 | |
| 2276 | func TestInstallChannelLeavesAnInstalledBoxToUpdates(t *testing.T) { |
| 2277 | // An armed guard. |
| 2278 | f := newChannelFixture(t, "v1.2.0") |
| 2279 | ctx := context.Background() |
| 2280 | raw, _, _ := f.store.Get(ctx, hostfloor.Key) |
| 2281 | auth, _, _ := f.store.Get(ctx, hostfloor.AuthKey) |
| 2282 | policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: "bucket", Mode: "armed", Floor: raw, Authorization: auth}) |
| 2283 | if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| 2284 | t.Fatal(err) |
| 2285 | } |
| 2286 | if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), policy, 0o644); err != nil { |
| 2287 | t.Fatal(err) |
| 2288 | } |
| 2289 | if _, err := f.in.run(ctx); err == nil || !strings.Contains(err.Error(), "guard is armed") { |
| 2290 | t.Fatalf("armed guard: %v", err) |
| 2291 | } |
| 2292 | // A box an earlier install or update put another release on. |
| 2293 | g := newChannelFixture(t, "v1.2.0") |
| 2294 | if _, err := g.in.run(ctx); err != nil { |
| 2295 | t.Fatal(err) |
| 2296 | } |
| 2297 | g.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n")) |
| 2298 | if _, err := g.in.run(ctx); err == nil || !strings.Contains(err.Error(), "already runs v1.4.0") { |
| 2299 | t.Fatalf("installed box: %v", err) |
| 2300 | } |
| 2301 | if cur, _ := g.slots(t); !bytes.Equal(cur, g.body) { |
| 2302 | t.Fatalf("installed box changed to %q", cur) |
| 2303 | } |
| 2304 | } |
| 2305 | ```` |
| 2306 | |
| 2307 | - [ ] **Step 2: Run them to see them fail** |
| 2308 | |
| 2309 | Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1` |
| 2310 | Expected: FAIL, the package does not build (`undefined: channelInstall`). |
| 2311 | |
| 2312 | - [ ] **Step 3: Write the command** |
| 2313 | |
| 2314 | `internal/host/installchannel.go`: |
| 2315 | |
| 2316 | ````go |
| 2317 | package host |
| 2318 | |
| 2319 | import ( |
| 2320 | "context" |
| 2321 | "crypto/ed25519" |
| 2322 | "errors" |
| 2323 | "flag" |
| 2324 | "fmt" |
| 2325 | "os" |
| 2326 | "path/filepath" |
| 2327 | "runtime" |
| 2328 | |
| 2329 | "github.com/carlosframework/platform/internal/cliflag" |
| 2330 | "github.com/carlosframework/platform/internal/hostfloor" |
| 2331 | "github.com/carlosframework/platform/internal/release" |
| 2332 | "github.com/carlosframework/platform/internal/version" |
| 2333 | ) |
| 2334 | |
| 2335 | // carlos system install-channel (region HA spec 2 §5 step 3, A1-6, A2-2). |
| 2336 | // |
| 2337 | // A group box boots from a bootstrap object, which is whatever build the |
| 2338 | // deployment last uploaded there, not its neighbours' release. This puts |
| 2339 | // the release the box's signed channel pointer names in place instead, |
| 2340 | // through the same path `carlos system update` takes: the pointer verified |
| 2341 | // with CARLOS_RELEASE_PUBKEY (verifyPointer), the artifact fetched against |
| 2342 | // the signed sha256 into the agent's cache, then copied into root's |
| 2343 | // staging, checked again and asked its version (stageCandidate). |
| 2344 | // |
| 2345 | // Both protected slots, carlos and carlos.prev, become that artifact |
| 2346 | // (A2-2): guard-arm checks both against the release identity and refuses |
| 2347 | // a box with any other build in either. The release identity is kept |
| 2348 | // beside them (channelIdentityPath) for the join step's guard-arm, and the |
| 2349 | // release must pass the published host floor (hostfloor.CheckImage, the |
| 2350 | // check guard-arm makes) before anything is fetched. |
| 2351 | // |
| 2352 | // It is for a fresh box. A box whose guard is armed, or that already runs |
| 2353 | // another release this way or through an update, is refused: its releases |
| 2354 | // come through `carlos system update`. Run again on a box it has already |
| 2355 | // installed, it changes nothing. |
| 2356 | |
| 2357 | const installChannelUsage = "usage: carlos system install-channel\n\n" + |
| 2358 | "Install the release this box's channel names, as root, on a box that has none yet.\n" |
| 2359 | |
| 2360 | const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary" |
| 2361 | |
| 2362 | // channelIdentityPath is where the installed release's signed identity is |
| 2363 | // kept for guard-arm (§5 step 5.3). |
| 2364 | func channelIdentityPath(optDir string) string { |
| 2365 | return filepath.Join(systemRootDir(optDir), "channel-identity.json") |
| 2366 | } |
| 2367 | |
| 2368 | // channelInstall is one run. fetchTo is release.FetchToFile over the |
| 2369 | // pointer's store, and own hands a directory tree to the service user |
| 2370 | // (ownByService); both are seams for tests. |
| 2371 | type channelInstall struct { |
| 2372 | a *systemApplier |
| 2373 | bucket string |
| 2374 | fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error |
| 2375 | own func(path string) error |
| 2376 | } |
| 2377 | |
| 2378 | // channelInstalled is what a run did. |
| 2379 | type channelInstalled struct { |
| 2380 | Version, Tag, Channel string |
| 2381 | Already bool |
| 2382 | } |
| 2383 | |
| 2384 | // RunSystemInstallChannel is `carlos system install-channel`. |
| 2385 | func RunSystemInstallChannel(args []string) { |
| 2386 | fs := flag.NewFlagSet("system install-channel", flag.ExitOnError) |
| 2387 | fs.Usage = cliflag.Usage(fs, installChannelUsage) |
| 2388 | _ = fs.Parse(args) |
| 2389 | if fs.NArg() != 0 { |
| 2390 | fs.Usage() |
| 2391 | os.Exit(2) |
| 2392 | } |
| 2393 | if os.Geteuid() != 0 { |
| 2394 | fmt.Fprintln(os.Stderr, installChannelNotRoot) |
| 2395 | os.Exit(1) |
| 2396 | } |
| 2397 | channel, invalid := systemChannel(os.Getenv) |
| 2398 | if invalid != "" { |
| 2399 | fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid)) |
| 2400 | os.Exit(1) |
| 2401 | } |
| 2402 | in := channelInstall{ |
| 2403 | a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(), |
| 2404 | dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH}, |
| 2405 | bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| 2406 | fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| 2407 | return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| 2408 | }, |
| 2409 | own: ownByService, |
| 2410 | } |
| 2411 | got, err := in.run(context.Background()) |
| 2412 | if err != nil { |
| 2413 | fmt.Fprintln(os.Stderr, err) |
| 2414 | os.Exit(1) |
| 2415 | } |
| 2416 | fmt.Println(installedLine(got)) |
| 2417 | } |
| 2418 | |
| 2419 | func installedLine(got channelInstalled) string { |
| 2420 | if got.Already { |
| 2421 | return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel) |
| 2422 | } |
| 2423 | return fmt.Sprintf("installed %s (%s) from the %s channel as carlos and carlos.prev", got.Version, got.Tag, got.Channel) |
| 2424 | } |
| 2425 | |
| 2426 | // run takes the apply lock, so no update runs beside it, and installs. |
| 2427 | func (in channelInstall) run(ctx context.Context) (channelInstalled, error) { |
| 2428 | a := in.a |
| 2429 | if err := os.MkdirAll(a.rootDir(), 0o755); err != nil { |
| 2430 | return channelInstalled{}, err |
| 2431 | } |
| 2432 | lock, err := os.OpenFile(a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600) |
| 2433 | if err != nil { |
| 2434 | return channelInstalled{}, err |
| 2435 | } |
| 2436 | defer lock.Close() |
| 2437 | if err := flockExclusiveNonblock(lock); err != nil { |
| 2438 | return channelInstalled{}, fmt.Errorf("an update is running on this box, so nothing was installed: %w", err) |
| 2439 | } |
| 2440 | return in.install(ctx) |
| 2441 | } |
| 2442 | |
| 2443 | func (in channelInstall) install(ctx context.Context) (channelInstalled, error) { |
| 2444 | a := in.a |
| 2445 | var pub ed25519.PublicKey |
| 2446 | open := a.openStore |
| 2447 | a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| 2448 | s, k, err := open(ctx) |
| 2449 | pub = k |
| 2450 | return s, k, err |
| 2451 | } |
| 2452 | p, err := a.verifyPointer(ctx) |
| 2453 | if err != nil { |
| 2454 | return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err) |
| 2455 | } |
| 2456 | got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel} |
| 2457 | art, ok := p.Current.Artifacts[a.platform] |
| 2458 | if !ok { |
| 2459 | return got, fmt.Errorf("the %s channel names %s with no artifact for %s, so nothing was installed", a.channel, p.Current.Version, a.platform) |
| 2460 | } |
| 2461 | // A box whose guard is armed takes releases only through an update. |
| 2462 | guard := hostfloor.Disk{Dir: systemGuardDir(a.optDir), Bucket: in.bucket, PublicKey: pub} |
| 2463 | if policy, _, err := guard.Load(); err == nil && policy.Mode != "unpublished" { |
| 2464 | return got, errors.New("this box's host lifecycle guard is armed, so carlos system update installs its releases") |
| 2465 | } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 2466 | return got, fmt.Errorf("this box's host lifecycle guard could not be read, so nothing was installed: %w", err) |
| 2467 | } |
| 2468 | applied, err := readSystemApplied(a.optDir) |
| 2469 | if err != nil { |
| 2470 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 2471 | } |
| 2472 | if applied != nil && applied.Version != p.Current.Version { |
| 2473 | return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| 2474 | } |
| 2475 | wire, err := in.checkFloor(ctx, p, art, pub) |
| 2476 | if err != nil { |
| 2477 | return got, err |
| 2478 | } |
| 2479 | if applied != nil && systemCacheHolds(systemBinaryPath(a.optDir), art.SHA256) && systemCacheHolds(systemPrevPath(a.optDir), art.SHA256) && |
| 2480 | systemCacheHolds(channelIdentityPath(a.optDir), hostfloor.Digest(wire)) { |
| 2481 | got.Already = true |
| 2482 | return got, nil |
| 2483 | } |
| 2484 | cache := systemCachePath(a.dataDir, p.Current.Version) |
| 2485 | defer os.RemoveAll(filepath.Dir(cache)) |
| 2486 | defer os.RemoveAll(a.stagingDir()) |
| 2487 | if err := in.cacheDir(p.Current.Version); err != nil { |
| 2488 | return got, err |
| 2489 | } |
| 2490 | if err := in.fetchTo(ctx, a.store, art.Key, cache, art.SHA256); err != nil { |
| 2491 | return got, fmt.Errorf("%s could not be fetched, so nothing was installed: %w", p.Current.Version, err) |
| 2492 | } |
| 2493 | staged, err := a.stageCandidate(ctx, p, systemRequest{Version: p.Current.Version, PromotedAt: p.PromotedAt}) |
| 2494 | if err != nil { |
| 2495 | return got, fmt.Errorf("%s did not verify, so nothing was installed: %w", p.Current.Version, err) |
| 2496 | } |
| 2497 | // carlos.prev first: a run cut off between the two copies leaves the |
| 2498 | // new build in .prev and none in the current slot, which the next run |
| 2499 | // replaces, never the other way round. |
| 2500 | for _, slot := range []string{systemPrevPath(a.optDir), systemBinaryPath(a.optDir)} { |
| 2501 | if err := copyFileDurable(staged, slot, 0o755); err != nil { |
| 2502 | return got, err |
| 2503 | } |
| 2504 | if !systemCacheHolds(slot, art.SHA256) { |
| 2505 | return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| 2506 | } |
| 2507 | } |
| 2508 | if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| 2509 | return got, err |
| 2510 | } |
| 2511 | seen, err := readSystemSeen(a.optDir) |
| 2512 | if err != nil { |
| 2513 | return got, err |
| 2514 | } |
| 2515 | seen[a.channel] = p.PromotedAt |
| 2516 | if err := writeJSONDurable(a.file("seen.json"), seen, 0o644); err != nil { |
| 2517 | return got, err |
| 2518 | } |
| 2519 | if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: p.Current.Version, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil { |
| 2520 | return got, err |
| 2521 | } |
| 2522 | return got, nil |
| 2523 | } |
| 2524 | |
| 2525 | // checkFloor reads the published host floor and the release's signed |
| 2526 | // identity, and returns the identity once the release passes the floor |
| 2527 | // exactly as guard-arm will check it. No floor, or one that cannot be read, |
| 2528 | // refuses: a joining box could not arm its guard without one, and a read |
| 2529 | // that failed says nothing about where the floor is. |
| 2530 | func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, error) { |
| 2531 | s := in.a.store |
| 2532 | raw, _, err := s.Get(ctx, hostfloor.Key) |
| 2533 | if errors.Is(err, os.ErrNotExist) { |
| 2534 | return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| 2535 | } |
| 2536 | if err != nil { |
| 2537 | return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| 2538 | } |
| 2539 | auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| 2540 | if err != nil { |
| 2541 | return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| 2542 | } |
| 2543 | floor, err := hostfloor.Verify(pub, in.bucket, raw, auth) |
| 2544 | if err != nil { |
| 2545 | return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| 2546 | } |
| 2547 | key, err := release.IdentityKey(p.Current.ReleaseTag) |
| 2548 | if err != nil { |
| 2549 | return nil, fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version) |
| 2550 | } |
| 2551 | wire, _, err := s.Get(ctx, key) |
| 2552 | if err != nil { |
| 2553 | return nil, fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err) |
| 2554 | } |
| 2555 | if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil { |
| 2556 | return nil, fmt.Errorf("%s (%s) is below the host floor %s or is not the release its identity names, so nothing was installed", p.Current.Version, p.Current.ReleaseTag, floor.Floor.MinRelease) |
| 2557 | } |
| 2558 | return wire, nil |
| 2559 | } |
| 2560 | |
| 2561 | // cacheDir makes the agent's cache entry for version and reconciles the |
| 2562 | // ownership of every directory on its path below the data dir, on every |
| 2563 | // run: system, system/router and system/router/<version> are created as |
| 2564 | // bootstrap creates directories (bootstrapMakeDir: each component opened |
| 2565 | // with no symlink followed, so a symlinked parent is refused) and each is |
| 2566 | // handed to the service user, as bootstrap's chown of /data does |
| 2567 | // (privilegedPlan), whether this run created it or an earlier run that |
| 2568 | // failed before its handoff did. Nothing else is chowned. The agent |
| 2569 | // (User=carlos) later downloads releases beside the entry and writes its |
| 2570 | // update requests there. Only the entry itself is removed after the |
| 2571 | // install; its parents stay, owned by the agent. |
| 2572 | func (in channelInstall) cacheDir(version string) error { |
| 2573 | parts := systemCacheParts(version) |
| 2574 | dir := in.a.dataDir |
| 2575 | for _, part := range parts[:len(parts)-1] { |
| 2576 | dir = filepath.Join(dir, part) |
| 2577 | if err := bootstrapMakeDir(dir, 0o755); err != nil { |
| 2578 | return err |
| 2579 | } |
| 2580 | if err := in.own(dir); err != nil { |
| 2581 | return err |
| 2582 | } |
| 2583 | } |
| 2584 | return nil |
| 2585 | } |
| 2586 | ```` |
| 2587 | |
| 2588 | `internal/host/installchannel_linux.go`: |
| 2589 | |
| 2590 | ````go |
| 2591 | //go:build linux |
| 2592 | |
| 2593 | package host |
| 2594 | |
| 2595 | import "golang.org/x/sys/unix" |
| 2596 | |
| 2597 | // ownByService hands one directory, and nothing beneath it, to the service |
| 2598 | // user and its group, as bootstrap's ownership step does |
| 2599 | // (bootstrapOwner). It opens the directory as bootstrap opens one |
| 2600 | // (bootstrapOpenDir: no symlink anywhere on the path, not a symlink |
| 2601 | // itself) and changes the open descriptor, so a path swapped for a |
| 2602 | // symlink is refused, never followed. |
| 2603 | func ownByService(path string) error { |
| 2604 | uid, gid, err := bootstrapOwner(serviceUser, serviceUser) |
| 2605 | if err != nil { |
| 2606 | return err |
| 2607 | } |
| 2608 | return ownDir(path, uid, gid) |
| 2609 | } |
| 2610 | |
| 2611 | // ownDir is ownByService for a given owner. |
| 2612 | func ownDir(path string, uid, gid int) error { |
| 2613 | fd, err := bootstrapOpenDir(path) |
| 2614 | if err != nil { |
| 2615 | return err |
| 2616 | } |
| 2617 | defer unix.Close(fd) |
| 2618 | return unix.Fchown(fd, uid, gid) |
| 2619 | } |
| 2620 | ```` |
| 2621 | |
| 2622 | `internal/host/installchannel_other.go`: |
| 2623 | |
| 2624 | ````go |
| 2625 | //go:build !linux |
| 2626 | |
| 2627 | package host |
| 2628 | |
| 2629 | import "errors" |
| 2630 | |
| 2631 | // errOwnNotLinux is every ownership handoff off Linux: carlos system |
| 2632 | // install-channel runs only on a Linux box. |
| 2633 | var errOwnNotLinux = errors.New("install-channel's ownership handoff requires Linux") |
| 2634 | |
| 2635 | func ownByService(path string) error { return errOwnNotLinux } |
| 2636 | |
| 2637 | func ownDir(path string, uid, gid int) error { return errOwnNotLinux } |
| 2638 | ```` |
| 2639 | |
| 2640 | - [ ] **Step 4: Run the tests** |
| 2641 | |
| 2642 | Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1` |
| 2643 | Expected: PASS (on linux: staging opens the cache with `openat`, which only the linux build has). |
| 2644 | |
| 2645 | - [ ] **Step 5: The provider boundary** |
| 2646 | |
| 2647 | `region_ha_boundary_test.go`: |
| 2648 | |
| 2649 | ````diff |
| 2650 | --- a/region_ha_boundary_test.go |
| 2651 | +++ b/region_ha_boundary_test.go |
| 2652 | @@ -34,6 +34,7 @@ |
| 2653 | "internal/hostenv/cli.go", |
| 2654 | "internal/hostenv/write_linux.go", |
| 2655 | "internal/hostenv/write_other.go", |
| 2656 | + "internal/host/installchannel.go", |
| 2657 | } |
| 2658 | |
| 2659 | // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| 2660 | ```` |
| 2661 | |
| 2662 | Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1` |
| 2663 | Expected: PASS. |
| 2664 | |
| 2665 | - [ ] **Step 6: Mark the rows built** |
| 2666 | |
| 2667 | Add RHB-C19 to RHB-C39 to `regionHA2bCopyBuilt`, in the same commit as their text: |
| 2668 | |
| 2669 | `approved_region_ha_2b_copy_test.go`: |
| 2670 | |
| 2671 | ````diff |
| 2672 | --- a/approved_region_ha_2b_copy_test.go |
| 2673 | +++ b/approved_region_ha_2b_copy_test.go |
| 2674 | @@ -38,7 +38,7 @@ |
| 2675 | // regionHA2bCopyBuilt is every id whose text the code or docs carry. The |
| 2676 | // task that writes a row's text adds its ids here in the same commit, once |
| 2677 | // Paul has approved the rows. |
| 2678 | -var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68"} |
| 2679 | +var regionHA2bCopyBuilt = []string{"RHB-C1", "RHB-C2", "RHB-C3", "RHB-C4", "RHB-C5", "RHB-C6", "RHB-C7", "RHB-C8", "RHB-C9", "RHB-C10", "RHB-C11", "RHB-C12", "RHB-C13", "RHB-C14", "RHB-C15", "RHB-C16", "RHB-C17", "RHB-C18", "RHB-C64", "RHB-C67", "RHB-C68", "RHB-C19", "RHB-C20", "RHB-C21", "RHB-C22", "RHB-C23", "RHB-C24", "RHB-C25", "RHB-C26", "RHB-C27", "RHB-C28", "RHB-C29", "RHB-C30", "RHB-C31", "RHB-C32", "RHB-C33", "RHB-C34", "RHB-C35", "RHB-C36", "RHB-C37", "RHB-C38", "RHB-C39"} |
| 2680 | |
| 2681 | func mustParseRegionHA2bRecord(t *testing.T) []copyRow { |
| 2682 | t.Helper() |
| 2683 | ```` |
| 2684 | |
| 2685 | Run: `nice -n 19 go test . -run 'RegionHA2b' -count=1` |
| 2686 | Expected: PASS once the rows are approved. While any built row is pending, `TestRegionHA2bBuiltCopyIsApproved` fails naming each one, and nothing else fails. |
| 2687 | |
| 2688 | - [ ] **Step 7: Census** |
| 2689 | |
| 2690 | `channelInstall.run` is a new root-read unit: its one read site opens the apply lock. Census ruling region-ha-2b is asked to class it as `RunSystemGuardArm` is. The seal stands. |
| 2691 | |
| 2692 | `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| 2693 | |
| 2694 | ````diff |
| 2695 | --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 2696 | +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 2697 | @@ -1,7 +1,10 @@ |
| 2698 | # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| 2699 | # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands. |
| 2700 | +# Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| 2701 | @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| 2702 | @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| 2703 | internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| 2704 | internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| 2705 | internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| 2706 | +@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| 2707 | +internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| 2708 | ```` |
| 2709 | |
| 2710 | Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1` |
| 2711 | Expected: PASS. |
| 2712 | |
| 2713 | - [ ] **Step 8: Commit** |
| 2714 | |
| 2715 | ```bash |
| 2716 | git add internal/host/installchannel.go internal/host/installchannel_linux.go internal/host/installchannel_other.go internal/host/installchannel_test.go region_ha_boundary_test.go approved_region_ha_2b_copy_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 2717 | git commit -m "region HA 2b: carlos system install-channel" |
| 2718 | ``` |
| 2719 | |
| 2720 | --- |
| 2721 | |
| 2722 | ### Task 4: Group membership, read by the console |
| 2723 | |
| 2724 | **Files:** |
| 2725 | - Create: `internal/hostactors/membershipread.go`, `internal/hostactors/groupref.go`, `internal/hostactors/membershipread_test.go`, `internal/hostactors/export_test.go`, `internal/hostactors/boundsdk_test.go` |
| 2726 | - Modify: `internal/hostactors/enrollment_confirmation.go` (`EnrollAutomatic` and the unexported `checkConfirmed` read membership themselves; the stored confirmation is removed), `internal/hostactors/private.go` (a group box's report under its derived reference) |
| 2727 | - Modify: `internal/hostactors/attempt_regression_test.go`, `internal/hostactors/roles_test.go` (main's tests, onto a fake reader) |
| 2728 | - Create: `internal/hostactors/awsmembership/awsmembership.go`, `awsmembership_test.go` |
| 2729 | - Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0`) |
| 2730 | - Modify: `region_ha_boundary_test.go`, `internal/provision/plan9_b8_census_test.go` (`deadlineStore.Put`), the overlay |
| 2731 | |
| 2732 | **Interfaces:** |
| 2733 | - Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace` and `checkMembership` (all main's, unchanged, run on the bound store), `release.Store`. |
| 2734 | - Produces (`hostactors`): `type MembershipReader interface { Member(ctx, awsRegion, instance string) (Membership, error) }`; `Membership`; `MembershipEvidence{RequestIDs []string; ObservedAt time.Time}`; `ErrMembershipUnknown`, `ErrNotMember`, `ErrMembershipStale`, `ErrWriteDeadline`; `GroupBoxReference(account, instance string) string`; `func (d Directory) EnrollAutomatic(ctx, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (Observed, MembershipEvidence, error)`. Unexported: `func (d Directory) checkConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, membershipConfirmation, error)`, `membershipConfirmation` (with `fresh`, `deadline`, `atWrite`, `evidence`), `confirmMembership` (each `Member` call cut off at `storeCallTimeout`, as unknown), `writeBound`, `deadlineStore` (with `call`), `membershipFreshness = time.Minute`, and `var storeCallTimeout = 15 * time.Second` (a var so tests can shorten it). Changed: `Directory.PrivateBoxReport` reads a group box (`Box.Group` set) under `GroupBoxReference` of its pinned account and instance. |
| 2735 | - Removes (`hostactors`): `ConfirmationKey`, `MembershipConfirmation`, `TerminatedEnrollment`, `freshConfirmation`. Nothing on main but `EnrollAutomatic` and its two tests used them. |
| 2736 | - Produces (`hostactors/awsmembership`): `New(ctx) (*Reader, error)` over the console's credentials; `(*Reader).Member` (`DescribeAutoScalingInstances`, then `DescribeInstances` by the `instance-id` filter). |
| 2737 | |
| 2738 | H28: the console is the only membership confirmer, and its read cannot be forged (C1). `EnrollAutomatic` no longer reads a confirmation object from the store: it takes the console's `MembershipReader`, calls `Member` itself, and builds the confirmation from that answer through the unexported `confirmMembership`. The confirmation type is unexported and never leaves the package, and no exported function takes one, or a `Membership`; `TestNoExportedPathTakesAConfirmation` holds that. `checkConfirmed`, unexported, is the same check without the write, which the console's pass makes immediately before it invites (Task 5); it returns the confirmation itself, and the pass binds the invitation to it with `writeBound`, so no exported path carries one. |
| 2739 | |
| 2740 | Every other admission check stands, unchanged: the attempt in the roster matches the request, the record (by digest), the instance, the region's group (name and ARN), account and AWS region; it is not consumed; the two attempt ids are tokens and differ. The checks that existed only for box confirmers go: the confirmer's roster entry and `TerminationFence`, and the cross-region `TerminatedEnrollments` (H17 is withdrawn: the console reads any region, so an emptied region recovers the same way). `BeginEnrollmentAttempt` and the single-use `Consumed` are main's, unchanged. |
| 2741 | |
| 2742 | The read (C2) is a member only when the instance is in the region's group, `Pending` or `InService`, `pending` or `running`, owned by the region's account and in its AWS region, with a request id from each call. Its anchor, its time, is taken before the call, so never later than the describe's completion. `checkConfirmed` refuses a read that was already 60 seconds old or more when the describe returned (`ErrMembershipStale`, which counts against the attempt); that is the only freshness check besides the bound. A failed call, or an answer that is not one (EC2 does not list the instance, a request id is missing), is `ErrMembershipUnknown`, never `ErrNotMember` (#7). The request ids come back as `MembershipEvidence` whenever the cloud answered, refusals included, for the console's log: main's roster has no field for them, and none is added. |
| 2743 | |
| 2744 | **The write bound: the single enforcement point (S1, T3).** The write deadline is the read's anchor plus 60 seconds, and a write at or after it is refused: the write is issued no later than 60 s after the membership read. Each call issued has a hard timeout of 15 s (U2). What the bound guarantees, and what it does not (V1): every store call of the invite and the enrolling write is issued before the deadline (60 s from the start of the membership describe); the console waits at most 15 s for any call; a write whose result the console did not see is unknown and is settled on a later pass by reading the roster. A cancelled write can still commit later; such an enrolment rests on a membership read that was true when the write was issued, and is the same exposure as an instance detached after enrolment, which spec 3's worker reconciles. `Invite`, `checkMembership` and `Replace` are sealed and stay unchanged, and so does `Directory.enroll` (round 3 removed the callback round 1 gave it); instead `writeBound` gives them a copy of the `Directory` whose store is a `deadlineStore` and a context that ends at the deadline. What the bound guarantees: |
| 2745 | - no store call (`Get`, `Put`, `List`, `ListDirs`, `Delete`), the roster `Put` included, is issued at or after the deadline, by the read's clock or the context's: `deadlineStore` refuses it before it reaches the store underneath; |
| 2746 | - every call runs under `deadlineStore.call`, with its own context that ends 15 seconds after it was issued (`storeCallTimeout`) or at the deadline, whichever is first. A call still in flight at the deadline, or still unanswered at its timeout, is cut off: `call` returns `ErrWriteDeadline` itself, without waiting for a store that ignores its context, and cancels the call's context, which the AWS SDK enforces at the HTTP layer; |
| 2747 | - a call that answers only after the deadline, even with success, is `ErrWriteDeadline` too, so a write whose commit can't be confirmed before the deadline is unknown; |
| 2748 | - an unknown write keeps the attempt and its wait, and the next pass reads the roster as every pass does (Task 5, U1); |
| 2749 | - it bounds the console's waiting, not the bucket's commit: a call cut off may still be applied later. Every write it carries is conditional ("Global Constraints: Freshness bound", V4), so such a write lands only on the roster revision it was built against. |
| 2750 | |
| 2751 | The store is wrapped because it can't be trusted to do this: `release.DirStore` ignores its context (`release.S3Store` passes it to the AWS SDK, which does honour it). The pass's S3 store is the console's claim data boundary (`internal/console/claim_runtime.go:42`, `NewConditionalRecordStore`, then `NewObservedMutationBoundary`), whose S3 client the console shares; U2 preferred a dedicated client for the pass, but a second client means a second boundary, with its own census-selected construction and its own mutation observer. The wrapper gives the same hard limit without one: each call's own 15-second context reaches the SDK's HTTP request, and `call` stops waiting at that point whatever the store does. `TestTheBoundReachesTheSDKRequest` (V2, W1) holds that: it builds the console's stores as `claim_runtime.go:33` to `:50` does, the production mutation check included, over `release.NewS3Store` with only the network replaced by a recorder that honours S3's conditional headers. The bucket is seeded with a root-signed roster, so the enrolling roster write is a successful conditional `PUT` through the whole stack: the deadline store, the observed mutation boundary (its pre-read, the production check, which admits a root-signed roster, and its forwarding), the conditional record store's forwarding and the S3 store. The test asserts the SDK sends exactly the roster read's `GET`, then the write's pre-read `GET` and its `PUT`, that the `PUT` carries `If-Match` with the ETag read, that a stale ETag is then refused, and that every request carries 15 seconds from its call, or the write deadline when that is earlier. `export_test.go` gives the external test `writeBound`; the test is external because `hostops` imports `hostactors`. `EnrollAutomatic` runs main's `enroll` on the bound directory. The tests drive a describe of exactly 60 seconds, a clock that reaches the deadline between the check and the roster write, and (Task 5) a store read that answers at the deadline inside the sealed functions, a `Put` still in flight there, a store that ignores its context and never answers, and a `Put` that answers success after the deadline. |
| 2752 | |
| 2753 | **Group references (S3).** `GroupBoxReference` is platform-1e's function: `g-` and the first 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`. `checkConfirmed` refuses a request whose reference is not the attempt instance's group reference, before the cloud is asked, and `PrivateBoxReport` reads a group box's report under the reference derived from the instance its entry pins, so `checkMembership`'s `VerifyReport`, the census and activation never read a group box's report under a key the box chose. A report another box writes there names its own instance and enrollment, and `VerifyReport` refuses it. Non-group boxes are read exactly as before. `deadlineStore.Put` is a new site in the record-write census (nonrecord). |
| 2754 | |
| 2755 | - [ ] **Step 1: Write the failing tests** |
| 2756 | |
| 2757 | `internal/hostactors/membershipread_test.go`: |
| 2758 | |
| 2759 | ````go |
| 2760 | package hostactors |
| 2761 | |
| 2762 | import ( |
| 2763 | "context" |
| 2764 | "encoding/json" |
| 2765 | "errors" |
| 2766 | "fmt" |
| 2767 | "reflect" |
| 2768 | "strings" |
| 2769 | "testing" |
| 2770 | "time" |
| 2771 | |
| 2772 | "github.com/carlosframework/platform/internal/boxid" |
| 2773 | "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| 2774 | "github.com/carlosframework/platform/internal/release" |
| 2775 | "github.com/carlosframework/platform/internal/status" |
| 2776 | ) |
| 2777 | |
| 2778 | // fakeMembership is the cloud, as the console's reader sees it: each |
| 2779 | // instance's membership. err fails every call; during runs inside each |
| 2780 | // call (a slow describe, say). |
| 2781 | type fakeMembership struct { |
| 2782 | members map[string]Membership |
| 2783 | err error |
| 2784 | during func() |
| 2785 | calls []string |
| 2786 | } |
| 2787 | |
| 2788 | func (f *fakeMembership) Member(_ context.Context, awsRegion, instance string) (Membership, error) { |
| 2789 | f.calls = append(f.calls, awsRegion+" "+instance) |
| 2790 | if f.during != nil { |
| 2791 | f.during() |
| 2792 | } |
| 2793 | if f.err != nil { |
| 2794 | return Membership{}, f.err |
| 2795 | } |
| 2796 | return f.members[instance], nil |
| 2797 | } |
| 2798 | |
| 2799 | func member(group string) Membership { |
| 2800 | return Membership{GroupName: group, AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}} |
| 2801 | } |
| 2802 | |
| 2803 | // memberWorld is fixture()'s roster with ie's group "routers-ie", and a |
| 2804 | // box (instance i-new, reference newRef) the group launched, invited, with an open |
| 2805 | // attempt. |
| 2806 | type memberWorld struct { |
| 2807 | d Directory |
| 2808 | sg release.Signer |
| 2809 | o Observed |
| 2810 | v boxid.Verifier |
| 2811 | raw []byte |
| 2812 | in EnrollmentRequest |
| 2813 | cloud *fakeMembership |
| 2814 | clock time.Time |
| 2815 | } |
| 2816 | |
| 2817 | func newMemberWorld(t *testing.T, m Membership) *memberWorld { |
| 2818 | t.Helper() |
| 2819 | ctx := context.Background() |
| 2820 | d, sg, o := fixture(t) |
| 2821 | aws := boxidtest.NewSigner(t) |
| 2822 | _, raw := aws.Record(t, newRef, "123", "eu-west-1", "i-new") |
| 2823 | next := Clone(o.Roster) |
| 2824 | next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers-ie", GroupARN: "arn:aws:autoscaling:eu-west-1:123:autoScalingGroup:x:autoScalingGroupName/routers-ie", Namespaces: []string{"platform"}, Pools: []string{"default"}}} |
| 2825 | o, err := d.Replace(ctx, sg, o, next) |
| 2826 | if err != nil { |
| 2827 | t.Fatal(err) |
| 2828 | } |
| 2829 | w := &memberWorld{d: d, sg: sg, v: boxid.Verifier{Account: "123", Certs: aws.Certs()}, raw: raw, clock: time.Now().UTC(), |
| 2830 | cloud: &fakeMembership{members: map[string]Membership{"i-new": m}}, |
| 2831 | in: EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}} |
| 2832 | if _, err := d.Invite(ctx, sg, newRef, raw); err != nil { |
| 2833 | t.Fatal(err) |
| 2834 | } |
| 2835 | o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, w.in, raw, "i-new") |
| 2836 | if err != nil { |
| 2837 | t.Fatal(err) |
| 2838 | } |
| 2839 | w.o, w.in.AttemptID, w.in.ConfirmationID = o, a.AttemptID, a.ConfirmationID |
| 2840 | return w |
| 2841 | } |
| 2842 | |
| 2843 | func (w *memberWorld) now() time.Time { return w.clock } |
| 2844 | |
| 2845 | func (w *memberWorld) enroll() (Observed, MembershipEvidence, error) { |
| 2846 | return w.d.EnrollAutomatic(context.Background(), w.sg, w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now) |
| 2847 | } |
| 2848 | |
| 2849 | // The console reads membership itself, once, in the attempt's AWS region, |
| 2850 | // and enrolls on it; the read's request ids come back for its log. |
| 2851 | func TestEnrollAutomaticReadsMembershipItself(t *testing.T) { |
| 2852 | w := newMemberWorld(t, member("routers-ie")) |
| 2853 | next, ev, err := w.enroll() |
| 2854 | if err != nil { |
| 2855 | t.Fatal(err) |
| 2856 | } |
| 2857 | if b := next.Roster.Boxes[newRef]; b.State != "active" || b.Group != "routers-ie" || b.Cloud.InstanceID != "i-new" { |
| 2858 | t.Fatalf("roster entry %+v", b) |
| 2859 | } |
| 2860 | if !reflect.DeepEqual(w.cloud.calls, []string{"eu-west-1 i-new"}) || !reflect.DeepEqual(ev.RequestIDs, []string{"asg-req", "ec2-req"}) || !ev.ObservedAt.Equal(w.clock) { |
| 2861 | t.Fatalf("calls %v, evidence %+v", w.cloud.calls, ev) |
| 2862 | } |
| 2863 | // A replay of the consumed attempt is refused before the cloud is |
| 2864 | // asked again. |
| 2865 | w.o = next |
| 2866 | if _, _, err := w.enroll(); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 1 { |
| 2867 | t.Fatalf("replay: %v, calls %v", err, w.cloud.calls) |
| 2868 | } |
| 2869 | if _, _, err := w.d.checkConfirmed(context.Background(), next, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) { |
| 2870 | t.Fatalf("replayed check: %v", err) |
| 2871 | } |
| 2872 | } |
| 2873 | |
| 2874 | // Every positive answer that the instance is not a current member of the |
| 2875 | // region's group refuses, and writes nothing. |
| 2876 | func TestEnrollAutomaticRefusesANonMember(t *testing.T) { |
| 2877 | for name, m := range map[string]Membership{ |
| 2878 | "another group": member("routers-uk"), |
| 2879 | "no group": {Owner: "123", InstanceState: "running", AvailabilityZoneRegion: "eu-west-1", RequestIDs: []string{"a", "b"}}, |
| 2880 | "leaving the group": func() Membership { m := member("routers-ie"); m.LifecycleState = "Terminating"; return m }(), |
| 2881 | "stopped": func() Membership { m := member("routers-ie"); m.InstanceState = "stopped"; return m }(), |
| 2882 | "terminated": func() Membership { m := member("routers-ie"); m.InstanceState = "terminated"; return m }(), |
| 2883 | "another account": func() Membership { m := member("routers-ie"); m.Owner = "999"; return m }(), |
| 2884 | "another zone": func() Membership { m := member("routers-ie"); m.AvailabilityZoneRegion = "eu-west-2"; return m }(), |
| 2885 | } { |
| 2886 | t.Run(name, func(t *testing.T) { |
| 2887 | w := newMemberWorld(t, m) |
| 2888 | if _, _, err := w.d.checkConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrNotMember) { |
| 2889 | t.Fatalf("check: %v", err) |
| 2890 | } |
| 2891 | if _, _, err := w.enroll(); !errors.Is(err, ErrNotMember) { |
| 2892 | t.Fatalf("enroll: %v", err) |
| 2893 | } |
| 2894 | o, err := w.d.Read(context.Background()) |
| 2895 | if err != nil || o.Roster.Revision != w.o.Roster.Revision { |
| 2896 | t.Fatalf("a refused enrollment wrote the roster: %v", err) |
| 2897 | } |
| 2898 | }) |
| 2899 | } |
| 2900 | } |
| 2901 | |
| 2902 | // A read that fails, or is not an answer, is unknown: never a member and |
| 2903 | // never a non-member. |
| 2904 | func TestAMembershipReadThatIsNotAnAnswerIsUnknown(t *testing.T) { |
| 2905 | for name, edit := range map[string]func(*fakeMembership){ |
| 2906 | "the call fails": func(f *fakeMembership) { f.err = errors.New("Throttling") }, |
| 2907 | "EC2 does not list it": func(f *fakeMembership) { |
| 2908 | m := f.members["i-new"] |
| 2909 | m.InstanceState, m.Owner = "", "" |
| 2910 | f.members["i-new"] = m |
| 2911 | }, |
| 2912 | "one request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a"}; f.members["i-new"] = m }, |
| 2913 | "a blank request id": func(f *fakeMembership) { |
| 2914 | m := f.members["i-new"] |
| 2915 | m.RequestIDs = []string{"a", ""} |
| 2916 | f.members["i-new"] = m |
| 2917 | }, |
| 2918 | } { |
| 2919 | t.Run(name, func(t *testing.T) { |
| 2920 | w := newMemberWorld(t, member("routers-ie")) |
| 2921 | edit(w.cloud) |
| 2922 | _, _, err := w.enroll() |
| 2923 | if !errors.Is(err, ErrMembershipUnknown) || errors.Is(err, ErrNotMember) { |
| 2924 | t.Fatalf("%v", err) |
| 2925 | } |
| 2926 | }) |
| 2927 | } |
| 2928 | } |
| 2929 | |
| 2930 | // C2: the write deadline is 60 seconds from the read's anchor, taken |
| 2931 | // before the describe and so never later than its completion, and a read |
| 2932 | // sixty seconds old or more is refused. A describe that took exactly a |
| 2933 | // minute leaves the roster as it was. |
| 2934 | func TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite(t *testing.T) { |
| 2935 | w := newMemberWorld(t, member("routers-ie")) |
| 2936 | w.cloud.during = func() { w.clock = w.clock.Add(60 * time.Second) } |
| 2937 | if _, _, err := w.enroll(); !errors.Is(err, ErrMembershipStale) { |
| 2938 | t.Fatalf("slow read: %v", err) |
| 2939 | } |
| 2940 | o, err := w.d.Read(context.Background()) |
| 2941 | if err != nil || o.Roster.Revision != w.o.Roster.Revision || o.Roster.EnrollmentAttempts[newRef].Consumed { |
| 2942 | t.Fatalf("a stale read wrote the roster: %v", err) |
| 2943 | } |
| 2944 | // A read that was fresh when it was checked but is over a minute old |
| 2945 | // by the write: here every look at the clock takes 31 seconds, so the |
| 2946 | // read is 31 seconds old when checked and 62 when the roster write is |
| 2947 | // about to be made. The write bound refuses it there, as unknown. |
| 2948 | u := newMemberWorld(t, member("routers-ie")) |
| 2949 | ticking := func() time.Time { u.clock = u.clock.Add(31 * time.Second); return u.clock } |
| 2950 | if _, _, err := u.d.EnrollAutomatic(context.Background(), u.sg, u.o, u.v, u.in, u.raw, "i-new", u.cloud, ticking); !errors.Is(err, ErrWriteDeadline) || errors.Is(err, ErrMembershipStale) { |
| 2951 | t.Fatalf("a read gone stale before the write: %v", err) |
| 2952 | } |
| 2953 | if o, err := u.d.Read(context.Background()); err != nil || o.Roster.Revision != u.o.Roster.Revision { |
| 2954 | t.Fatalf("a read gone stale before the write wrote the roster: %v", err) |
| 2955 | } |
| 2956 | // Fifty-nine seconds is inside the minute. |
| 2957 | v := newMemberWorld(t, member("routers-ie")) |
| 2958 | v.cloud.during = func() { v.clock = v.clock.Add(59 * time.Second) } |
| 2959 | if _, _, err := v.enroll(); err != nil { |
| 2960 | t.Fatalf("a read inside the minute: %v", err) |
| 2961 | } |
| 2962 | } |
| 2963 | |
| 2964 | // No exported identifier of this package carries a membership |
| 2965 | // confirmation: the only one is built inside it, from a reader's answer. |
| 2966 | func TestNoExportedPathTakesAConfirmation(t *testing.T) { |
| 2967 | // Every exported method of Directory, EnrollAutomatic among them: the |
| 2968 | // admission check itself (checkConfirmed) is not exported. |
| 2969 | if _, ok := reflect.TypeOf(Directory{}).MethodByName("EnrollAutomatic"); !ok { |
| 2970 | t.Fatal("no EnrollAutomatic") |
| 2971 | } |
| 2972 | if _, ok := reflect.TypeOf(Directory{}).MethodByName("CheckConfirmed"); ok { |
| 2973 | t.Fatal("the admission check is exported again") |
| 2974 | } |
| 2975 | for j := 0; j < reflect.TypeOf(Directory{}).NumMethod(); j++ { |
| 2976 | m := reflect.TypeOf(Directory{}).Method(j) |
| 2977 | name := m.Name |
| 2978 | for i := 0; i < m.Type.NumIn(); i++ { |
| 2979 | in := m.Type.In(i) |
| 2980 | if strings.Contains(strings.ToLower(in.Name()), "confirmation") || in == reflect.TypeOf(MembershipEvidence{}) || in == reflect.TypeOf(Membership{}) { |
| 2981 | t.Fatalf("%s takes a %s", name, in) |
| 2982 | } |
| 2983 | } |
| 2984 | } |
| 2985 | } |
| 2986 | |
| 2987 | // newRef is the joining box's reference: the group reference of |
| 2988 | // instance i-new in account 123. |
| 2989 | var newRef = GroupBoxReference("123", "i-new") |
| 2990 | |
| 2991 | // The ruling's first test: two group boxes never share a reference. The |
| 2992 | // reference is a pseudonym of account and instance, stable, and outside |
| 2993 | // the label space. |
| 2994 | func TestGroupBoxReferencesDoNotCollide(t *testing.T) { |
| 2995 | if GroupBoxReference("123", "i-0a61f580df1acfa65") != GroupBoxReference("123", "i-0a61f580df1acfa65") { |
| 2996 | t.Fatal("not stable") |
| 2997 | } |
| 2998 | if GroupBoxReference("123", "i-a") == GroupBoxReference("124", "i-a") { |
| 2999 | t.Fatal("the account is not in the reference") |
| 3000 | } |
| 3001 | seen := map[string]string{} |
| 3002 | for i := 0; i < 200000; i++ { |
| 3003 | inst := fmt.Sprintf("i-%017x", i) |
| 3004 | ref := GroupBoxReference("123456789012", inst) |
| 3005 | if len(ref) != 22 || !strings.HasPrefix(ref, "g-") || !segment(ref) { |
| 3006 | t.Fatalf("%s: %q", inst, ref) |
| 3007 | } |
| 3008 | if other, ok := seen[ref]; ok { |
| 3009 | t.Fatalf("%s and %s share %s", inst, other, ref) |
| 3010 | } |
| 3011 | seen[ref] = inst |
| 3012 | } |
| 3013 | if strings.Contains(GroupBoxReference("123", "i-0a61f580df1acfa65"), "0a61f580") { |
| 3014 | t.Fatal("the reference shows the instance id") |
| 3015 | } |
| 3016 | } |
| 3017 | |
| 3018 | // The console reads a group box's report under the reference it derives |
| 3019 | // from the roster's pinned instance, never the label in the entry; every |
| 3020 | // other box under its reference, exactly as before. |
| 3021 | func TestPrivateBoxReportDerivesAGroupBoxsKey(t *testing.T) { |
| 3022 | d, _, _ := fixture(t) |
| 3023 | ctx := context.Background() |
| 3024 | put := func(key, instance string) { |
| 3025 | raw, _ := json.Marshal(status.Box{GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{InstanceID: instance}}) |
| 3026 | if _, err := d.Store.Put(ctx, key, raw, ""); err != nil { |
| 3027 | t.Fatal(err) |
| 3028 | } |
| 3029 | } |
| 3030 | derived := GroupBoxReference("123", "i-g") |
| 3031 | put("status/boxes/"+derived+".json", "i-g") |
| 3032 | put("status/boxes/chosen.json", "i-chosen") |
| 3033 | put("status/boxes/annie-welding.json", "i-old") |
| 3034 | r, err := d.PrivateBoxReport(ctx, "chosen", Box{Label: "chosen", Group: "routers-ie", Cloud: Cloud{Account: "123", InstanceID: "i-g"}}) |
| 3035 | if err != nil || r.InstanceID != "i-g" { |
| 3036 | t.Fatalf("group box: %+v %v", r, err) |
| 3037 | } |
| 3038 | r, err = d.PrivateBoxReport(ctx, "annie-welding", Box{Label: "annie-welding", Cloud: Cloud{Account: "123", InstanceID: "i-old"}}) |
| 3039 | if err != nil || r.InstanceID != "i-old" { |
| 3040 | t.Fatalf("a box an operator enrolled: %+v %v", r, err) |
| 3041 | } |
| 3042 | if _, err := d.PrivateBoxReport(ctx, derived, Box{Group: "routers-ie"}); !errors.Is(err, ErrRoster) { |
| 3043 | t.Fatalf("a group box with no pinned instance: %v", err) |
| 3044 | } |
| 3045 | } |
| 3046 | |
| 3047 | // A group box is admitted only under the reference its instance derives: |
| 3048 | // an attempt under a reference the box chose is refused before the cloud |
| 3049 | // is asked, even for a member. |
| 3050 | func TestEnrollAutomaticRefusesAReferenceTheBoxChose(t *testing.T) { |
| 3051 | w := newMemberWorld(t, member("routers-ie")) |
| 3052 | in := w.in |
| 3053 | in.Reference, in.Label = "chosen", "chosen" |
| 3054 | o, a, err := w.d.BeginEnrollmentAttempt(context.Background(), w.sg, w.o, in, w.raw, "i-new") |
| 3055 | if err != nil { |
| 3056 | t.Fatal(err) |
| 3057 | } |
| 3058 | in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID |
| 3059 | if _, _, err := w.d.EnrollAutomatic(context.Background(), w.sg, o, w.v, in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 0 { |
| 3060 | t.Fatalf("a chosen reference: %v, calls %v", err, w.cloud.calls) |
| 3061 | } |
| 3062 | } |
| 3063 | ```` |
| 3064 | |
| 3065 | The bound through the console's production stores, down to the SDK request (V2): |
| 3066 | |
| 3067 | `internal/hostactors/export_test.go`: |
| 3068 | |
| 3069 | ````go |
| 3070 | package hostactors |
| 3071 | |
| 3072 | import ( |
| 3073 | "context" |
| 3074 | "time" |
| 3075 | ) |
| 3076 | |
| 3077 | // BindForTest is writeBound for a membership read anchored at observedAt, |
| 3078 | // for the external test that drives the console's production store. |
| 3079 | func BindForTest(ctx context.Context, d Directory, observedAt time.Time, now func() time.Time) (Directory, context.Context, context.CancelFunc) { |
| 3080 | return writeBound(ctx, d, membershipConfirmation{observedAt: observedAt}, now) |
| 3081 | } |
| 3082 | ```` |
| 3083 | |
| 3084 | `internal/hostactors/boundsdk_test.go`: |
| 3085 | |
| 3086 | ````go |
| 3087 | package hostactors_test |
| 3088 | |
| 3089 | import ( |
| 3090 | "bytes" |
| 3091 | "context" |
| 3092 | "crypto/ed25519" |
| 3093 | "errors" |
| 3094 | "fmt" |
| 3095 | "io" |
| 3096 | "net/http" |
| 3097 | "strings" |
| 3098 | "sync" |
| 3099 | "testing" |
| 3100 | "time" |
| 3101 | |
| 3102 | awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| 3103 | |
| 3104 | "github.com/carlosframework/platform/internal/hostactors" |
| 3105 | "github.com/carlosframework/platform/internal/hostops" |
| 3106 | "github.com/carlosframework/platform/internal/release" |
| 3107 | ) |
| 3108 | |
| 3109 | // sdkRequest is one HTTP request the AWS SDK sent: its method, its path, |
| 3110 | // its If-Match condition, and how long its context had left when it was |
| 3111 | // sent (zero: no deadline). |
| 3112 | type sdkRequest struct { |
| 3113 | method, path, ifMatch string |
| 3114 | left time.Duration |
| 3115 | } |
| 3116 | |
| 3117 | // recordingHTTP stands in for the network under the SDK: a bucket that |
| 3118 | // honours S3's conditional headers. It records each request's context |
| 3119 | // deadline. A GET answers the object and its ETag, or NoSuchKey; a PUT |
| 3120 | // with If-Match succeeds only on the current ETag, one with |
| 3121 | // If-None-Match: * only when the key is absent, and gets a new ETag. |
| 3122 | type recordingHTTP struct { |
| 3123 | mu sync.Mutex |
| 3124 | seen []sdkRequest |
| 3125 | objects map[string]bucketObject |
| 3126 | next int |
| 3127 | } |
| 3128 | |
| 3129 | type bucketObject struct { |
| 3130 | body []byte |
| 3131 | etag string |
| 3132 | } |
| 3133 | |
| 3134 | func (h *recordingHTTP) Do(r *http.Request) (*http.Response, error) { |
| 3135 | var left time.Duration |
| 3136 | if dl, ok := r.Context().Deadline(); ok { |
| 3137 | left = time.Until(dl) |
| 3138 | } |
| 3139 | var body []byte |
| 3140 | if r.Body != nil { |
| 3141 | body, _ = io.ReadAll(r.Body) |
| 3142 | } |
| 3143 | h.mu.Lock() |
| 3144 | defer h.mu.Unlock() |
| 3145 | h.seen = append(h.seen, sdkRequest{r.Method, r.URL.Path, r.Header.Get("If-Match"), left}) |
| 3146 | resp := &http.Response{Header: http.Header{}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Body: io.NopCloser(strings.NewReader(""))} |
| 3147 | reply := func(code int, xml string) (*http.Response, error) { |
| 3148 | resp.StatusCode = code |
| 3149 | resp.Header.Set("Content-Type", "application/xml") |
| 3150 | resp.Body = io.NopCloser(strings.NewReader(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>` + xml + `</Code><Message>fake</Message></Error>`)) |
| 3151 | return resp, nil |
| 3152 | } |
| 3153 | key := r.URL.Path |
| 3154 | cur, exists := h.objects[key] |
| 3155 | switch r.Method { |
| 3156 | case http.MethodPut: |
| 3157 | if m := r.Header.Get("If-Match"); m != "" && (!exists || strings.Trim(m, `"`) != strings.Trim(cur.etag, `"`)) { |
| 3158 | return reply(http.StatusPreconditionFailed, "PreconditionFailed") |
| 3159 | } |
| 3160 | if r.Header.Get("If-None-Match") == "*" && exists { |
| 3161 | return reply(http.StatusPreconditionFailed, "PreconditionFailed") |
| 3162 | } |
| 3163 | h.next++ |
| 3164 | obj := bucketObject{body, fmt.Sprintf(`"e%d"`, h.next)} |
| 3165 | h.objects[key] = obj |
| 3166 | resp.StatusCode = http.StatusOK |
| 3167 | resp.Header.Set("ETag", obj.etag) |
| 3168 | return resp, nil |
| 3169 | case http.MethodGet: |
| 3170 | if !exists { |
| 3171 | return reply(http.StatusNotFound, "NoSuchKey") |
| 3172 | } |
| 3173 | resp.StatusCode = http.StatusOK |
| 3174 | resp.Header.Set("ETag", cur.etag) |
| 3175 | resp.Header.Set("Content-Length", fmt.Sprint(len(cur.body))) |
| 3176 | resp.Body = io.NopCloser(bytes.NewReader(cur.body)) |
| 3177 | resp.ContentLength = int64(len(cur.body)) |
| 3178 | return resp, nil |
| 3179 | } |
| 3180 | return reply(http.StatusNotFound, "NoSuchKey") |
| 3181 | } |
| 3182 | |
| 3183 | func (h *recordingHTTP) take() []sdkRequest { |
| 3184 | h.mu.Lock() |
| 3185 | defer h.mu.Unlock() |
| 3186 | out := h.seen |
| 3187 | h.seen = nil |
| 3188 | return out |
| 3189 | } |
| 3190 | |
| 3191 | // productionStores builds the console's stores as claim_runtime.go does: |
| 3192 | // the release S3 store (its own construction, s3compat and all, with only |
| 3193 | // the network replaced), the conditional record store over it, and the |
| 3194 | // observed mutation boundary with the production mutation check over |
| 3195 | // that: the store the enrolment pass's Directory carries. |
| 3196 | func productionStores(t *testing.T) (raw *release.S3Store, data release.Store, h *recordingHTTP, root release.Signer) { |
| 3197 | t.Helper() |
| 3198 | t.Setenv("AWS_ACCESS_KEY_ID", "k") |
| 3199 | t.Setenv("AWS_SECRET_ACCESS_KEY", "s") |
| 3200 | t.Setenv("AWS_SESSION_TOKEN", "") |
| 3201 | t.Setenv("AWS_REGION", "eu-west-1") |
| 3202 | t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| 3203 | t.Setenv("AWS_CONFIG_FILE", "/nonexistent") |
| 3204 | t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/nonexistent") |
| 3205 | t.Setenv("CARLOS_S3_ENDPOINT", "http://127.0.0.1:9") |
| 3206 | raw, err := release.NewS3Store(context.Background(), "deployment") |
| 3207 | if err != nil { |
| 3208 | t.Fatal(err) |
| 3209 | } |
| 3210 | h = &recordingHTTP{objects: map[string]bucketObject{}} |
| 3211 | raw.Client = awss3.New(raw.Client.Options(), func(o *awss3.Options) { o.HTTPClient = h; o.RetryMaxAttempts = 1 }) |
| 3212 | pub, priv, err := ed25519.GenerateKey(nil) |
| 3213 | if err != nil { |
| 3214 | t.Fatal(err) |
| 3215 | } |
| 3216 | control, err := hostops.NewControlWriter(raw, pub, time.Now) |
| 3217 | if err != nil { |
| 3218 | t.Fatal(err) |
| 3219 | } |
| 3220 | check, err := hostops.NewProductionMutationCheck(raw, control) |
| 3221 | if err != nil { |
| 3222 | t.Fatal(err) |
| 3223 | } |
| 3224 | records, err := hostops.NewConditionalRecordStore(raw, pub) |
| 3225 | if err != nil { |
| 3226 | t.Fatal(err) |
| 3227 | } |
| 3228 | data, err = hostops.NewObservedMutationBoundary(true, records, check, nil) |
| 3229 | if err != nil { |
| 3230 | t.Fatal(err) |
| 3231 | } |
| 3232 | return raw, data, h, release.RootSigner(priv) |
| 3233 | } |
| 3234 | |
| 3235 | // V2 and W1 (platform-1e): the per-call context of a bound write reaches |
| 3236 | // the AWS SDK's HTTP request through the console's production stores, so |
| 3237 | // the 15-second limit is enforced where the request is sent. The roster |
| 3238 | // write is a successful conditional PUT through the whole stack: the |
| 3239 | // bound's deadline store, the observed mutation boundary (its pre-read, |
| 3240 | // the production mutation check, which admits a root-signed roster, and |
| 3241 | // its forwarding), the conditional record store's forwarding and the S3 |
| 3242 | // store. Every request it sends, the PUT included, carries a deadline of |
| 3243 | // 15 seconds from its call, or the write deadline when that comes first. |
| 3244 | // A wrapper anywhere on the path that drops the context, or a bound that |
| 3245 | // does not give each call its own, fails here. |
| 3246 | func TestTheBoundReachesTheSDKRequest(t *testing.T) { |
| 3247 | _, data, h, root := productionStores(t) |
| 3248 | for name, c := range map[string]struct { |
| 3249 | readAge time.Duration |
| 3250 | low, high time.Duration |
| 3251 | }{ |
| 3252 | "the per-call limit": {0, 14 * time.Second, 15 * time.Second}, |
| 3253 | "the earlier write deadline": {50 * time.Second, 9 * time.Second, 10 * time.Second}, |
| 3254 | } { |
| 3255 | t.Run(name, func(t *testing.T) { |
| 3256 | check := func(what string, got []sdkRequest, methods ...string) []sdkRequest { |
| 3257 | t.Helper() |
| 3258 | var sent []string |
| 3259 | for _, r := range got { |
| 3260 | sent = append(sent, r.method) |
| 3261 | if r.left <= c.low || r.left > c.high { |
| 3262 | t.Errorf("%s: %s %s had %s left, want (%s, %s]", what, r.method, r.path, r.left, c.low, c.high) |
| 3263 | } |
| 3264 | } |
| 3265 | if strings.Join(sent, " ") != strings.Join(methods, " ") { |
| 3266 | t.Fatalf("%s: the SDK sent %v, want %v", what, sent, methods) |
| 3267 | } |
| 3268 | return got |
| 3269 | } |
| 3270 | // The bucket holds a roster the pass has read. |
| 3271 | h.mu.Lock() |
| 3272 | h.objects = map[string]bucketObject{} |
| 3273 | h.mu.Unlock() |
| 3274 | before, err := release.SignRootEnvelope(root, map[string]int{"revision": 1}) |
| 3275 | if err != nil { |
| 3276 | t.Fatal(err) |
| 3277 | } |
| 3278 | d := hostactors.Directory{Store: data, Bucket: "deployment"} |
| 3279 | etag, err := data.Put(context.Background(), hostactors.Key, before, "") |
| 3280 | if err != nil { |
| 3281 | t.Fatal(err) |
| 3282 | } |
| 3283 | h.take() |
| 3284 | bd, bctx, cancel := hostactors.BindForTest(context.Background(), d, time.Now().Add(-c.readAge), time.Now) |
| 3285 | defer cancel() |
| 3286 | if _, got, err := bd.Store.Get(bctx, hostactors.Key); err != nil || got != etag { |
| 3287 | t.Fatalf("roster read: %q %v", got, err) |
| 3288 | } |
| 3289 | check("the roster read", h.take(), http.MethodGet) |
| 3290 | // The enrolling write: conditional on that ETag, and admitted. |
| 3291 | next, err := release.SignRootEnvelope(root, map[string]int{"revision": 2}) |
| 3292 | if err != nil { |
| 3293 | t.Fatal(err) |
| 3294 | } |
| 3295 | after, err := bd.Store.Put(bctx, hostactors.Key, next, etag) |
| 3296 | if err != nil || after == etag { |
| 3297 | t.Fatalf("the conditional roster write: %q %v", after, err) |
| 3298 | } |
| 3299 | sent := check("the roster write", h.take(), http.MethodGet, http.MethodPut) |
| 3300 | if strings.Trim(sent[1].ifMatch, `"`) != strings.Trim(etag, `"`) { |
| 3301 | t.Fatalf("the roster PUT was sent with If-Match %q, want the ETag read, %q", sent[1].ifMatch, etag) |
| 3302 | } |
| 3303 | // And it stays conditional: the old ETag now loses. |
| 3304 | if _, err := bd.Store.Put(bctx, hostactors.Key, next, etag); !errors.Is(err, release.ErrCASConflict) { |
| 3305 | t.Fatalf("a stale ETag was not refused: %v", err) |
| 3306 | } |
| 3307 | h.take() |
| 3308 | }) |
| 3309 | } |
| 3310 | } |
| 3311 | ```` |
| 3312 | |
| 3313 | Main's two tests that wrote a confirmation object read through a fake reader instead: |
| 3314 | |
| 3315 | `internal/hostactors/attempt_regression_test.go`: |
| 3316 | |
| 3317 | ````diff |
| 3318 | --- a/internal/hostactors/attempt_regression_test.go |
| 3319 | +++ b/internal/hostactors/attempt_regression_test.go |
| 3320 | @@ -10,12 +10,11 @@ |
| 3321 | "encoding/base64" |
| 3322 | "encoding/json" |
| 3323 | "github.com/carlosframework/platform/internal/boxid" |
| 3324 | - "github.com/carlosframework/platform/internal/release" |
| 3325 | "testing" |
| 3326 | "time" |
| 3327 | ) |
| 3328 | |
| 3329 | -func TestEnrollmentRefreshRejectsExpiredSupersededAndConsumedEvidence(t *testing.T) { |
| 3330 | +func TestEnrollmentRejectsSupersededAndConsumedAttempts(t *testing.T) { |
| 3331 | ctx := context.Background() |
| 3332 | d, sg, o := fixture(t) |
| 3333 | key, err := rsa.GenerateKey(rand.Reader, 2048) |
| 3334 | @@ -28,10 +27,10 @@ |
| 3335 | if err != nil { |
| 3336 | t.Fatal(err) |
| 3337 | } |
| 3338 | - rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)} |
| 3339 | + rec := boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)} |
| 3340 | raw, _ := json.Marshal(rec) |
| 3341 | v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| 3342 | - in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| 3343 | + in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| 3344 | next := Clone(o.Roster) |
| 3345 | next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}} |
| 3346 | o, err = d.Replace(ctx, sg, o, next) |
| 3347 | @@ -45,27 +44,9 @@ |
| 3348 | if err != nil { |
| 3349 | t.Fatal(err) |
| 3350 | } |
| 3351 | - now := time.Now().UTC() |
| 3352 | - confirmation := func(a EnrollmentAttempt, at time.Time) MembershipConfirmation { |
| 3353 | - c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: at, RequestIDs: []string{"asg", "ec2"}} |
| 3354 | - c.Confirmer.Box = "box" |
| 3355 | - c.Confirmer.Enrollment = o.Roster.Boxes["box"].Enrollment |
| 3356 | - c.Confirmer.Region = "ie" |
| 3357 | - return c |
| 3358 | - } |
| 3359 | - save := func(a EnrollmentAttempt, c MembershipConfirmation) { |
| 3360 | - t.Helper() |
| 3361 | - wire, _ := json.Marshal(c) |
| 3362 | - if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil { |
| 3363 | - t.Fatal(err) |
| 3364 | - } |
| 3365 | - } |
| 3366 | - save(first, confirmation(first, now.Add(-2*time.Minute))) |
| 3367 | - in.AttemptID = first.AttemptID |
| 3368 | - in.ConfirmationID = first.ConfirmationID |
| 3369 | - if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil { |
| 3370 | - t.Fatal("expired evidence admitted") |
| 3371 | - } |
| 3372 | + // The console's own read, which says the instance is a member. |
| 3373 | + cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}} |
| 3374 | + now := func() time.Time { return time.Now().UTC() } |
| 3375 | staleRoster := o |
| 3376 | o, second, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new") |
| 3377 | if err != nil { |
| 3378 | @@ -74,30 +55,24 @@ |
| 3379 | if first.AttemptID == second.AttemptID || first.ConfirmationID == second.ConfirmationID { |
| 3380 | t.Fatal("immutable identity reused") |
| 3381 | } |
| 3382 | - save(second, confirmation(second, now)) |
| 3383 | - if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now); err == nil { |
| 3384 | + in.AttemptID, in.ConfirmationID = first.AttemptID, first.ConfirmationID |
| 3385 | + if _, _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now); err == nil { |
| 3386 | t.Fatal("superseded request admitted") |
| 3387 | } |
| 3388 | - if _, err = d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil { |
| 3389 | - t.Fatal("superseded IDs admitted while old evidence was still fresh") |
| 3390 | - } |
| 3391 | - // Even a still-fresh old confirmation cannot win the stale roster CAS. |
| 3392 | - if _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", now.Add(-2*time.Minute)); err == nil { |
| 3393 | + // The superseded roster snapshot loses its CAS even with its own |
| 3394 | + // attempt's ids. |
| 3395 | + if _, _, err = d.EnrollAutomatic(ctx, sg, staleRoster, v, in, raw, "i-new", cloud, now); err == nil { |
| 3396 | t.Fatal("superseded snapshot admitted") |
| 3397 | } |
| 3398 | - in.AttemptID = second.AttemptID |
| 3399 | - in.ConfirmationID = second.ConfirmationID |
| 3400 | - admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now) |
| 3401 | + in.AttemptID, in.ConfirmationID = second.AttemptID, second.ConfirmationID |
| 3402 | + admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, now) |
| 3403 | if err != nil { |
| 3404 | t.Fatal(err) |
| 3405 | } |
| 3406 | if !admitted.Roster.EnrollmentAttempts[in.Reference].Consumed { |
| 3407 | t.Fatal("attempt not consumed with roster admission") |
| 3408 | } |
| 3409 | - if _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", now); err == nil { |
| 3410 | - t.Fatal("consumed confirmation replayed") |
| 3411 | - } |
| 3412 | - if _, _, err = d.Store.Get(ctx, ConfirmationKey(in.Reference, first.AttemptID, first.ConfirmationID)); err != nil { |
| 3413 | - t.Fatal("old immutable evidence removed", err) |
| 3414 | + if _, _, err = d.EnrollAutomatic(ctx, sg, admitted, v, in, raw, "i-new", cloud, now); err == nil { |
| 3415 | + t.Fatal("consumed attempt replayed") |
| 3416 | } |
| 3417 | } |
| 3418 | ```` |
| 3419 | |
| 3420 | `internal/hostactors/roles_test.go`: |
| 3421 | |
| 3422 | ````diff |
| 3423 | --- a/internal/hostactors/roles_test.go |
| 3424 | +++ b/internal/hostactors/roles_test.go |
| 3425 | @@ -189,9 +189,9 @@ |
| 3426 | if err != nil { |
| 3427 | t.Fatal(err) |
| 3428 | } |
| 3429 | - raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) |
| 3430 | + raw, _ := json.Marshal(boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) |
| 3431 | v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| 3432 | - in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| 3433 | + in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| 3434 | next := Clone(o.Roster) |
| 3435 | next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}} |
| 3436 | if o, err = d.Replace(ctx, sg, o, next); err != nil { |
| 3437 | @@ -204,19 +204,14 @@ |
| 3438 | if err != nil { |
| 3439 | t.Fatal(err) |
| 3440 | } |
| 3441 | - now := time.Now().UTC() |
| 3442 | - c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: now, RequestIDs: []string{"asg", "ec2"}} |
| 3443 | - c.Confirmer.Box, c.Confirmer.Enrollment, c.Confirmer.Region = "box", o.Roster.Boxes["box"].Enrollment, "ie" |
| 3444 | - wire, _ := json.Marshal(c) |
| 3445 | - if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil { |
| 3446 | - t.Fatal(err) |
| 3447 | - } |
| 3448 | + // The console's own read: a running member of the group. |
| 3449 | + cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}} |
| 3450 | in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID |
| 3451 | - admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now) |
| 3452 | - if err != nil { |
| 3453 | - t.Fatal(err) |
| 3454 | - } |
| 3455 | - b := admitted.Roster.Boxes["new"] |
| 3456 | + admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, time.Now) |
| 3457 | + if err != nil { |
| 3458 | + t.Fatal(err) |
| 3459 | + } |
| 3460 | + b := admitted.Roster.Boxes[newRef] |
| 3461 | if b.Group != "routers" || b.Role != role { |
| 3462 | t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role) |
| 3463 | } |
| 3464 | ```` |
| 3465 | |
| 3466 | `internal/hostactors/awsmembership/awsmembership_test.go`: |
| 3467 | |
| 3468 | ````go |
| 3469 | package awsmembership |
| 3470 | |
| 3471 | import ( |
| 3472 | "reflect" |
| 3473 | "testing" |
| 3474 | |
| 3475 | "github.com/aws/aws-sdk-go-v2/aws" |
| 3476 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 3477 | "github.com/aws/aws-sdk-go-v2/service/autoscaling" |
| 3478 | asgtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types" |
| 3479 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 3480 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 3481 | "github.com/aws/smithy-go/middleware" |
| 3482 | |
| 3483 | "github.com/carlosframework/platform/internal/hostactors" |
| 3484 | ) |
| 3485 | |
| 3486 | func meta(id string) middleware.Metadata { |
| 3487 | var md middleware.Metadata |
| 3488 | awsmiddleware.SetRequestIDMetadata(&md, id) |
| 3489 | return md |
| 3490 | } |
| 3491 | |
| 3492 | func asgReply(instance, group, zone, state string) *autoscaling.DescribeAutoScalingInstancesOutput { |
| 3493 | return &autoscaling.DescribeAutoScalingInstancesOutput{ResultMetadata: meta("asg-req"), AutoScalingInstances: []asgtypes.AutoScalingInstanceDetails{{ |
| 3494 | InstanceId: aws.String(instance), AutoScalingGroupName: aws.String(group), AvailabilityZone: aws.String(zone), LifecycleState: aws.String(state)}}} |
| 3495 | } |
| 3496 | |
| 3497 | func ec2Reply(owner string, instances map[string]ec2types.InstanceStateName) *ec2.DescribeInstancesOutput { |
| 3498 | res := ec2types.Reservation{OwnerId: aws.String(owner)} |
| 3499 | for id, st := range instances { |
| 3500 | res.Instances = append(res.Instances, ec2types.Instance{InstanceId: aws.String(id), State: &ec2types.InstanceState{Name: st}}) |
| 3501 | } |
| 3502 | return &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req"), Reservations: []ec2types.Reservation{res}} |
| 3503 | } |
| 3504 | |
| 3505 | func TestMembershipFromTheTwoReplies(t *testing.T) { |
| 3506 | got := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "InService"), ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning})) |
| 3507 | want := hostactors.Membership{GroupName: "routers-ie", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}} |
| 3508 | if !reflect.DeepEqual(got, want) { |
| 3509 | t.Fatalf("got %+v\nwant %+v", got, want) |
| 3510 | } |
| 3511 | // Not a member: Auto Scaling knows nothing of it, or answers for |
| 3512 | // another instance. EC2 knowing it changes nothing. |
| 3513 | for _, ao := range []*autoscaling.DescribeAutoScalingInstancesOutput{{ResultMetadata: meta("asg-req")}, asgReply("i-other", "routers-ie", "eu-west-1b", "InService")} { |
| 3514 | m := membershipFrom("i-new", ao, ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning})) |
| 3515 | if m.GroupName != "" || m.LifecycleState != "" { |
| 3516 | t.Fatalf("non-member read as %+v", m) |
| 3517 | } |
| 3518 | } |
| 3519 | // EC2 no longer lists it: no state and no owner. |
| 3520 | m := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "Terminating"), &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req")}) |
| 3521 | if m.InstanceState != "" || m.Owner != "" { |
| 3522 | t.Fatalf("forgotten instance read as %+v", m) |
| 3523 | } |
| 3524 | } |
| 3525 | |
| 3526 | func TestZoneRegion(t *testing.T) { |
| 3527 | for az, want := range map[string]string{"eu-west-1a": "eu-west-1", "ap-southeast-2c": "ap-southeast-2", "us-west-2-lax-1a": "us-west-2-lax-1", "euw1-az1": "", "eu-west-1": "", "": ""} { |
| 3528 | if got := zoneRegion(az); got != want { |
| 3529 | t.Fatalf("zoneRegion(%q) = %q, want %q", az, got, want) |
| 3530 | } |
| 3531 | } |
| 3532 | } |
| 3533 | ```` |
| 3534 | |
| 3535 | - [ ] **Step 2: Run them to see them fail** |
| 3536 | |
| 3537 | Run: `nice -n 19 go test ./internal/hostactors/... -count=1` |
| 3538 | Expected: FAIL, neither package builds (`undefined: Membership`, `undefined: membershipFrom`). |
| 3539 | |
| 3540 | - [ ] **Step 3: Add the Auto Scaling SDK** |
| 3541 | |
| 3542 | v1.74.0 is the release built against the module's AWS core v1.44.0. Its sums match sum.golang.org. |
| 3543 | |
| 3544 | `go.mod`: |
| 3545 | |
| 3546 | ````diff |
| 3547 | --- a/go.mod |
| 3548 | +++ b/go.mod |
| 3549 | @@ -7,6 +7,7 @@ |
| 3550 | github.com/aws/aws-sdk-go-v2/config v1.32.6 |
| 3551 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 |
| 3552 | github.com/aws/aws-sdk-go-v2/service/acm v1.45.0 |
| 3553 | + github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0 |
| 3554 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 |
| 3555 | github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 |
| 3556 | github.com/aws/aws-sdk-go-v2/service/ec2 v1.324.0 |
| 3557 | ```` |
| 3558 | |
| 3559 | `go.sum`: |
| 3560 | |
| 3561 | ````diff |
| 3562 | --- a/go.sum |
| 3563 | +++ b/go.sum |
| 3564 | @@ -40,6 +40,8 @@ |
| 3565 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= |
| 3566 | github.com/aws/aws-sdk-go-v2/service/acm v1.45.0 h1:C/LUyd2F3iFtityAYQbAYb56uP0x32HaHSVXfRSImAM= |
| 3567 | github.com/aws/aws-sdk-go-v2/service/acm v1.45.0/go.mod h1:8TFQHTFTgR7OoBHlqJr8gsHv+ePC8z4S1lcrZVPgqbM= |
| 3568 | +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0 h1:hm0br6zbFj1tLd5l8F6owm2/4pFK+z6NA+wIkvdMFxg= |
| 3569 | +github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0/go.mod h1:71sdt+AKd1aCexOigHqdAO5Er2oY2dEhI63220v7A6o= |
| 3570 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0 h1:RdWMeCVaJfu4hCPyRNVQMKMP/Y5J04zB5LF45DmHhRg= |
| 3571 | github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.68.0/go.mod h1:HfsU8PQ6mHewSJNS77IPAFMZZgFaYSHjAhSOAFXdu9M= |
| 3572 | github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA= |
| 3573 | ```` |
| 3574 | |
| 3575 | - [ ] **Step 4: Write the read, the admission check and the adapter** |
| 3576 | |
| 3577 | `internal/hostactors/membershipread.go`: |
| 3578 | |
| 3579 | ````go |
| 3580 | package hostactors |
| 3581 | |
| 3582 | import ( |
| 3583 | "context" |
| 3584 | "errors" |
| 3585 | "fmt" |
| 3586 | "time" |
| 3587 | |
| 3588 | "github.com/carlosframework/platform/internal/release" |
| 3589 | ) |
| 3590 | |
| 3591 | // Group membership, confirmed by the console (region HA spec 2 §5 step 5, |
| 3592 | // H16, H28). Before it invites a box its region's group launched, and |
| 3593 | // again before it enrolls it, the console asks the cloud, read-only and |
| 3594 | // with its own credentials, whether the instance is a current member of |
| 3595 | // the region's group. No box writes or contributes membership evidence: |
| 3596 | // a confirmation exists only inside this package, built by |
| 3597 | // confirmMembership from one MembershipReader answer, and nothing |
| 3598 | // exported takes one in. |
| 3599 | // |
| 3600 | // What the cloud says is the reader's business; the AWS adapter is |
| 3601 | // internal/hostactors/awsmembership. This file names no provider (§25). |
| 3602 | |
| 3603 | // MembershipReader answers, read-only, from the cloud the roster's region |
| 3604 | // entries name. It is the console's own client. |
| 3605 | type MembershipReader interface { |
| 3606 | // Member describes instance in awsRegion: its group, if any, and its |
| 3607 | // state. An instance in no group is a Membership with no GroupName. An |
| 3608 | // error is a read that failed, never an answer. |
| 3609 | Member(ctx context.Context, awsRegion, instance string) (Membership, error) |
| 3610 | } |
| 3611 | |
| 3612 | // Membership is one instance's group membership, as the cloud reported it. |
| 3613 | type Membership struct { |
| 3614 | GroupName string |
| 3615 | AvailabilityZoneRegion string |
| 3616 | LifecycleState string |
| 3617 | InstanceState string |
| 3618 | Owner string |
| 3619 | // RequestIDs are the cloud's request ids for the reads, one per call. |
| 3620 | RequestIDs []string |
| 3621 | } |
| 3622 | |
| 3623 | // MembershipEvidence is what a confirmation read, for the console's log: |
| 3624 | // the cloud's request ids, and when the read began. |
| 3625 | type MembershipEvidence struct { |
| 3626 | RequestIDs []string |
| 3627 | ObservedAt time.Time |
| 3628 | } |
| 3629 | |
| 3630 | // membershipFreshness is the write deadline: a read's write is issued |
| 3631 | // less than membershipFreshness after the read's anchor, never at or after |
| 3632 | // it. |
| 3633 | const membershipFreshness = time.Minute |
| 3634 | |
| 3635 | var ( |
| 3636 | // ErrMembershipUnknown is a membership read that failed. It says |
| 3637 | // nothing about the instance: the console tries again with the same |
| 3638 | // attempt. |
| 3639 | ErrMembershipUnknown = errors.New("hostactors: group membership could not be read") |
| 3640 | // ErrNotMember is the cloud's positive answer that the instance is not |
| 3641 | // a current, running member of the region's group in the deployment's |
| 3642 | // account and AWS region. |
| 3643 | ErrNotMember = errors.New("hostactors: the instance is not a current member of the region's group") |
| 3644 | // ErrMembershipStale is a read that was already membershipFreshness old |
| 3645 | // or more when the describe returned: the cloud was slow, and the |
| 3646 | // answer counts against the attempt. |
| 3647 | ErrMembershipStale = errors.New("hostactors: the membership read is more than a minute old") |
| 3648 | // ErrWriteDeadline is a write the deadline stopped (writeBound): no |
| 3649 | // store call was issued at or after it, but one already in flight was |
| 3650 | // cancelled, so whether it committed is unknown. The next pass reads |
| 3651 | // back and describes again. |
| 3652 | ErrWriteDeadline = errors.New("hostactors: the write would be issued more than a minute after its membership read") |
| 3653 | ) |
| 3654 | |
| 3655 | // membershipConfirmation is one read of an attempt's instance. It is built |
| 3656 | // only by confirmMembership, from the reader's answer, and never leaves |
| 3657 | // this package. A read the cloud answered carries its request ids and |
| 3658 | // time whether or not it found a member, so every read can be logged. |
| 3659 | type membershipConfirmation struct { |
| 3660 | requestIDs []string |
| 3661 | observedAt time.Time |
| 3662 | } |
| 3663 | |
| 3664 | // confirmMembership asks r about instance in region's AWS region, and |
| 3665 | // returns the confirmation only when the answer is a current member of |
| 3666 | // region's group: Pending or InService, pending or running, owned by the |
| 3667 | // region's account, in its AWS region, with a request id for each call. |
| 3668 | // The read's anchor, its time, is taken before the call: never later than |
| 3669 | // the describe's completion, so its age is never understated. |
| 3670 | // |
| 3671 | // The call gets at most storeCallTimeout, as each bound store call does: |
| 3672 | // neither describe sets a deadline of its own, and a connection that |
| 3673 | // stalls would otherwise hold the serial pass, and every box behind this |
| 3674 | // one, for as long as it stalls. A call cut off, by the timeout or by the |
| 3675 | // pass's own context, is unknown whatever it returned: the attempt stands |
| 3676 | // and its wait does not grow. |
| 3677 | func confirmMembership(ctx context.Context, r MembershipReader, region Region, instance string, now func() time.Time) (membershipConfirmation, error) { |
| 3678 | if r == nil || now == nil { |
| 3679 | return membershipConfirmation{}, fmt.Errorf("%w: no reader", ErrMembershipUnknown) |
| 3680 | } |
| 3681 | at := now().UTC() |
| 3682 | rctx, cancel := context.WithTimeout(ctx, storeCallTimeout) |
| 3683 | m, err := r.Member(rctx, region.AWSRegion, instance) |
| 3684 | cut := rctx.Err() |
| 3685 | cancel() |
| 3686 | // The ids the cloud gave are kept whatever came of the read, failed |
| 3687 | // calls included, for the console's log. |
| 3688 | c := membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at} |
| 3689 | if cut != nil { |
| 3690 | return c, fmt.Errorf("%w: the read did not finish in time: %v", ErrMembershipUnknown, cut) |
| 3691 | } |
| 3692 | if err != nil { |
| 3693 | return c, fmt.Errorf("%w: %v", ErrMembershipUnknown, err) |
| 3694 | } |
| 3695 | // A reply without an id from each call is not an answer, whatever it |
| 3696 | // claims, so it is never counted against the attempt. |
| 3697 | if len(m.RequestIDs) < 2 { |
| 3698 | return c, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown) |
| 3699 | } |
| 3700 | for _, id := range m.RequestIDs { |
| 3701 | if id == "" || id == NoRequestID { |
| 3702 | return c, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown) |
| 3703 | } |
| 3704 | } |
| 3705 | switch { |
| 3706 | case m.GroupName != region.GroupName: |
| 3707 | return c, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName) |
| 3708 | case m.LifecycleState != "Pending" && m.LifecycleState != "InService": |
| 3709 | return c, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState) |
| 3710 | case m.InstanceState == "" || m.Owner == "": |
| 3711 | // Auto Scaling knows it but EC2 does not list it (yet, or any |
| 3712 | // more): no answer. |
| 3713 | return c, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown) |
| 3714 | case m.InstanceState != "pending" && m.InstanceState != "running": |
| 3715 | return c, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState) |
| 3716 | case m.Owner != region.Account || m.AvailabilityZoneRegion != region.AWSRegion: |
| 3717 | return c, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion) |
| 3718 | } |
| 3719 | return c, nil |
| 3720 | } |
| 3721 | |
| 3722 | // fresh refuses a confirmation membershipFreshness old or more at now, or |
| 3723 | // one from the future: the describe itself was too slow. |
| 3724 | func (c membershipConfirmation) fresh(now time.Time) error { |
| 3725 | if c.observedAt.IsZero() || now.Sub(c.observedAt) >= membershipFreshness || c.observedAt.After(now.Add(5*time.Second)) { |
| 3726 | return fmt.Errorf("%w (read at %s, checked at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339)) |
| 3727 | } |
| 3728 | return nil |
| 3729 | } |
| 3730 | |
| 3731 | // deadline is the last moment a write this read allows may be issued. |
| 3732 | func (c membershipConfirmation) deadline() time.Time { return c.observedAt.Add(membershipFreshness) } |
| 3733 | |
| 3734 | // atWrite is the bound store's check before each call: at or after the |
| 3735 | // deadline it is ErrWriteDeadline, unknown, never a refusal. |
| 3736 | func (c membershipConfirmation) atWrite(now time.Time) error { |
| 3737 | if c.observedAt.IsZero() || !now.Before(c.deadline()) { |
| 3738 | return fmt.Errorf("%w (read at %s, write at %s)", ErrWriteDeadline, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339)) |
| 3739 | } |
| 3740 | return nil |
| 3741 | } |
| 3742 | |
| 3743 | func (c membershipConfirmation) evidence() MembershipEvidence { |
| 3744 | return MembershipEvidence{RequestIDs: append([]string(nil), c.requestIDs...), ObservedAt: c.observedAt} |
| 3745 | } |
| 3746 | |
| 3747 | // writeBound is d with every store call bound to c's deadline (C2, S1), |
| 3748 | // the single enforcement point of the write's freshness. Each store call |
| 3749 | // (a read, the write, the read-back after a lost write) is refused before |
| 3750 | // it is issued once the deadline is reached, by either clock, the |
| 3751 | // context's or now; the returned context ends at the deadline; and each |
| 3752 | // call it does issue gets at most storeCallTimeout (U2). The sealed |
| 3753 | // writers (Invite, Replace and the checkMembership it makes) run on it |
| 3754 | // unchanged, so no read, signature's Put or read-back of theirs is issued |
| 3755 | // 60 seconds or more after the membership read's anchor, and any outcome |
| 3756 | // not confirmed in time is ErrWriteDeadline, unknown. |
| 3757 | func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) { |
| 3758 | bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now())) |
| 3759 | bd := d |
| 3760 | bd.Store = deadlineStore{Store: d.Store, check: func(callCtx context.Context) error { |
| 3761 | if bctx.Err() != nil || callCtx.Err() != nil { |
| 3762 | return fmt.Errorf("%w: the deadline passed", ErrWriteDeadline) |
| 3763 | } |
| 3764 | return c.atWrite(now()) |
| 3765 | }} |
| 3766 | return bd, bctx, cancel |
| 3767 | } |
| 3768 | |
| 3769 | // storeCallTimeout is the hard limit on one store call of a bound write |
| 3770 | // (U2): a call the store has not answered within it is unknown, and its |
| 3771 | // context is cancelled, so an S3 request is abandoned at the HTTP layer. |
| 3772 | // It bounds how long the console waits, not when a write commits: a |
| 3773 | // call cut off may still commit later, and is settled from the roster on |
| 3774 | // a later pass. confirmMembership gives each membership read the same |
| 3775 | // limit. A var so tests can shorten it. |
| 3776 | var storeCallTimeout = 15 * time.Second |
| 3777 | |
| 3778 | // deadlineStore refuses every call once check says the deadline has been |
| 3779 | // reached, before the call reaches the store underneath, and gives each |
| 3780 | // call it issues at most storeCallTimeout. The bound cannot rest on the |
| 3781 | // store alone: release.DirStore ignores its context, so the wrapper |
| 3782 | // returns on its own when the call outlives the timeout or the deadline, |
| 3783 | // whatever the store does. Every outcome it cannot confirm in time is |
| 3784 | // ErrWriteDeadline, unknown: a call cut off in flight, one that never |
| 3785 | // answered, and one that answered, even successfully, only after the |
| 3786 | // deadline. |
| 3787 | type deadlineStore struct { |
| 3788 | release.Store |
| 3789 | check func(context.Context) error |
| 3790 | } |
| 3791 | |
| 3792 | // call runs one store call under the bound. |
| 3793 | func (s deadlineStore) call(ctx context.Context, f func(context.Context) error) error { |
| 3794 | if err := s.check(ctx); err != nil { |
| 3795 | return err |
| 3796 | } |
| 3797 | cctx, cancel := context.WithTimeout(ctx, storeCallTimeout) |
| 3798 | defer cancel() |
| 3799 | done := make(chan error, 1) |
| 3800 | go func() { done <- f(cctx) }() |
| 3801 | var err error |
| 3802 | select { |
| 3803 | case err = <-done: |
| 3804 | case <-cctx.Done(): |
| 3805 | } |
| 3806 | // Cut off, whether the store gave up on the context or not: by the |
| 3807 | // deadline, or by the hard timeout. Whatever it answered, if anything, |
| 3808 | // is not known to be the outcome. |
| 3809 | if cctx.Err() != nil { |
| 3810 | if ctx.Err() != nil { |
| 3811 | return fmt.Errorf("%w: in flight at the deadline", ErrWriteDeadline) |
| 3812 | } |
| 3813 | return fmt.Errorf("%w: the store did not answer in time", ErrWriteDeadline) |
| 3814 | } |
| 3815 | if err == nil { |
| 3816 | if late := s.check(ctx); late != nil { |
| 3817 | return fmt.Errorf("%w: it answered only after the deadline", ErrWriteDeadline) |
| 3818 | } |
| 3819 | } |
| 3820 | return err |
| 3821 | } |
| 3822 | |
| 3823 | func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 3824 | var body []byte |
| 3825 | var etag string |
| 3826 | err := s.call(ctx, func(cctx context.Context) error { |
| 3827 | var err error |
| 3828 | body, etag, err = s.Store.Get(cctx, key) |
| 3829 | return err |
| 3830 | }) |
| 3831 | if err != nil { |
| 3832 | return nil, "", err |
| 3833 | } |
| 3834 | return body, etag, nil |
| 3835 | } |
| 3836 | |
| 3837 | func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| 3838 | var etag string |
| 3839 | err := s.call(ctx, func(cctx context.Context) error { |
| 3840 | var err error |
| 3841 | etag, err = s.Store.Put(cctx, key, body, ifMatch) |
| 3842 | return err |
| 3843 | }) |
| 3844 | if err != nil { |
| 3845 | return "", err |
| 3846 | } |
| 3847 | return etag, nil |
| 3848 | } |
| 3849 | |
| 3850 | func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 3851 | var keys []string |
| 3852 | err := s.call(ctx, func(cctx context.Context) error { |
| 3853 | var err error |
| 3854 | keys, err = s.Store.List(cctx, prefix) |
| 3855 | return err |
| 3856 | }) |
| 3857 | if err != nil { |
| 3858 | return nil, err |
| 3859 | } |
| 3860 | return keys, nil |
| 3861 | } |
| 3862 | |
| 3863 | func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| 3864 | var dirs []string |
| 3865 | err := s.call(ctx, func(cctx context.Context) error { |
| 3866 | var err error |
| 3867 | dirs, err = s.Store.ListDirs(cctx, prefix) |
| 3868 | return err |
| 3869 | }) |
| 3870 | if err != nil { |
| 3871 | return nil, err |
| 3872 | } |
| 3873 | return dirs, nil |
| 3874 | } |
| 3875 | |
| 3876 | func (s deadlineStore) Delete(ctx context.Context, key string) error { |
| 3877 | return s.call(ctx, func(cctx context.Context) error { return s.Store.Delete(cctx, key) }) |
| 3878 | } |
| 3879 | ```` |
| 3880 | |
| 3881 | `internal/hostactors/groupref.go`: |
| 3882 | |
| 3883 | ````go |
| 3884 | package hostactors |
| 3885 | |
| 3886 | import ( |
| 3887 | "crypto/sha256" |
| 3888 | "encoding/hex" |
| 3889 | ) |
| 3890 | |
| 3891 | // GroupBoxReference is the reference of a box its region's group launched |
| 3892 | // (census ruling region-ha-2b): "g-" and the first 20 hex digits of |
| 3893 | // sha256("carlos-box-ref-v1|" + account + "|" + instance). Status labels |
| 3894 | // come from 1,600 values and collide as groups replace their boxes; |
| 3895 | // instance ids never repeat in an account, and 80 bits of their digest |
| 3896 | // make a collision negligible. It is a pseudonym, so the public status |
| 3897 | // feed never shows an instance id. The box derives it to publish under it, |
| 3898 | // and the console derives it again from the instance it proved (the |
| 3899 | // attempt's, then the roster's pinned one) to read and admit it, never |
| 3900 | // trusting the key a box chose. Boxes an operator enrolled keep their |
| 3901 | // labels. |
| 3902 | func GroupBoxReference(account, instance string) string { |
| 3903 | sum := sha256.Sum256([]byte("carlos-box-ref-v1|" + account + "|" + instance)) |
| 3904 | return "g-" + hex.EncodeToString(sum[:])[:20] |
| 3905 | } |
| 3906 | ```` |
| 3907 | |
| 3908 | `internal/hostactors/enrollment_confirmation.go`: |
| 3909 | |
| 3910 | ````diff |
| 3911 | --- a/internal/hostactors/enrollment_confirmation.go |
| 3912 | +++ b/internal/hostactors/enrollment_confirmation.go |
| 3913 | @@ -4,7 +4,6 @@ |
| 3914 | "context" |
| 3915 | "crypto/rand" |
| 3916 | "encoding/hex" |
| 3917 | - "encoding/json" |
| 3918 | "github.com/carlosframework/platform/internal/boxid" |
| 3919 | "github.com/carlosframework/platform/internal/hostfloor" |
| 3920 | "github.com/carlosframework/platform/internal/release" |
| 3921 | @@ -22,97 +21,75 @@ |
| 3922 | // group takes (roles.go): "" (default) or legacy. |
| 3923 | Role string `json:"role,omitempty"` |
| 3924 | } |
| 3925 | -type TerminatedEnrollment struct { |
| 3926 | - PassID string `json:"pass_id"` |
| 3927 | - Box string `json:"box"` |
| 3928 | - Enrollment string `json:"enrollment"` |
| 3929 | - InstanceID string `json:"instance_id"` |
| 3930 | - State string `json:"state"` |
| 3931 | - ObservedAt time.Time `json:"observed_at"` |
| 3932 | - RequestID string `json:"request_id"` |
| 3933 | -} |
| 3934 | -type MembershipConfirmation struct { |
| 3935 | - PassID string `json:"pass_id"` |
| 3936 | - AttemptID string `json:"attempt_id"` |
| 3937 | - ConfirmationID string `json:"confirmation_id"` |
| 3938 | - EvidenceSHA256 string `json:"evidence_sha256"` |
| 3939 | |
| 3940 | - TargetPlacement string `json:"target_placement"` |
| 3941 | - AWSRegion string `json:"aws_region"` |
| 3942 | - GroupName string `json:"group_name"` |
| 3943 | - GroupARN string `json:"group_arn"` |
| 3944 | - InstanceID string `json:"instance_id"` |
| 3945 | - Account string `json:"account"` |
| 3946 | - AvailabilityZoneRegion string `json:"availability_zone_region"` |
| 3947 | - LifecycleState string `json:"lifecycle_state"` |
| 3948 | - InstanceState string `json:"instance_state"` |
| 3949 | - ObservedAt time.Time `json:"observed_at"` |
| 3950 | - RequestIDs []string `json:"request_ids"` |
| 3951 | - Confirmer struct { |
| 3952 | - Box string `json:"box"` |
| 3953 | - Enrollment string `json:"enrollment"` |
| 3954 | - Region string `json:"region"` |
| 3955 | - } `json:"confirmer"` |
| 3956 | - TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"` |
| 3957 | +// EnrollAutomatic admits a box its region's group launched (H16, H28). The |
| 3958 | +// console confirms the box's group membership itself, here: Member is |
| 3959 | +// asked, through r, and every store call of the enrolling write, |
| 3960 | +// checkMembership's reads and Replace's Put included, runs on the store |
| 3961 | +// writeBound binds to that read's deadline, the single enforcement point: |
| 3962 | +// none is issued at or after it. No box writes or contributes membership |
| 3963 | +// evidence, and no caller can hand a confirmation in: membershipread.go |
| 3964 | +// builds it from r's answer alone. The evidence comes back whenever the |
| 3965 | +// cloud answered, so every read can be logged. |
| 3966 | +func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (Observed, MembershipEvidence, error) { |
| 3967 | + pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now) |
| 3968 | + if err != nil { |
| 3969 | + return Observed{}, c.evidence(), err |
| 3970 | + } |
| 3971 | + bd, bctx, cancel := writeBound(ctx, d, c, now) |
| 3972 | + defer cancel() |
| 3973 | + next, err := bd.enroll(bctx, sg, o, v, pinned, raw, true) |
| 3974 | + if err != nil { |
| 3975 | + return Observed{}, c.evidence(), err |
| 3976 | + } |
| 3977 | + // Callers go on with the store itself, not the bound one. |
| 3978 | + return next, c.evidence(), nil |
| 3979 | } |
| 3980 | |
| 3981 | -// Confirmers write this create-only evidence with read-only ASG/EC2 results. |
| 3982 | -// This consumer gives H16/H17 their admission boundary; spec 4 owns producing it. |
| 3983 | -func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, now time.Time) (Observed, error) { |
| 3984 | +// checkConfirmed is EnrollAutomatic's admission check, made without |
| 3985 | +// writing: the attempt in the roster matches the request and the record, |
| 3986 | +// the request's reference is the instance's group reference |
| 3987 | +// (GroupBoxReference), and a describe made now confirms the attempt's |
| 3988 | +// instance is a current member of the region's group. It returns in |
| 3989 | +// pinned to that instance and the confirmation the read built, which |
| 3990 | +// never leaves this package. The console's enrollment pass asks it |
| 3991 | +// immediately before it invites a box, and makes the invitation on the |
| 3992 | +// store writeBound binds to that confirmation (autoenroll.go). |
| 3993 | +func (d Directory) checkConfirmed(ctx context.Context, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, membershipConfirmation, error) { |
| 3994 | if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil { |
| 3995 | - return Observed{}, err |
| 3996 | + return EnrollmentRequest{}, membershipConfirmation{}, err |
| 3997 | } |
| 3998 | region, ok := o.Roster.Regions[in.Region] |
| 3999 | if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference { |
| 4000 | - return Observed{}, ErrRoster |
| 4001 | + return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster |
| 4002 | + } |
| 4003 | + // A group box is enrolled only under the reference derived from its |
| 4004 | + // instance, never one it chose (census ruling region-ha-2b). |
| 4005 | + if in.Reference != GroupBoxReference(region.Account, instance) { |
| 4006 | + return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster |
| 4007 | } |
| 4008 | attempt, ok := o.Roster.EnrollmentAttempts[in.Reference] |
| 4009 | if !ok || attempt.Consumed || !token.MatchString(in.AttemptID) || !token.MatchString(in.ConfirmationID) || in.AttemptID == in.ConfirmationID || attempt.AttemptID != in.AttemptID || attempt.ConfirmationID != in.ConfirmationID || attempt.InstanceID != instance || attempt.EvidenceSHA256 != hostfloor.Digest(raw) || attempt.Region != in.Region || attempt.Account != region.Account || attempt.AWSRegion != region.AWSRegion || attempt.GroupARN != region.GroupARN || attempt.GroupName != region.GroupName { |
| 4010 | - return Observed{}, ErrRoster |
| 4011 | + return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster |
| 4012 | } |
| 4013 | - body, _, err := d.Store.Get(ctx, ConfirmationKey(in.Reference, in.AttemptID, in.ConfirmationID)) |
| 4014 | + c, err := confirmMembership(ctx, r, region, instance, now) |
| 4015 | if err != nil { |
| 4016 | - return Observed{}, err |
| 4017 | + return EnrollmentRequest{}, c, err |
| 4018 | } |
| 4019 | - var c MembershipConfirmation |
| 4020 | - if json.Unmarshal(body, &c) != nil || !segment(c.PassID) || c.AttemptID != attempt.AttemptID || c.ConfirmationID != attempt.ConfirmationID || c.EvidenceSHA256 != attempt.EvidenceSHA256 || c.TargetPlacement != in.Region || c.AWSRegion != region.AWSRegion || c.Account != region.Account || c.GroupARN != region.GroupARN || c.GroupName != region.GroupName || c.InstanceID != instance || c.AvailabilityZoneRegion != region.AWSRegion || (c.LifecycleState != "Pending" && c.LifecycleState != "InService") || (c.InstanceState != "pending" && c.InstanceState != "running") || !freshConfirmation(c.ObservedAt, now) || len(c.RequestIDs) < 2 { |
| 4021 | - return Observed{}, ErrRoster |
| 4022 | - } |
| 4023 | - for _, id := range c.RequestIDs { |
| 4024 | - if id == "" { |
| 4025 | - return Observed{}, ErrRoster |
| 4026 | - } |
| 4027 | - } |
| 4028 | - confirmer, ok := o.Roster.Boxes[c.Confirmer.Box] |
| 4029 | - if !ok || confirmer.State != "active" || confirmer.Enrollment != c.Confirmer.Enrollment || confirmer.Region != c.Confirmer.Region { |
| 4030 | - return Observed{}, ErrRoster |
| 4031 | - } |
| 4032 | - fenced, err := d.TerminationFence(ctx, o, c.Confirmer.Box, now) |
| 4033 | - if err != nil || fenced { |
| 4034 | - return Observed{}, ErrRoster |
| 4035 | - } |
| 4036 | - if confirmer.Region != in.Region { |
| 4037 | - seen := map[string]bool{} |
| 4038 | - for _, read := range c.TerminatedEnrollments { |
| 4039 | - b, ok := o.Roster.Boxes[read.Box] |
| 4040 | - if !ok || read.PassID != c.PassID || seen[read.Box] || b.Region != in.Region || b.Enrollment != read.Enrollment || b.Cloud.InstanceID != read.InstanceID || read.State != "terminated" || read.RequestID == "" || !freshConfirmation(read.ObservedAt, now) || read.ObservedAt.Before(c.ObservedAt.Add(-time.Minute)) || read.ObservedAt.After(c.ObservedAt) { |
| 4041 | - return Observed{}, ErrRoster |
| 4042 | - } |
| 4043 | - seen[read.Box] = true |
| 4044 | - } |
| 4045 | - for ref, b := range o.Roster.Boxes { |
| 4046 | - if b.Region == in.Region && (b.State == "active" || b.State == "quarantined") && !seen[ref] { |
| 4047 | - return Observed{}, ErrRoster |
| 4048 | - } |
| 4049 | - } |
| 4050 | + if err := c.fresh(now()); err != nil { |
| 4051 | + return EnrollmentRequest{}, c, err |
| 4052 | } |
| 4053 | // Pin the verified IMDS identity to the instance that membership proved. |
| 4054 | in.TofuInstanceID = instance |
| 4055 | in.ExistingReference = true |
| 4056 | - return d.enroll(ctx, sg, o, v, in, raw, true) |
| 4057 | -} |
| 4058 | -func freshConfirmation(at, now time.Time) bool { |
| 4059 | - return !at.IsZero() && now.Sub(at) <= time.Minute && !at.After(now.Add(5*time.Second)) |
| 4060 | + return in, c, nil |
| 4061 | } |
| 4062 | |
| 4063 | type EnrollmentAttempt struct { |
| 4064 | @@ -126,10 +103,6 @@ |
| 4065 | GroupName string `json:"group_name"` |
| 4066 | GroupARN string `json:"group_arn"` |
| 4067 | Consumed bool `json:"consumed"` |
| 4068 | -} |
| 4069 | - |
| 4070 | -func ConfirmationKey(ref, attempt, confirmation string) string { |
| 4071 | - return "control/box-enrollment/" + ref + "/" + attempt + "/" + confirmation + ".json" |
| 4072 | } |
| 4073 | |
| 4074 | // BeginEnrollmentAttempt supersedes evidence by CAS in the signed authority. |
| 4075 | ```` |
| 4076 | |
| 4077 | `internal/hostactors/private.go`: |
| 4078 | |
| 4079 | ````diff |
| 4080 | --- a/internal/hostactors/private.go |
| 4081 | +++ b/internal/hostactors/private.go |
| 4082 | @@ -7,9 +7,18 @@ |
| 4083 | "strings" |
| 4084 | ) |
| 4085 | |
| 4086 | +// PrivateBoxReport reads the private status report of box ref. A box its |
| 4087 | +// region's group enrolled (b.Group set) is read under the reference the |
| 4088 | +// console derives from the instance the roster pins, never under the key |
| 4089 | +// the box chose (GroupBoxReference); every other box under ref, as before. |
| 4090 | func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) { |
| 4091 | key := "status/boxes/" + ref + ".json" |
| 4092 | - if strings.HasPrefix(b.Namespace, "fleet:") { |
| 4093 | + if b.Group != "" { |
| 4094 | + if b.Cloud.Account == "" || !segment(b.Cloud.InstanceID) { |
| 4095 | + return Report{}, ErrRoster |
| 4096 | + } |
| 4097 | + key = "status/boxes/" + GroupBoxReference(b.Cloud.Account, b.Cloud.InstanceID) + ".json" |
| 4098 | + } else if strings.HasPrefix(b.Namespace, "fleet:") { |
| 4099 | fleet := strings.TrimPrefix(b.Namespace, "fleet:") |
| 4100 | if !segment(fleet) || !segment(b.Label) { |
| 4101 | return Report{}, ErrRoster |
| 4102 | ```` |
| 4103 | |
| 4104 | `internal/hostactors/awsmembership/awsmembership.go`: |
| 4105 | |
| 4106 | ````go |
| 4107 | // Package awsmembership is the AWS adapter of the console's membership |
| 4108 | // read (region HA spec 2 §5 step 5, §25; H16, H28): read-only Auto Scaling |
| 4109 | // and EC2 describe calls, made in the AWS region the roster names for the |
| 4110 | // attempt's placement. The console role needs |
| 4111 | // autoscaling:DescribeAutoScalingInstances and ec2:DescribeInstances, |
| 4112 | // conditioned on aws:RequestedRegion being one of the deployment's AWS |
| 4113 | // regions. No box role has either. |
| 4114 | package awsmembership |
| 4115 | |
| 4116 | import ( |
| 4117 | "context" |
| 4118 | |
| 4119 | "github.com/aws/aws-sdk-go-v2/aws" |
| 4120 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 4121 | "github.com/aws/aws-sdk-go-v2/config" |
| 4122 | "github.com/aws/aws-sdk-go-v2/service/autoscaling" |
| 4123 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 4124 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 4125 | "github.com/aws/smithy-go/middleware" |
| 4126 | |
| 4127 | "github.com/carlosframework/platform/internal/hostactors" |
| 4128 | ) |
| 4129 | |
| 4130 | // Reader is hostactors.MembershipReader over the console's own credentials. |
| 4131 | type Reader struct{ cfg aws.Config } |
| 4132 | |
| 4133 | // New loads the console's AWS configuration (its instance role). |
| 4134 | func New(ctx context.Context) (*Reader, error) { |
| 4135 | cfg, err := config.LoadDefaultConfig(ctx) |
| 4136 | if err != nil { |
| 4137 | return nil, err |
| 4138 | } |
| 4139 | return &Reader{cfg: cfg}, nil |
| 4140 | } |
| 4141 | |
| 4142 | // Member describes instance's Auto Scaling membership, then its EC2 state |
| 4143 | // and owner: two calls, two request ids. |
| 4144 | func (r *Reader) Member(ctx context.Context, awsRegion, instance string) (hostactors.Membership, error) { |
| 4145 | asg := autoscaling.NewFromConfig(r.cfg, func(o *autoscaling.Options) { o.Region = awsRegion }) |
| 4146 | ao, err := asg.DescribeAutoScalingInstances(ctx, &autoscaling.DescribeAutoScalingInstancesInput{InstanceIds: []string{instance}}) |
| 4147 | if err != nil { |
| 4148 | return hostactors.Membership{}, err |
| 4149 | } |
| 4150 | ec := ec2.NewFromConfig(r.cfg, func(o *ec2.Options) { o.Region = awsRegion }) |
| 4151 | eo, err := ec.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: []ec2types.Filter{{Name: aws.String("instance-id"), Values: []string{instance}}}}) |
| 4152 | if err != nil { |
| 4153 | return hostactors.Membership{}, err |
| 4154 | } |
| 4155 | return membershipFrom(instance, ao, eo), nil |
| 4156 | } |
| 4157 | |
| 4158 | // membershipFrom reads the two replies. Anything but exactly the one |
| 4159 | // instance asked about, in exactly one group, is no membership. |
| 4160 | func membershipFrom(instance string, ao *autoscaling.DescribeAutoScalingInstancesOutput, eo *ec2.DescribeInstancesOutput) hostactors.Membership { |
| 4161 | m := hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), requestID(eo.ResultMetadata)}} |
| 4162 | if len(ao.AutoScalingInstances) == 1 && aws.ToString(ao.AutoScalingInstances[0].InstanceId) == instance { |
| 4163 | d := ao.AutoScalingInstances[0] |
| 4164 | m.GroupName = aws.ToString(d.AutoScalingGroupName) |
| 4165 | m.LifecycleState = aws.ToString(d.LifecycleState) |
| 4166 | m.AvailabilityZoneRegion = zoneRegion(aws.ToString(d.AvailabilityZone)) |
| 4167 | } |
| 4168 | if len(eo.Reservations) == 1 && len(eo.Reservations[0].Instances) == 1 && aws.ToString(eo.Reservations[0].Instances[0].InstanceId) == instance { |
| 4169 | res := eo.Reservations[0] |
| 4170 | m.Owner = aws.ToString(res.OwnerId) |
| 4171 | if st := res.Instances[0].State; st != nil { |
| 4172 | m.InstanceState = string(st.Name) |
| 4173 | } |
| 4174 | } |
| 4175 | return m |
| 4176 | } |
| 4177 | |
| 4178 | // zoneRegion is the region of an availability zone name ("eu-west-1a" is |
| 4179 | // in "eu-west-1"): the name less one trailing letter after a digit. Any |
| 4180 | // other shape (a Local Zone, a zone id) answers "", which matches no |
| 4181 | // region. |
| 4182 | func zoneRegion(az string) string { |
| 4183 | n := len(az) |
| 4184 | if n < 3 || az[n-1] < 'a' || az[n-1] > 'z' || az[n-2] < '0' || az[n-2] > '9' { |
| 4185 | return "" |
| 4186 | } |
| 4187 | return az[:n-1] |
| 4188 | } |
| 4189 | |
| 4190 | func requestID(md middleware.Metadata) string { |
| 4191 | id, _ := awsmiddleware.GetRequestIDMetadata(md) |
| 4192 | return id |
| 4193 | } |
| 4194 | |
| 4195 | var _ hostactors.MembershipReader = (*Reader)(nil) |
| 4196 | ```` |
| 4197 | |
| 4198 | - [ ] **Step 5: Run the tests** |
| 4199 | |
| 4200 | Run: `nice -n 19 go test ./internal/hostactors/... -count=1` |
| 4201 | Expected: PASS, main's tests included. |
| 4202 | |
| 4203 | - [ ] **Step 6: The provider boundary** |
| 4204 | |
| 4205 | `region_ha_boundary_test.go`: |
| 4206 | |
| 4207 | ````diff |
| 4208 | --- a/region_ha_boundary_test.go |
| 4209 | +++ b/region_ha_boundary_test.go |
| 4210 | @@ -35,6 +35,8 @@ |
| 4211 | "internal/hostenv/write_linux.go", |
| 4212 | "internal/hostenv/write_other.go", |
| 4213 | "internal/host/installchannel.go", |
| 4214 | + "internal/hostactors/membershipread.go", |
| 4215 | + "internal/hostactors/groupref.go", |
| 4216 | } |
| 4217 | |
| 4218 | // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| 4219 | @@ -43,6 +45,7 @@ |
| 4220 | "internal/host/awsaddr.go", |
| 4221 | "internal/console/awsregionmetric.go", |
| 4222 | "internal/hostenv/awsssm.go", |
| 4223 | + "internal/hostactors/awsmembership/awsmembership.go", |
| 4224 | } |
| 4225 | |
| 4226 | // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| 4227 | @@ -55,6 +58,7 @@ |
| 4228 | "internal/edgesfile", |
| 4229 | "internal/host", |
| 4230 | "internal/hostactors", |
| 4231 | + "internal/hostactors/awsmembership", |
| 4232 | "internal/hostenv", |
| 4233 | "internal/hostops", |
| 4234 | "internal/lbhealth", |
| 4235 | ```` |
| 4236 | |
| 4237 | Run: `nice -n 19 go test . -run 'TestRegionHA(Core|Adapters|S3)' -count=1` |
| 4238 | Expected: PASS. |
| 4239 | |
| 4240 | - [ ] **Step 7: Census** |
| 4241 | |
| 4242 | No root-read unit is added or changed: the read is the cloud's, through the reader, and `EnrollAutomatic` no longer reads the store itself. Only the two `@coverage` rows move, and `.` for go.mod. The seal stands. The record-write census gains `deadlineStore.Put`, classed nonrecord as the other forwarding stores are: |
| 4243 | |
| 4244 | `internal/provision/plan9_b8_census_test.go`: |
| 4245 | |
| 4246 | ````diff |
| 4247 | --- a/internal/provision/plan9_b8_census_test.go |
| 4248 | +++ b/internal/provision/plan9_b8_census_test.go |
| 4249 | @@ -814,6 +814,11 @@ |
| 4250 | {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"}, |
| 4251 | {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"}, |
| 4252 | {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"}, |
| 4253 | + // Region HA plan 2b (Task 4): the store a membership read binds to its |
| 4254 | + // deadline; it forwards Put, refuses it once the deadline passes, gives |
| 4255 | + // it at most storeCallTimeout, and reports one that answers late as |
| 4256 | + // unknown. |
| 4257 | + {"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "848f2b529d87e34f128dd481f14213e1cfaf8c54555806e6314864ef9a2dbb14"}, |
| 4258 | {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"}, |
| 4259 | {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"}, |
| 4260 | {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"}, |
| 4261 | ```` |
| 4262 | |
| 4263 | `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| 4264 | |
| 4265 | ````diff |
| 4266 | --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 4267 | +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 4268 | @@ -1,10 +1,13 @@ |
| 4269 | # Plan 14 census overlay region-ha-2b (region HA spec 2, plan 2b: docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md), loaded last. RULED by the Plan 14 controller 2026-10-16 as census-ruling-region-ha-2b. |
| 4270 | # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands. |
| 4271 | # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| 4272 | -@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| 4273 | +# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| 4274 | +@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| 4275 | @coverage internal/hostenv a3f3274a86cdf8194afb871301c1ba3289dfbdc9b703dd547178273df151cdaa |
| 4276 | internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| 4277 | internal/hostenv/write_linux.go checkedDir authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): opens one directory under one already held, O_NOFOLLOW|O_DIRECTORY, creating it 0755 first only when asked and missing, and refuses it unless fstat shows a directory owned by this process's user (root, as write requires) that no group or other can write file:Fstat@576,file:Openat@168,file:Openat@372 012257ac0866f3385044785d8abae9c31e61134928c394e22a2aeaef39ec9f3c |
| 4278 | internal/hostenv/write_linux.go writeHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env write (root, Linux): writes /etc/carlos/host.env only through /etc and /etc/carlos held by descriptor and checked by checkedDir; the new file is created 0600, O_EXCL|O_NOFOLLOW, in that directory, synced, renamed over host.env within it, and the directory synced; its read sites open the root and the temporary file file:Open@68,file:Openat@509 1245fc0f819ba73fb45545ffdcf92d5d4941802175e73bac209ab4d4238adff1 |
| 4279 | @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| 4280 | internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, at a fresh box's start-up): its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage and stageCandidate into both protected slots, and refuses a box whose guard is armed or that already runs another release file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| 4281 | +@coverage internal/hostactors f6dbcea46bed878257c5b34f8caeef83e71cc995cae7b3a4ae59339ed0740c68 |
| 4282 | +@coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| 4283 | ```` |
| 4284 | |
| 4285 | Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1` |
| 4286 | Expected: PASS. |
| 4287 | |
| 4288 | - [ ] **Step 8: Commit** |
| 4289 | |
| 4290 | ```bash |
| 4291 | git add internal/hostactors go.mod go.sum region_ha_boundary_test.go internal/provision/plan9_b8_census_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| 4292 | git commit -m "region HA 2b: group membership, read by the console" |
| 4293 | ``` |
| 4294 | |
| 4295 | --- |
| 4296 | |
| 4297 | ### Task 5: The console's automatic enrolment pass |
| 4298 | |
| 4299 | **Files:** |
| 4300 | - Create: `internal/hostactors/autoenroll.go`, `internal/hostactors/autoenroll_test.go` |
| 4301 | - Create: `internal/console/enrollpass.go`, `internal/console/enrollpass_test.go`, `internal/console/awsmembership.go` |
| 4302 | - Modify: `internal/console/hostclaimops.go` (start the pass with region HA on) |
| 4303 | - Modify: `internal/console/claim_authority_census_test.go`, `internal/console/testdata/claim-authority-sites.txt` (R6) |
| 4304 | - Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal) |
| 4305 | |
| 4306 | **Interfaces:** |
| 4307 | - Consumes: `Directory.Read`, `Invite`, `enrollmentNonce`, `BeginEnrollmentAttempt`, `checkConfirmed`, `EnrollAutomatic`, `GroupBoxReference`, `writeBound` and `ErrWriteDeadline` (Task 4), `MembershipReader`, `VerifyReport`, `Evidence.BoxReport`, `RoleName`; `awsmembership.New` (Task 4); `boxid.Load`, `Verifier`, `EmbeddedCerts`; `hostfloor.Read`, `Digest`; the console's `ClaimAuthority` (`Directory`, `Signer`, `Evidence`, `Namespace`). |
| 4308 | - Produces (`hostactors`): `type AutoEnroller struct { Directory; Signer; Evidence; Members MembershipReader; Certs; Namespace, Pool string; Now func() time.Time; Patience time.Duration; Logf func(string, ...any) }`; `func (e *AutoEnroller) EnrollPass(ctx) ([]EnrollStep, error)`; `EnrollStep{Box, Step string}`; `ErrNoFloor`. |
| 4309 | - Produces (`console`): `newAutoEnroller(*hostops.ProductionAuthority, hostactors.MembershipReader) (*hostactors.AutoEnroller, error)`; `runEnrollPass(ctx, e, every, logf)`; `(*Server).startEnrollPass(ctx)`; `consoleMembershipReader(ctx)` (`awsmembership.go`, the adapter); `enrollPassInterval = 10s`, `enrollPatience = 2m`. |
| 4310 | |
| 4311 | For each box whose identity record is published and which the roster does not list, the pass takes the next step its state allows (§5 step 5, A2-1, H28): with no usable attempt, begin one; with one, read the instance's membership itself (`checkConfirmed`) and invite on the directory bound to the confirmation that read returns (`writeBound`), so `Invite`'s own read, its signature's `Put` and any read-back of its own are refused at or after the deadline, a call in flight then is cancelled, and one still unanswered 15 seconds after it began is cut off; once invited, wait for the box's guard-ready report to pass `VerifyReport` (the check `membership.go:40` makes at admission), and then enroll through `EnrollAutomatic`, which reads membership again itself and writes on its own bound directory. The same attempt serves the invitation and the enrolment: the read, not the attempt, carries the freshness, and the attempt's `Consumed` refuses a replay. An invited box is found with `enrollmentNonce`, which reads and verifies the invitation and writes nothing; `Invite` is get-or-create, so it runs only on the bound store after a membership read, and an invitation that vanished between two reads is never minted again without one. Round 3 removed the check the pass made just before `Invite`: the bound store is the single enforcement point, and nothing that waits sits between the describe and the `Invite` call. |
| 4312 | |
| 4313 | A read that fails is unknown: the step names it, and the next pass asks again with the same attempt, whose wait does not grow (#7). So is a write the bound could not confirm (`ErrWriteDeadline`, S1, U2): nothing was issued at or after the deadline, but a call cut off in flight, or one that answered only after the deadline, may have committed, or may still commit later (V1). `TestAWriteTheConsoleGaveUpOnCanCommitLater` documents that exposure: the enrolling `Put` blocks, the pass returns unknown, the instance is detached, the next pass finds the box absent and its fresh describe refuses without writing, then the delayed `Put` commits on the unchanged roster revision, and the pass after that sees the box enrolled and does nothing more. Such an enrolment rests on a membership read that was true when its write was issued: the exposure of an instance detached after enrolment, which spec 3's worker reconciles. An invitation that landed is simply found and the box goes on to its guard-ready report: an invitation grants nothing. |
| 4314 | |
| 4315 | **Unknown enrolments are settled by the roster (U1).** An enrolling write whose outcome is unknown is not remembered and not read back. The next pass reads the signed roster, as every pass does, and that settles it: a box the roster lists is enrolled, and the pass has nothing more to do for it (no step, no describe); one it does not list goes on with the same attempt while that attempt is still valid, and its wait does not grow, so the next read and write are made under the same rules as any other. A box whose instance left its group, but still runs, after its enrolment landed keeps that enrolment until an operator removes it; spec 3's regional worker reconciles roster membership against Auto Scaling group membership, including detached-but-running instances ("Activation gates", note). A terminated instance is kept from hosting and from capacity by main's termination fence, and a stopped one by the stale-edges rule and Task 6's restart hold. Only `ErrNotMember` or `ErrMembershipStale` refuses the attempt: the pass keeps it, asking the cloud nothing more, for `Patience`, then twice that, up to 32 times, before it begins another, so the record of a box the group has already replaced costs one roster write and one describe an hour. An invitation resets the wait. The memory of attempts is the console's own and starts afresh at a restart. Every membership read is logged (`Logf`, S5), with the box's reference, its attempt id, its instance id, both request ids ("none" when the call failed before the cloud answered), the read's time and what came of it: the audit main's roster has no field for. Between the final check and the `Invite` call there is nothing that waits: no I/O, channel, lock or store call, only the construction of the bound directory. |
| 4316 | |
| 4317 | The box's region is the one roster region whose group runs in the record's account and AWS region; the record is then verified with `boxid.Verifier` before anything is written. The record's key must be the group reference of the instance it proves (S3): a record under any other key, one the box chose, is refused before an attempt is begun. The box's report is read under that derived reference too (`Box.Group` and the record's proven `Cloud`, through `PrivateBoxReport`), never under the box's own say. The pass enrolls into the console's namespace and the `default` pool, and the box takes its group's role in the same write (2a). A deployment without a published host floor enrolls nothing (`ErrNoFloor`): no box could arm its guard. The console signs with the root key it already holds; it is the roster's only writer and the only membership confirmer. |
| 4318 | |
| 4319 | The construction of the pass hands this console's roster directory and root signer to `AutoEnroller`, and its invocation runs the pass that invites and enrolls, so both go into the console clai |