| index 5fcc760c3..808e2c08b 100644 |
| --- a/docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md |
| +++ b/docs/superpowers/plans/2026-10-17-region-ha-2b-joining.md |
| @@ -4,11 +4,11 @@ |
| |
| **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. |
| |
| -**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 `CheckConfirmed` make that read themselves, immediately before the write it allows, and build the confirmation inside `hostactors` where no caller can supply one. They stand on main's `BeginEnrollmentAttempt`, `Invite` and `membership.go`, unchanged. 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`). |
| +**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 `CheckConfirmed` make that read themselves and build the confirmation inside `hostactors` where no caller can supply one. The write each read allows runs on a store bound to the read's deadline (`writeBound`), so it is issued no later than 60 seconds after the read. 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 the console derives it again from the instance it proved, never trusting the key the box chose. 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`). |
| |
| **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). |
| |
| -**Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 16**: §5 (boot and enrolment), §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). Plan 2a (`2026-10-16-region-ha-2a-core.md`, built and merged as 6834659c) for format and the seams it left. |
| +**Spec:** `docs/superpowers/specs/2026-10-15-region-ha-two-box-regions-design.md`, **rev 17**: §5 (boot and enrolment, the write deadline, group references and the activation gates), §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 on `statusLoop`. Plan 2a (`2026-10-16-region-ha-2a-core.md`, built and merged as 6834659c) for format and the seams it left. |
| |
| **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): |
| |
| @@ -16,13 +16,23 @@ |
| |---|---| |
| | 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) | |
| | 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) | |
| -| 3 (P2): 1,600 labels collide | **Not built: blocked by a legacy-sealed unit.** A hosting box publishes the report the console reads under its status label from `statusLoop` (accepted-legacy), so a reference outside the label space needs that unit changed. The obstacle and the smallest change are Open question 4, for platform-1e and Paul | |
| +| 3 (P2): 1,600 labels collide | Deferred in round 1 (a legacy-sealed unit); built in round 2 under platform-1e's ruling (below) | |
| | 4 (P2): racing seeds overwrite shared values | Shared values are written create-only and read back on a conflict (Task 2) | |
| | 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 | |
| | 6 (P2): the console's new signing path escapes its census | Its construction, start and invocation are in the claim-authority census (Task 5) | |
| | 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) | |
| | 8 (P3): the join units' descriptions have no copy rows | RHB-C62 and RHB-C63 (Tasks 1, 7) | |
| |
| +**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): |
| + |
| +| Finding | What changed | |
| +|---|---| |
| +| 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) | |
| +| 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") | |
| +| 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) | |
| +| 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) | |
| +| 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) | |
| + |
| ## Scope |
| |
| Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the others: |
| @@ -42,39 +52,66 @@ Spec 2 is four plans. This is **2b**. What 2a left it, and what it leaves the ot |
| - **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. |
| - **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`). For a box its region's group launched (the roster entry names a `Group`), a fourth: 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. |
| - **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. |
| -- **Box references.** A box's reference is still its status label (`metricsBoxLabel`), as on main. Review round 1's finding 3 (labels collide as groups replace boxes) is not built: it needs a legacy-sealed unit changed (Open question 4). |
| +- **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. |
| + - **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` passes it as its label override (one branch, re-sealed); the joining report is written under it. An identity IMDS has not given is no reference, never a label in its place. |
| + - **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. |
| + - **Every other box**, fleet boxes included, keeps exactly today's label and status key. |
| - **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. |
| - **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). |
| - **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`), 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. 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. |
| - **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/`. |
| - `EnrollAutomatic` and `CheckConfirmed` take a `MembershipReader` (the console's AWS client) and call `Member` themselves. 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. |
| - 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. |
| - - **Freshness bound: 60 seconds.** A fresh describe runs immediately before each write, the invitation and the enrolment. A read more than 60 seconds old at the moment of the write is refused (`ErrMembershipStale`): for the enrolment inside `enroll`, immediately before the roster CAS; for the invitation, immediately before `Invite` (main's, unchanged), which writes on its next store call. The read's time is taken before the call, so its age is never understated. |
| - - **Audit.** 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 both request ids, with the read's time, at each invitation and enrolment (`AutoEnroller.Logf`). |
| + - **Freshness bound: 60 seconds, at the write (S1).** A fresh describe runs immediately before each write, the invitation and the enrolment. **The write is issued no later than 60 s after the membership read; a write whose result is lost to the deadline is unknown and is read back next pass.** The read's time is taken before the call, so its age is never understated, and it sets the write's deadline. `writeBound` gives the sealed writers (`Invite`, and `Replace` with the `checkMembership` it makes) a copy of the directory whose store refuses every call once the deadline has passed, by the read's clock or by the context's, and a context that ends at the deadline, so a call in flight is cut off: no store read, signing step's `Put` or read-back can be issued after the bound. The store must be wrapped, not trusted: `release.S3Store` passes its context to the AWS SDK, which honours the deadline, but `release.DirStore` ignores its context. Past the deadline the write fails with `ErrWriteDeadline`, which is unknown: the attempt is kept, its wait does not grow, and the next pass describes again. A describe that itself took over a minute is `ErrMembershipStale` and counts against the attempt. The checks just before `enroll`'s roster write and just before `Invite` stay, as the same bound made once more; nothing that waits (no I/O, channel, lock or store call) sits between the check and the call. |
| + - **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`). |
| - **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. |
| - **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. |
| -- **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. Every directory it creates under the data dir is handed to the service user with bootstrap's own chown (`bootstrapOwner`, `bootstrapChownTree`), so the agent's next update can download and ask. It refuses an armed guard, a box that already runs another release, a missing floor, and any read that fails. |
| -- **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. `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. |
| +- **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. |
| +- **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. `write` adds `CARLOS_BOX_REFERENCE=group`, which is never a parameter. `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. |
| - **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. |
| - **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. |
| - **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. |
| - **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`. |
| - **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`). |
| |
| +## Activation gates |
| + |
| +2b ships dormant. Automatic enrolment must not be switched on for any region until both hold: |
| + |
| +1. **Group references have shipped** (Astra round 2, #3). A release with all of 2b, Tasks 2, 4, 5, 6 and 8 in particular, 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. |
| +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. |
| + |
| ## Census items for platform-1e (census ruling region-ha-2b) |
| |
| -2b asks for one ruling, region-ha-2b, covering these rows. **No legacy-sealed unit is changed**: `runAgent`, `edgesFileLoop`, `ExecutorChooser.eligible`, `stageCandidate`, `Directory.Invite`, `Directory.BeginEnrollmentAttempt`, `Directory.checkMembership`, `readIdentityRecord`, `status.CollectBox` and `RunSystemGuardArm` are called, never edited. The gate sits in `RunAgent`, which is not a census unit, so `runAgent` keeps its digest. 2b also changes code that no census selects: `Directory.EnrollAutomatic` and `Directory.enroll` (Task 4). |
| +2b asks for one ruling, region-ha-2b, covering every unit it adds or changes, in the three censuses and outside them. Ready to send to platform-1e as it stands. **One legacy-sealed unit changes, under platform-1e's approval of 2026-10-16: `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. |
| + |
| +**Census rows.** |
| + |
| +| Task | Census | Unit | Proposed class | Basis and change | Seal | |
| +|---|---|---|---|---|---| |
| +| 2 | root-read | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New. Its one read site is `seed`'s `--from` file, an operator's host.env whose values go only to the parameter store. `write` runs as root, reads no file, and writes `/etc/carlos/host.env` 0600 from the parameters | stands | |
| +| 3 | root-read | `internal/host/installchannel.go:channelInstall.run` | authority, F,J4 (as `RunSystemGuardArm`) | New. Its one read site opens root's apply lock (`systemApplier.lockPath`); the install itself goes through `verifyPointer`, `hostfloor.CheckImage` and `stageCandidate`, unchanged | stands | |
| +| 4 | record-write (B8) | `internal/hostactors/membershipread.go:deadlineStore.Put` | nonrecord (`def:Put,sel:Put`, as `selectionStore.Put`) | New. The store a membership read binds to its deadline: it forwards `Put` to the deployment store, and refuses it once the deadline has passed. It names no record key | n/a | |
| +| 5 | root-read | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New. Its two read sites are the signed roster and the host floor (`hostfloor.Read`), in the console, feeding the enrolling roster write | moves | |
| +| 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, as 2a's `capacityPublisherFromEnv` site | New. The construction hands this console's roster directory and root signer to `AutoEnroller`; the start and the invocation run the pass that invites and enrolls. `newAutoEnroller`, `runEnrollPass` and `EnrollPass` join the census's selected names, and the three lines are appended to `claim-authority-sites.txt` | n/a | |
| +| 6 | root-read | `internal/host/agent.go:statusLoop` | accepted-legacy, re-sealed: `accepted-legacy;rev3=census-ruling-region-ha-2b` (was `rev3=J1;basis=root-route`) | Changed, as ruled: one branch before the fleet label. A box whose host.env says `CARLOS_BOX_REFERENCE=group` publishes its status report under its group reference (`groupStatusLabel`), the key the console derives; every other box, fleet boxes included, takes the old branches with today's label and key. Read sites unchanged, offsets moved; new kinds and digest in the overlay | moves | |
| +| 7 | root-read | `internal/host/units/carlos-system-join.path` and `carlos-system-join.service` (`external`) | authority, F,J4 (as `carlos-system-update.path` and `.service`) | New root inputs: the path unit starts the service when the joining agent touches `/data/system-join-requested`; the service runs `carlos system join` as root, which takes nothing from the marker | stands | |
| +| 9 | root-read | `system.go:runSystem`, `system.go:systemVerb` | authority, B,C,D,F,J4 (seed overlay, kept) | Re-digest: three verbs added to the list and the dispatch; class, task, basis and (empty) kinds unchanged, no root read added | stands | |
| |
| -| Task | Unit | Proposed class | Change | Seal | |
| -|---|---|---|---|---| |
| -| 2 | `internal/hostenv/cli.go:runHostEnv` | authority, A,J4 | New. Its one read site is `seed`'s `--from` file, an operator's host.env whose values go only to the parameter store. `write` runs as root, reads no file, and writes `/etc/carlos/host.env` 0600 from the parameters | stands | |
| -| 3 | `internal/host/installchannel.go:channelInstall.run` | authority, F,J4 (as `RunSystemGuardArm`) | New. Its one read site opens root's apply lock (`systemApplier.lockPath`); the install itself goes through `verifyPointer`, `hostfloor.CheckImage` and `stageCandidate`, unchanged | stands | |
| -| 5 | `internal/hostactors/autoenroll.go:AutoEnroller.inputs` | accepted-legacy, J4 root-input | New. Its two read sites are the signed roster and the host floor (`hostfloor.Read`), in the console, feeding the enrolling roster write | moves | |
| -| 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 (the console claim-authority inventory), as 2a's `capacityPublisherFromEnv` site | New. The construction hands this console's roster directory and root signer to `AutoEnroller`; the start and the invocation run the pass that invites and enrolls. `newAutoEnroller`, `runEnrollPass` and `EnrollPass` join the census's selected names, and the three lines are appended to `claim-authority-sites.txt` | n/a | |
| -| 7 | `internal/host/units/carlos-system-join.path` and `carlos-system-join.service` (`external`) | authority, F,J4 (as `carlos-system-update.path` and `.service`) | New root inputs: the path unit starts the service when the joining agent touches `/data/system-join-requested`; the service runs `carlos system join` as root, which takes nothing from the marker | stands | |
| -| 9 | `system.go:runSystem`, `system.go:systemVerb` | authority, B,C,D,F,J4 (seed overlay, kept) | Re-digest: three verbs added to the list and the dispatch; class, task, basis and (empty) kinds unchanged, no root read added | stands | |
| +**Changed code no census selects**, listed so the ruling covers everything 2b changes: |
| |
| -The rows are written as `rev3=census-ruling-region-ha-2b` proposals. If platform-1e classes a row differently, change its class, task and basis in the overlay (and, for a legacy row, its `@legacy` fingerprint and the seal); its kinds and digest stay. Tasks 4, 6 and 8 add no unit: their rows are `@coverage` only. **Record-write census (B8): no change.** No unit names `Put`: invitations are 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`. |
| +| Task | Unit | Change | Why no census row | |
| +|---|---|---|---| |
| +| 2 | `internal/hostenv` (`Fetch`, `Assemble`, `Seed`, `sameShared`) | The group-box marker; create-only shared values | No root-read site; `SetNew` and `Set` are not `Put` | |
| +| 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); `Fchown` and `Mkdir` are not read sites | |
| +| 4 | `Directory.EnrollAutomatic`, `CheckConfirmed`, `checkConfirmed`, `Directory.enroll`, `confirmMembership`, `writeBound`, `deadlineStore`'s other methods | The console's own read; the write bound; the group-reference check | Not root-read units; `deadlineStore.Put` is the B8 row above | |
| +| 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, which now never read a group box's report under a key the box chose; non-group boxes unchanged | |
| +| 4 | `GroupBoxReference` (`groupref.go`) | New: the reference function | No I/O | |
| +| 5 | `AutoEnroller.EnrollPass`, `step`, `confirmAndWrite`, `logRead` | The pass, its write bound, its reference check and its log lines | Not root-read units; its construction and invocation are the claim-authority sites above | |
| +| 6 | `metricsBoxLabel`, `boxRecordLoop`, `productionModeFacts`, `groupReferenced`, `groupBoxReference`, `groupStatusLabel` | A group box names itself by its group reference; the record loop retries until it is read | Not census units; `readIdentityRecord` is called, unchanged | |
| +| 8 | `productionJoining`'s report | The guard-ready report under the group reference | Not a census unit | |
| + |
| +The rows are written as `rev3=census-ruling-region-ha-2b` proposals. If platform-1e classes a row differently, change its class, task and basis in the overlay (and, for a legacy row, its `@legacy` fingerprint and the seal); its kinds and digest stay. **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`. |
| |
| **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. |
| |
| @@ -129,19 +166,21 @@ nice -n 19 go test ./internal/console/ -run 'TestClaimAuthoritySourceCensus' -co |
| 3. **The health answer must drop when the claim worker stops.** Task 8's `TestClaimWiringHostingFollowsTheModeAndTheWorker`. |
| 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. |
| 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. |
| -6. **A read is fresh at its write (C2).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a slow describe, and a read that goes stale between the check and the roster CAS) and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`. |
| +6. **No write is issued more than 60 seconds after its read (C2, S1).** Task 4's `TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite` (a slow describe, and a read that goes stale before the roster CAS), and Task 5's `TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`, `TestASlowReadInsideInviteWritesNothing` and `TestASlowReadInsideTheRosterCheckWritesNothing`: a bucket read inside the sealed `Invite`, or inside `checkMembership`, that answers after the deadline leaves no `Put` issued, keeps the attempt and its wait, and the next pass writes. |
| 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. |
| 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). |
| 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. |
| 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. |
| 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`. |
| 12. **A group box started again never hosts on its old enrolment (C3).** Task 6's `TestSelectModeHoldsAGroupBoxStartedAgain` and `TestRelaunchedComparesTheLaunchTheRosterBinds`. |
| +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`). |
| +14. **Ownership is reconciled on a retry (S2).** Task 3's `TestInstallChannelReconcilesOwnershipOnARetry` and `TestInstallChannelRefusesASymlinkedParent`. |
| |
| ## Dry run |
| |
| Every code block below was written into this branch on main `6834659c` (plan 2a merged), built for linux, darwin and windows, vetted for linux and darwin, 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 (19 after Task 2, 64 after Task 9). Through `go test -overlay`, with a scratch record that marks every row approved, the applied check passes for all 64 rows, and fails for a row whose text was edited. |
| |
| -Round 1's revision was dry-run the same way. Eighteen mutations were run through `go test -overlay` from `$TMPDIR`, never editing source in place, and each was caught by the test named: the membership read without the group check, and `EnrollAutomatic` without its own read (`TestEnrollAutomaticRefusesANonMember`, `TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled`); `enroll` without the freshness check before the roster write (this first survived, because the slow-describe case was already refused by `CheckConfirmed`'s own check; the test now also runs a clock that ticks 31 seconds a look, so the read is fresh at the check and stale at the write); the pass inviting without its freshness check (`TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite`); a failed read counted against the attempt, or reported as a non-member (`TestAMembershipReadThatFailsKeepsTheAttempt`, `TestAMembershipReadThatIsNotAnAnswerIsUnknown`); no backoff; enrolment without the guard-ready report; seed writing shared values with overwrite, or not reading back a value another seed set (`TestSeedRacesForASharedValueAndOnlyOneWins`); install-channel not handing its directories to the service user; mode selection without the relaunch hold, or `relaunched` ignoring `pendingTime`; and joining marking the box hosting. The claim-authority census reads source with `go/parser`, which an overlay does not reach, so 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 with the file unmutated it stayed green. `internal/hostenv` and `internal/hostactors/...` passed in full on the final tree, and the console's enrolment, capacity, no-capacity and authority-census tests on their own. The code was then removed, leaving the plan, its copy record and the spec. |
| +Rounds 1 and 2 were dry-run the same way, round 2 from a clean base again for every task. Round 2's mutations were all run through `go test -overlay` from `$TMPDIR`, never editing source in place, and each was caught by the test named. The sixteen from round 1 still are: 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; the relaunch hold, or `relaunched` ignoring `pendingTime`; joining marking the box hosting. Round 2 added: the bound store never refusing (`TestASlowReadInsideInviteWritesNothing`, `TestASlowReadInsideTheRosterCheckWritesNothing`); `Invite` run on the unbound directory, and `enroll` run on it; a missed deadline counted against the attempt; install-channel handing over only the directories it created (`TestInstallChannelReconcilesOwnershipOnARetry`), or following a symlinked parent (`TestInstallChannelRefusesASymlinkedParent`); `groupStatusLabel` off, `metricsBoxLabel` without its group branch, and every box taken for a group box (`TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey`); `PrivateBoxReport` reading the box's own key; the pass and `checkConfirmed` each trusting a chosen key; the reference without the account (`TestGroupBoxReferencesDoNotCollide`); `Fetch` without the marker; a log line without the attempt id; and the pass reading the report under another key. The check just before `enroll`'s roster write, and the one just before `Invite`, each survive when removed alone, because the bound store refuses the same write; removing each together with the bound is caught. The claim-authority census reads source with `go/parser`, which an overlay does not reach, so 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. `internal/hostenv` and `internal/hostactors/...` passed in full on the final tree, and the console's enrolment, capacity, no-capacity and authority-census tests on their own. The code was then removed, leaving the plan, its copy record and the spec. |
| |
| --- |
| |
| @@ -287,7 +326,7 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference. |
| | 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 | |
| | 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 | |
| | 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 | |
| -| RHB-C61 | docs/site/cli.md | Seeding needs ssm:PutParameter and kms:Encrypt on the deployment's configuration key. | pending | |
| +| 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 | |
| ```` |
| |
| - [ ] **Step 2: Write the gate** |
| @@ -395,7 +434,7 @@ No package envelope changes: neither file is a non-test Go file. |
| |
| **Interfaces:** |
| - Consumes: `hostfloor.Durable` (the guard's whole-or-nothing write). |
| -- Produces (`internal/hostenv`): `const Path = "/etc/carlos/host.env"`, `const SharedScope = "shared"`; `var Required, SharedKeys []string`; `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). |
| +- 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, SharedKeys []string`; `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). |
| - 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`. |
| - Produces: the census overlay every later task edits, and its load in `TestPlan14RootReadCensus`. |
| |
| @@ -405,6 +444,8 @@ The core treats host.env as a file and names no provider (§25): `Fetch` assembl |
| |
| 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. |
| |
| +`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. |
| + |
| - [ ] **Step 1: Write the failing tests** |
| |
| `internal/hostenv/hostenv_test.go`: |
| @@ -419,6 +460,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "slices" |
| "strings" |
| "testing" |
| ) |
| @@ -510,10 +552,14 @@ func TestSeedThenFetchIsTodaysFile(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if !reflect.DeepEqual(got, today()) { |
| - t.Fatalf("round trip:\n got %v\nwant %v", got, today()) |
| + // A box write assembles host.env for is one its group launched: it |
| + // takes its group reference (BoxReference), which is never a parameter. |
| + want := today() |
| + want[BoxReference] = GroupReference |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("round trip:\n got %v\nwant %v", got, want) |
| } |
| - if !bytes.HasPrefix(body, []byte("# ")) || !bytes.Contains(body, []byte("\nCARLOS_ALERT_SNS_TOPIC_ARN=arn:aws:sns:eu-west-1:123456789012:carlos-alerts\nCARLOS_DEPLOYMENT_BUCKET=deploy-bucket\n")) { |
| + 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")) { |
| t.Fatalf("not sorted KEY=value lines:\n%s", body) |
| } |
| } |
| @@ -595,6 +641,13 @@ func TestSeedNeverChangesASharedValue(t *testing.T) { |
| if _, err := Seed(ctx, p, "flagship", "au", au); !errors.Is(err, ErrConflict) || len(p.sets) != 0 { |
| t.Fatalf("conflicting shared value: %v, wrote %v", err, p.sets) |
| } |
| + // A group box's own host.env seeds no BoxReference. |
| + grp := today() |
| + grp[BoxReference] = GroupReference |
| + p.sets = nil |
| + if _, err := Seed(ctx, p, "flagship", "ie", grp); err != nil || slices.ContainsFunc(p.sets, func(n string) bool { return strings.HasSuffix(n, BoxReference) }) { |
| + t.Fatalf("seeded %v: %v", p.sets, err) |
| + } |
| // An incomplete file writes nothing either. |
| partial := today() |
| delete(partial, "CARLOS_REGION_HA") |
| @@ -804,6 +857,17 @@ var SharedKeys = []string{ |
| "CARLOS_SECRETS_KEY", |
| } |
| |
| +// BoxReference and GroupReference are the one line host.env carries that |
| +// is not a parameter. write runs only in the start-up of a box its |
| +// region's group launched, so a host.env it wrote marks such a box, and |
| +// the box takes the reference its account and instance derive |
| +// (hostactors.GroupBoxReference) instead of a status label. Every other |
| +// box's host.env lacks it and keeps its label. |
| +const ( |
| + BoxReference = "CARLOS_BOX_REFERENCE" |
| + GroupReference = "group" |
| +) |
| + |
| // Params is a store of named, encrypted parameters. |
| type Params interface { |
| // ByPath returns every parameter directly under prefix, keyed by its |
| @@ -858,7 +922,7 @@ func Assemble(shared, regional map[string]string) (map[string]string, error) { |
| var both []string |
| for _, src := range []map[string]string{shared, regional} { |
| for k, v := range src { |
| - if !ValidKey(k) { |
| + if !ValidKey(k) || k == BoxReference { |
| continue |
| } |
| if _, dup := env[k]; dup { |
| @@ -967,7 +1031,8 @@ func Split(env map[string]string) (shared, regional map[string]string) { |
| return shared, regional |
| } |
| |
| -// Fetch assembles host.env for one region of a deployment from p. |
| +// Fetch assembles host.env for one region of a deployment from p, for a |
| +// box the region's group launched: it adds BoxReference. |
| func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, error) { |
| if region == SharedScope { |
| return nil, fmt.Errorf("%w: %q is not a region", ErrValue, region) |
| @@ -992,6 +1057,7 @@ func Fetch(ctx context.Context, p Params, deployment, region string) ([]byte, er |
| if err != nil { |
| return nil, err |
| } |
| + env[BoxReference] = GroupReference |
| return Render(env) |
| } |
| |
| @@ -1009,7 +1075,8 @@ type Seeded struct { |
| // written. Two seeds racing for the same shared variable cannot both |
| // win: the one SetNew refuses reads the value back and goes on only if |
| // it is the value it would have written. env must hold every Required |
| -// variable, in a form Render accepts. |
| +// variable, in a form Render accepts. BoxReference is never seeded: it |
| +// is write's. |
| func Seed(ctx context.Context, p Params, deployment, region string, env map[string]string) (Seeded, error) { |
| if region == SharedScope { |
| return Seeded{}, fmt.Errorf("%w: %q is not a region", ErrValue, region) |
| @@ -1029,6 +1096,7 @@ func Seed(ctx context.Context, p Params, deployment, region string, env map[stri |
| return Seeded{}, err |
| } |
| shared, regional := Split(env) |
| + delete(regional, BoxReference) |
| existing, err := p.ByPath(ctx, sharedPrefix) |
| if err != nil { |
| return Seeded{}, fmt.Errorf("parameters under %s: %w", sharedPrefix, err) |
| @@ -1396,9 +1464,9 @@ Create the overlay and load it last. `runHostEnv` is a new root-read unit (its ` |
| |
| ````text |
| # 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. |
| -# Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| +# Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| -@coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| +@coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| ```` |
| |
| @@ -1442,18 +1510,18 @@ git commit -m "region HA 2b: host.env from the deployment's parameters" |
| ### Task 3: `carlos system install-channel` |
| |
| **Files:** |
| -- Create: `internal/host/installchannel.go`, `internal/host/installchannel_test.go` |
| +- Create: `internal/host/installchannel.go`, `internal/host/installchannel_linux.go`, `internal/host/installchannel_other.go`, `internal/host/installchannel_test.go` |
| - Modify: `region_ha_boundary_test.go`, `approved_region_ha_2b_copy_test.go`, the overlay |
| |
| **Interfaces:** |
| -- Consumes: `systemApplier.verifyPointer` and `stageCandidate` (unchanged), `systemCacheHolds`, `copyFileDurable`, `writeJSONDurable`, `readSystemApplied`, `readSystemSeen`, `flockExclusiveNonblock`, `systemGuardDir`, `systemCachePath`, `systemCacheParts`, `systemArtifactLimit`; bootstrap's `bootstrapOwner`, `bootstrapChownTree` and `serviceUser`; the agent's `systemLeg.fetch` and `writeRequest` (in the test); `hostfloor.Verify`, `CheckImage`, `Durable`, `Digest`; `release.FetchToFile`, `IdentityKey`. |
| -- 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`; `channelInstalled`; `installedLine`. |
| +- 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`. |
| +- 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`. |
| |
| 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. |
| |
| 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). |
| |
| -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`), so it creates that path one directory at a time, as the agent would, and hands the topmost directory it created to the service user with bootstrap's own ownership step (`bootstrapOwner` and `bootstrapChownTree`, `privilegedPlan`'s `chown -R carlos:carlos`). 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 (R2). The test's `own` seam records the call; the test then runs the agent's own `systemLeg.fetch` and `writeRequest` for the next release in what install-channel left. |
| +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. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -1590,10 +1658,10 @@ func TestInstallChannelPutsTheChannelReleaseInBothSlots(t *testing.T) { |
| if !f.called("run carlos version") { |
| t.Fatalf("calls %v", f.calls) |
| } |
| - // R2: the cache's parents install-channel created are handed to the |
| - // service user, the topmost once, so the agent's own update can |
| + // R2, S2: every directory on the cache's path is handed to the |
| + // service user, and nothing else, so the agent's own update can |
| // download the next release beside them and ask for it. |
| - if want := []string{filepath.Join(f.a.dataDir, "system")}; !reflect.DeepEqual(f.owned, want) { |
| + if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) { |
| t.Fatalf("handed to the service user: %v, want %v", f.owned, want) |
| } |
| // Run again: nothing to do, nothing fetched. |
| @@ -1621,6 +1689,60 @@ func TestInstallChannelPutsTheChannelReleaseInBothSlots(t *testing.T) { |
| } |
| } |
| |
| +// cachePathDirs are the directories install-channel hands the service |
| +// user for version: system, system/router and the version's own. |
| +func cachePathDirs(dataDir, version string) []string { |
| + return []string{filepath.Join(dataDir, "system"), filepath.Join(dataDir, "system", "router"), filepath.Join(dataDir, "system", "router", version)} |
| +} |
| + |
| +// S2: a run whose ownership handoff fails installs nothing; the next run |
| +// reconciles every directory on the path, including the ones the failed |
| +// run created, not only the ones it creates itself. |
| +func TestInstallChannelReconcilesOwnershipOnARetry(t *testing.T) { |
| + f := newChannelFixture(t, "v1.2.0") |
| + own := f.in.own |
| + f.in.own = func(string) error { return errors.New("chown: operation not permitted") } |
| + if _, err := f.in.run(context.Background()); err == nil { |
| + t.Fatal("a failed handoff installed") |
| + } |
| + if cur, prev := f.slots(t); cur != nil || prev != nil || f.fetches != 0 { |
| + t.Fatalf("a failed handoff wrote %q %q after %d fetches", cur, prev, f.fetches) |
| + } |
| + // The failed run created system, as root, and never handed it over. |
| + if _, err := os.Stat(filepath.Join(f.a.dataDir, "system")); err != nil { |
| + t.Fatalf("the failed run's parent: %v", err) |
| + } |
| + f.in.own = own |
| + if _, err := f.in.run(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := cachePathDirs(f.a.dataDir, "v1.4.0"); !reflect.DeepEqual(f.owned, want) { |
| + t.Fatalf("the retry handed %v, want %v", f.owned, want) |
| + } |
| +} |
| + |
| +// S2: a symlinked parent is refused before anything is fetched, and never |
| +// followed; the real handoff opens each directory without following one. |
| +func TestInstallChannelRefusesASymlinkedParent(t *testing.T) { |
| + f := newChannelFixture(t, "v1.2.0") |
| + elsewhere := t.TempDir() |
| + if err := os.Symlink(elsewhere, filepath.Join(f.a.dataDir, "system")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.in.run(context.Background()); err == nil || f.fetches != 0 || len(f.owned) != 0 { |
| + t.Fatalf("symlinked parent: %v, %d fetches, owned %v", err, f.fetches, f.owned) |
| + } |
| + if ents, _ := os.ReadDir(elsewhere); len(ents) != 0 { |
| + t.Fatalf("followed the symlink: %v", ents) |
| + } |
| + if err := ownDir(filepath.Join(f.a.dataDir, "system"), os.Getuid(), os.Getgid()); err == nil { |
| + t.Fatal("the handoff followed a symlink") |
| + } |
| + if err := ownDir(elsewhere, os.Getuid(), os.Getgid()); err != nil { |
| + t.Fatalf("the handoff of a real directory: %v", err) |
| + } |
| +} |
| + |
| func TestInstallChannelRefusesBelowTheFloorAndWithoutOne(t *testing.T) { |
| for _, c := range []struct{ name, floor, want string }{ |
| {"below the floor", "v2.0.0", "below the host floor v2.0.0"}, |
| @@ -1947,46 +2069,85 @@ func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointe |
| return wire, nil |
| } |
| |
| -// cacheDir makes the agent's cache entry for version, one directory at a |
| -// time below the data dir as the agent would, and hands the topmost one it |
| -// created to the service user, as bootstrap's chown of /data does |
| -// (privilegedPlan): the agent (User=carlos) later downloads releases |
| -// beside it and writes its update requests there. Only the entry itself |
| -// is removed after the install; its parents stay, owned by the agent. |
| +// cacheDir makes the agent's cache entry for version and reconciles the |
| +// ownership of every directory on its path below the data dir, on every |
| +// run: system, system/router and system/router/<version> are created as |
| +// bootstrap creates directories (bootstrapMakeDir: each component opened |
| +// with no symlink followed, so a symlinked parent is refused) and each is |
| +// handed to the service user, as bootstrap's chown of /data does |
| +// (privilegedPlan), whether this run created it or an earlier run that |
| +// failed before its handoff did. Nothing else is chowned. The agent |
| +// (User=carlos) later downloads releases beside the entry and writes its |
| +// update requests there. Only the entry itself is removed after the |
| +// install; its parents stay, owned by the agent. |
| func (in channelInstall) cacheDir(version string) error { |
| parts := systemCacheParts(version) |
| - top, dir := "", in.a.dataDir |
| + dir := in.a.dataDir |
| for _, part := range parts[:len(parts)-1] { |
| dir = filepath.Join(dir, part) |
| - err := os.Mkdir(dir, 0o755) |
| - if errors.Is(err, os.ErrExist) { |
| - continue |
| - } |
| - if err != nil { |
| + if err := bootstrapMakeDir(dir, 0o755); err != nil { |
| return err |
| } |
| - if top == "" { |
| - top = dir |
| + if err := in.own(dir); err != nil { |
| + return err |
| } |
| } |
| - if top == "" { |
| - return nil |
| - } |
| - return in.own(top) |
| + return nil |
| } |
| +```` |
| |
| -// ownByService is bootstrap's ownership step for one tree: the service |
| -// user and its group, through the same descriptor walk |
| -// (bootstrapChownTree). |
| +`internal/host/installchannel_linux.go`: |
| + |
| +````go |
| +//go:build linux |
| + |
| +package host |
| + |
| +import "golang.org/x/sys/unix" |
| + |
| +// ownByService hands one directory, and nothing beneath it, to the service |
| +// user and its group, as bootstrap's ownership step does |
| +// (bootstrapOwner). It opens the directory as bootstrap opens one |
| +// (bootstrapOpenDir: no symlink anywhere on the path, not a symlink |
| +// itself) and changes the open descriptor, so a path swapped for a |
| +// symlink is refused, never followed. |
| func ownByService(path string) error { |
| uid, gid, err := bootstrapOwner(serviceUser, serviceUser) |
| if err != nil { |
| return err |
| } |
| - return bootstrapChownTree(path, uid, gid) |
| + return ownDir(path, uid, gid) |
| +} |
| + |
| +// ownDir is ownByService for a given owner. |
| +func ownDir(path string, uid, gid int) error { |
| + fd, err := bootstrapOpenDir(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(fd) |
| + return unix.Fchown(fd, uid, gid) |
| } |
| ```` |
| |
| +`internal/host/installchannel_other.go`: |
| + |
| +````go |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import "errors" |
| + |
| +// errOwnNotLinux is every ownership handoff off Linux: carlos system |
| +// install-channel runs only on a Linux box. |
| +var errOwnNotLinux = errors.New("install-channel's ownership handoff requires Linux") |
| + |
| +func ownByService(path string) error { return errOwnNotLinux } |
| + |
| +func ownDir(path string, uid, gid int) error { return errOwnNotLinux } |
| +```` |
| + |
| - [ ] **Step 4: Run the tests** |
| |
| Run: `nice -n 19 go test ./internal/host/ -run TestInstallChannel -count=1` |
| @@ -2046,12 +2207,12 @@ Expected: PASS once the rows are approved. While any built row is pending, `Test |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -1,5 +1,8 @@ |
| # 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. |
| - # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| -+# Task 3: carlos system install-channel (internal/host installchannel.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and hands the cache directories it creates under the data dir to the service user, as bootstrap does. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| + # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| ++# 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). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| @coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| -+@coverage internal/host f3b2fc63654dbe9e41ec940bce86acae3c39ced0258d42d6ed5dfa0d26d9e8f9 |
| ++@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| +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 |
| ```` |
| |
| @@ -2061,7 +2222,7 @@ Expected: PASS. |
| - [ ] **Step 8: Commit** |
| |
| ```bash |
| -git add internal/host/installchannel.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 |
| +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 |
| git commit -m "region HA 2b: carlos system install-channel" |
| ``` |
| |
| @@ -2070,16 +2231,16 @@ git commit -m "region HA 2b: carlos system install-channel" |
| ### Task 4: Group membership, read by the console |
| |
| **Files:** |
| -- Create: `internal/hostactors/membershipread.go`, `internal/hostactors/membershipread_test.go` |
| -- Modify: `internal/hostactors/enrollment_confirmation.go` (`EnrollAutomatic` and `CheckConfirmed` read membership themselves; the stored confirmation is removed), `internal/hostactors/enroll.go` (the freshness check before the roster write) |
| +- Create: `internal/hostactors/membershipread.go`, `internal/hostactors/groupref.go`, `internal/hostactors/membershipread_test.go` |
| +- Modify: `internal/hostactors/enrollment_confirmation.go` (`EnrollAutomatic` and `CheckConfirmed` read membership themselves; the stored confirmation is removed), `internal/hostactors/enroll.go` (the check before the roster write), `internal/hostactors/private.go` (a group box's report under its derived reference) |
| - Modify: `internal/hostactors/attempt_regression_test.go`, `internal/hostactors/roles_test.go` (main's tests, onto a fake reader) |
| - Create: `internal/hostactors/awsmembership/awsmembership.go`, `awsmembership_test.go` |
| - Modify: `go.mod`, `go.sum` (`github.com/aws/aws-sdk-go-v2/service/autoscaling v1.74.0`) |
| -- Modify: `region_ha_boundary_test.go`, the overlay |
| +- Modify: `region_ha_boundary_test.go`, `internal/provision/plan9_b8_census_test.go` (`deadlineStore.Put`), the overlay |
| |
| **Interfaces:** |
| -- Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace`. |
| -- Produces (`hostactors`): `type MembershipReader interface { Member(ctx, awsRegion, instance string) (Membership, error) }`; `Membership`; `MembershipEvidence{RequestIDs []string; ObservedAt time.Time}`; `ErrMembershipUnknown`, `ErrNotMember`, `ErrMembershipStale`; `func (d Directory) CheckConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, MembershipEvidence, error)`; `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: `membershipConfirmation`, `confirmMembership`, `membershipFreshness = time.Minute`. |
| +- Consumes: `validateScope`, `segment`, `token`, `EnrollmentAttempt`, `Directory.enroll`, `Replace` and `checkMembership` (unchanged, run on the bound store), `release.Store`. |
| +- 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) CheckConfirmed(ctx, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, MembershipEvidence, error)`; `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: `membershipConfirmation` (with `fresh`, `deadline`, `atWrite`), `confirmMembership`, `writeBound`, `deadlineStore`, `membershipFreshness = time.Minute`. Changed: `Directory.PrivateBoxReport` reads a group box (`Box.Group` set) under `GroupBoxReference` of its pinned account and instance. |
| - Removes (`hostactors`): `ConfirmationKey`, `MembershipConfirmation`, `TerminatedEnrollment`, `freshConfirmation`. Nothing on main but `EnrollAutomatic` and its two tests used them. |
| - Produces (`hostactors/awsmembership`): `New(ctx) (*Reader, error)` over the console's credentials; `(*Reader).Member` (`DescribeAutoScalingInstances`, then `DescribeInstances` by the `instance-id` filter). |
| |
| @@ -2087,7 +2248,11 @@ H28: the console is the only membership confirmer, and its read cannot be forged |
| |
| 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. |
| |
| -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 time is taken before the call. `CheckConfirmed` refuses a read over a minute old once it has it; `EnrollAutomatic` also hands `enroll` a check that runs immediately before the roster CAS, so a read that went stale while `enroll` read its references is refused at the write. 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` for the console's log: main's roster has no field for them, and none is added. |
| +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 time is taken before the call. `CheckConfirmed` refuses a read that was already over a minute old when the describe returned (`ErrMembershipStale`, which counts against the attempt). 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. |
| + |
| +**The write bound (S1, Astra round 2 new #1).** The write is issued no later than 60 s after the membership read; a write whose result is lost to the deadline is unknown and is read back next pass. `Invite`, `checkMembership` and `Replace` are sealed and stay unchanged; instead `writeBound` gives them a copy of the `Directory` whose store is a `deadlineStore` and a context that ends at the deadline. `deadlineStore` refuses every call (`Get`, `Put`, `List`, `ListDirs`, `Delete`) once the deadline has passed by the read's clock or the context's, before the call reaches the store underneath: the bound cannot rest on the store, because `release.DirStore` ignores its context (`release.S3Store` passes it to the AWS SDK, which does honour it). So a slow bucket read inside `checkMembership`, `Replace` or `Invite` cannot be followed by a signed `Put`. `EnrollAutomatic` runs `enroll` on the bound directory, with the check just before the roster write kept as the same bound once more; a missed deadline is `ErrWriteDeadline`, unknown. The tests drive a store that answers late inside those sealed functions (Task 5). |
| + |
| +**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). |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -2098,7 +2263,9 @@ package hostactors |
| |
| import ( |
| "context" |
| + "encoding/json" |
| "errors" |
| + "fmt" |
| "reflect" |
| "strings" |
| "testing" |
| @@ -2107,6 +2274,7 @@ import ( |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| |
| // fakeMembership is the cloud, as the console's reader sees it: each |
| @@ -2135,7 +2303,7 @@ func member(group string) Membership { |
| } |
| |
| // memberWorld is fixture()'s roster with ie's group "routers-ie", and a |
| -// box "new" (instance i-new) the group launched, invited, with an open |
| +// box (instance i-new, reference newRef) the group launched, invited, with an open |
| // attempt. |
| type memberWorld struct { |
| d Directory |
| @@ -2153,7 +2321,7 @@ func newMemberWorld(t *testing.T, m Membership) *memberWorld { |
| ctx := context.Background() |
| d, sg, o := fixture(t) |
| aws := boxidtest.NewSigner(t) |
| - _, raw := aws.Record(t, "new", "123", "eu-west-1", "i-new") |
| + _, raw := aws.Record(t, newRef, "123", "eu-west-1", "i-new") |
| next := Clone(o.Roster) |
| 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"}}} |
| o, err := d.Replace(ctx, sg, o, next) |
| @@ -2162,8 +2330,8 @@ func newMemberWorld(t *testing.T, m Membership) *memberWorld { |
| } |
| w := &memberWorld{d: d, sg: sg, v: boxid.Verifier{Account: "123", Certs: aws.Certs()}, raw: raw, clock: time.Now().UTC(), |
| cloud: &fakeMembership{members: map[string]Membership{"i-new": m}}, |
| - in: EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}} |
| - if _, err := d.Invite(ctx, sg, "new", raw); err != nil { |
| + in: EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"}} |
| + if _, err := d.Invite(ctx, sg, newRef, raw); err != nil { |
| t.Fatal(err) |
| } |
| o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, w.in, raw, "i-new") |
| @@ -2188,7 +2356,7 @@ func TestEnrollAutomaticReadsMembershipItself(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if b := next.Roster.Boxes["new"]; b.State != "active" || b.Group != "routers-ie" || b.Cloud.InstanceID != "i-new" { |
| + if b := next.Roster.Boxes[newRef]; b.State != "active" || b.Group != "routers-ie" || b.Cloud.InstanceID != "i-new" { |
| t.Fatalf("roster entry %+v", b) |
| } |
| 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) { |
| @@ -2237,10 +2405,18 @@ func TestEnrollAutomaticRefusesANonMember(t *testing.T) { |
| // never a non-member. |
| func TestAMembershipReadThatIsNotAnAnswerIsUnknown(t *testing.T) { |
| for name, edit := range map[string]func(*fakeMembership){ |
| - "the call fails": func(f *fakeMembership) { f.err = errors.New("Throttling") }, |
| - "EC2 does not list it": func(f *fakeMembership) { m := f.members["i-new"]; m.InstanceState, m.Owner = "", ""; f.members["i-new"] = m }, |
| - "one request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a"}; f.members["i-new"] = m }, |
| - "a blank request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a", ""}; f.members["i-new"] = m }, |
| + "the call fails": func(f *fakeMembership) { f.err = errors.New("Throttling") }, |
| + "EC2 does not list it": func(f *fakeMembership) { |
| + m := f.members["i-new"] |
| + m.InstanceState, m.Owner = "", "" |
| + f.members["i-new"] = m |
| + }, |
| + "one request id": func(f *fakeMembership) { m := f.members["i-new"]; m.RequestIDs = []string{"a"}; f.members["i-new"] = m }, |
| + "a blank request id": func(f *fakeMembership) { |
| + m := f.members["i-new"] |
| + m.RequestIDs = []string{"a", ""} |
| + f.members["i-new"] = m |
| + }, |
| } { |
| t.Run(name, func(t *testing.T) { |
| w := newMemberWorld(t, member("routers-ie")) |
| @@ -2262,16 +2438,16 @@ func TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite(t *testing.T) { |
| t.Fatalf("slow read: %v", err) |
| } |
| o, err := w.d.Read(context.Background()) |
| - if err != nil || o.Roster.Revision != w.o.Roster.Revision || o.Roster.EnrollmentAttempts["new"].Consumed { |
| + if err != nil || o.Roster.Revision != w.o.Roster.Revision || o.Roster.EnrollmentAttempts[newRef].Consumed { |
| t.Fatalf("a stale read wrote the roster: %v", err) |
| } |
| // A read that was fresh when it was checked but is over a minute old |
| // by the write: here every look at the clock takes 31 seconds, so the |
| // read is 31 seconds old when checked and 62 when the roster write is |
| - // about to be made. enroll's hook refuses it there. |
| + // about to be made. The write bound refuses it there, as unknown. |
| u := newMemberWorld(t, member("routers-ie")) |
| ticking := func() time.Time { u.clock = u.clock.Add(31 * time.Second); return u.clock } |
| - 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, ErrMembershipStale) { |
| + 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) { |
| t.Fatalf("a read gone stale before the write: %v", err) |
| } |
| if o, err := u.d.Read(context.Background()); err != nil || o.Roster.Revision != u.o.Roster.Revision { |
| @@ -2301,6 +2477,83 @@ func TestNoExportedPathTakesAConfirmation(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// newRef is the joining box's reference: the group reference of |
| +// instance i-new in account 123. |
| +var newRef = GroupBoxReference("123", "i-new") |
| + |
| +// The ruling's first test: two group boxes never share a reference. The |
| +// reference is a pseudonym of account and instance, stable, and outside |
| +// the label space. |
| +func TestGroupBoxReferencesDoNotCollide(t *testing.T) { |
| + if GroupBoxReference("123", "i-0a61f580df1acfa65") != GroupBoxReference("123", "i-0a61f580df1acfa65") { |
| + t.Fatal("not stable") |
| + } |
| + if GroupBoxReference("123", "i-a") == GroupBoxReference("124", "i-a") { |
| + t.Fatal("the account is not in the reference") |
| + } |
| + seen := map[string]string{} |
| + for i := 0; i < 200000; i++ { |
| + inst := fmt.Sprintf("i-%017x", i) |
| + ref := GroupBoxReference("123456789012", inst) |
| + if len(ref) != 22 || !strings.HasPrefix(ref, "g-") || !segment(ref) { |
| + t.Fatalf("%s: %q", inst, ref) |
| + } |
| + if other, ok := seen[ref]; ok { |
| + t.Fatalf("%s and %s share %s", inst, other, ref) |
| + } |
| + seen[ref] = inst |
| + } |
| + if strings.Contains(GroupBoxReference("123", "i-0a61f580df1acfa65"), "0a61f580") { |
| + t.Fatal("the reference shows the instance id") |
| + } |
| +} |
| + |
| +// The console reads a group box's report under the reference it derives |
| +// from the roster's pinned instance, never the label in the entry; every |
| +// other box under its reference, exactly as before. |
| +func TestPrivateBoxReportDerivesAGroupBoxsKey(t *testing.T) { |
| + d, _, _ := fixture(t) |
| + ctx := context.Background() |
| + put := func(key, instance string) { |
| + raw, _ := json.Marshal(status.Box{GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{InstanceID: instance}}) |
| + if _, err := d.Store.Put(ctx, key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + derived := GroupBoxReference("123", "i-g") |
| + put("status/boxes/"+derived+".json", "i-g") |
| + put("status/boxes/chosen.json", "i-chosen") |
| + put("status/boxes/annie-welding.json", "i-old") |
| + r, err := d.PrivateBoxReport(ctx, "chosen", Box{Label: "chosen", Group: "routers-ie", Cloud: Cloud{Account: "123", InstanceID: "i-g"}}) |
| + if err != nil || r.InstanceID != "i-g" { |
| + t.Fatalf("group box: %+v %v", r, err) |
| + } |
| + r, err = d.PrivateBoxReport(ctx, "annie-welding", Box{Label: "annie-welding", Cloud: Cloud{Account: "123", InstanceID: "i-old"}}) |
| + if err != nil || r.InstanceID != "i-old" { |
| + t.Fatalf("a box an operator enrolled: %+v %v", r, err) |
| + } |
| + if _, err := d.PrivateBoxReport(ctx, derived, Box{Group: "routers-ie"}); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("a group box with no pinned instance: %v", err) |
| + } |
| +} |
| + |
| +// A group box is admitted only under the reference its instance derives: |
| +// an attempt under a reference the box chose is refused before the cloud |
| +// is asked, even for a member. |
| +func TestEnrollAutomaticRefusesAReferenceTheBoxChose(t *testing.T) { |
| + w := newMemberWorld(t, member("routers-ie")) |
| + in := w.in |
| + in.Reference, in.Label = "chosen", "chosen" |
| + o, a, err := w.d.BeginEnrollmentAttempt(context.Background(), w.sg, w.o, in, w.raw, "i-new") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID |
| + 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 { |
| + t.Fatalf("a chosen reference: %v, calls %v", err, w.cloud.calls) |
| + } |
| +} |
| ```` |
| |
| Main's two tests that wrote a confirmation object read through a fake reader instead: |
| @@ -2324,6 +2577,19 @@ Main's two tests that wrote a confirmation object read through a fake reader ins |
| ctx := context.Background() |
| d, sg, o := fixture(t) |
| key, err := rsa.GenerateKey(rand.Reader, 2048) |
| +@@ -28,10 +27,10 @@ |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +- rec := boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)} |
| ++ rec := boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)} |
| + raw, _ := json.Marshal(rec) |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| +- in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| ++ in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| + next := Clone(o.Roster) |
| + next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}} |
| + o, err = d.Replace(ctx, sg, o, next) |
| @@ -45,27 +44,9 @@ |
| if err != nil { |
| t.Fatal(err) |
| @@ -2402,7 +2668,19 @@ Main's two tests that wrote a confirmation object read through a fake reader ins |
| ````diff |
| --- a/internal/hostactors/roles_test.go |
| +++ b/internal/hostactors/roles_test.go |
| -@@ -204,15 +204,10 @@ |
| +@@ -189,9 +189,9 @@ |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| +- raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) |
| ++ raw, _ := json.Marshal(boxid.Record{V: 1, Box: newRef, IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| +- in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| ++ in := EnrollmentRequest{Reference: newRef, Namespace: "platform", Label: newRef, Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| + next := Clone(o.Roster) |
| + 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}} |
| + if o, err = d.Replace(ctx, sg, o, next); err != nil { |
| +@@ -204,19 +204,14 @@ |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -2417,9 +2695,17 @@ Main's two tests that wrote a confirmation object read through a fake reader ins |
| + cloud := &fakeMembership{members: map[string]Membership{"i-new": member("routers")}} |
| in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID |
| - admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now) |
| +- if err != nil { |
| +- t.Fatal(err) |
| +- } |
| +- b := admitted.Roster.Boxes["new"] |
| + admitted, _, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", cloud, time.Now) |
| - if err != nil { |
| - t.Fatal(err) |
| ++ if err != nil { |
| ++ t.Fatal(err) |
| ++ } |
| ++ b := admitted.Roster.Boxes[newRef] |
| + if b.Group != "routers" || b.Role != role { |
| + t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role) |
| } |
| ```` |
| |
| @@ -2544,6 +2830,8 @@ import ( |
| "errors" |
| "fmt" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| // Group membership, confirmed by the console (region HA spec 2 §5 step 5, |
| @@ -2598,14 +2886,21 @@ var ( |
| // a current, running member of the region's group in the deployment's |
| // account and AWS region. |
| ErrNotMember = errors.New("hostactors: the instance is not a current member of the region's group") |
| - // ErrMembershipStale is a read more than membershipFreshness old at |
| - // the moment of the write it was to allow. |
| + // ErrMembershipStale is a read that was already more than |
| + // membershipFreshness old when the describe returned: the cloud was |
| + // slow, and the answer counts against the attempt. |
| ErrMembershipStale = errors.New("hostactors: the membership read is more than a minute old") |
| + // ErrWriteDeadline is a write that could not be issued within |
| + // membershipFreshness of its read (writeBound). Nothing was written, |
| + // or a write's result was lost to the deadline: either way it is |
| + // unknown, and the next pass reads back and describes again. |
| + ErrWriteDeadline = errors.New("hostactors: the write would be issued more than a minute after its membership read") |
| ) |
| |
| // membershipConfirmation is one read of an attempt's instance. It is built |
| // only by confirmMembership, from the reader's answer, and never leaves |
| -// this package. |
| +// this package. A read the cloud answered carries its request ids and |
| +// time whether or not it found a member, so every read can be logged. |
| type membershipConfirmation struct { |
| requestIDs []string |
| observedAt time.Time |
| @@ -2624,37 +2919,50 @@ func confirmMembership(ctx context.Context, r MembershipReader, region Region, i |
| at := now().UTC() |
| m, err := r.Member(ctx, region.AWSRegion, instance) |
| if err != nil { |
| - return membershipConfirmation{}, fmt.Errorf("%w: %v", ErrMembershipUnknown, err) |
| + return membershipConfirmation{observedAt: at}, fmt.Errorf("%w: %v", ErrMembershipUnknown, err) |
| } |
| + c := membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at} |
| switch { |
| case m.GroupName != region.GroupName: |
| - return membershipConfirmation{}, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName) |
| + return c, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName) |
| case m.LifecycleState != "Pending" && m.LifecycleState != "InService": |
| - return membershipConfirmation{}, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState) |
| + return c, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState) |
| case m.InstanceState == "" || m.Owner == "": |
| // Auto Scaling knows it but EC2 does not list it (yet, or any |
| // more): no answer. |
| - return membershipConfirmation{}, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown) |
| + return c, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown) |
| case m.InstanceState != "pending" && m.InstanceState != "running": |
| - return membershipConfirmation{}, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState) |
| + return c, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState) |
| case m.Owner != region.Account || m.AvailabilityZoneRegion != region.AWSRegion: |
| - return membershipConfirmation{}, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion) |
| + return c, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion) |
| case len(m.RequestIDs) < 2: |
| - return membershipConfirmation{}, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown) |
| + return c, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown) |
| } |
| for _, id := range m.RequestIDs { |
| if id == "" { |
| - return membershipConfirmation{}, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown) |
| + return c, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown) |
| } |
| } |
| - return membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at}, nil |
| + return c, nil |
| } |
| |
| // fresh refuses a confirmation more than membershipFreshness old at now, |
| -// or one from the future. |
| +// or one from the future: the describe itself was too slow. |
| func (c membershipConfirmation) fresh(now time.Time) error { |
| if c.observedAt.IsZero() || now.Sub(c.observedAt) > membershipFreshness || c.observedAt.After(now.Add(5*time.Second)) { |
| - return fmt.Errorf("%w (read at %s, written at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339)) |
| + return fmt.Errorf("%w (read at %s, checked at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339)) |
| + } |
| + return nil |
| +} |
| + |
| +// deadline is the last moment a write this read allows may be issued. |
| +func (c membershipConfirmation) deadline() time.Time { return c.observedAt.Add(membershipFreshness) } |
| + |
| +// atWrite is the check made immediately before a write is issued: past |
| +// the deadline it is ErrWriteDeadline, unknown, never a refusal. |
| +func (c membershipConfirmation) atWrite(now time.Time) error { |
| + if c.observedAt.IsZero() || now.After(c.deadline()) { |
| + return fmt.Errorf("%w (read at %s, write at %s)", ErrWriteDeadline, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339)) |
| } |
| return nil |
| } |
| @@ -2662,6 +2970,96 @@ func (c membershipConfirmation) fresh(now time.Time) error { |
| func (c membershipConfirmation) evidence() MembershipEvidence { |
| return MembershipEvidence{RequestIDs: append([]string(nil), c.requestIDs...), ObservedAt: c.observedAt} |
| } |
| + |
| +// writeBound is d with every store call bound to c's deadline (C2, S1): |
| +// the returned context ends at the deadline, so a call in flight is cut |
| +// off, and each store call (a read, the write, the read-back after a |
| +// lost write) is refused before it is issued once the deadline has |
| +// passed by either clock, the context's or now. The sealed writers |
| +// (Invite, Replace and the checkMembership it makes) run on it |
| +// unchanged, so no read, signature or Put of theirs can be issued later |
| +// than 60 seconds after the membership read. |
| +func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) { |
| + bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now())) |
| + bd := d |
| + bd.Store = deadlineStore{Store: d.Store, check: func(callCtx context.Context) error { |
| + if bctx.Err() != nil || callCtx.Err() != nil { |
| + return fmt.Errorf("%w: the deadline passed", ErrWriteDeadline) |
| + } |
| + return c.atWrite(now()) |
| + }} |
| + return bd, bctx, cancel |
| +} |
| + |
| +// deadlineStore refuses every call once check says the deadline has |
| +// passed, before the call reaches the store underneath: release.DirStore |
| +// ignores its context, so the bound cannot rest on the store alone. |
| +type deadlineStore struct { |
| + release.Store |
| + check func(context.Context) error |
| +} |
| + |
| +func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if err := s.check(ctx); err != nil { |
| + return nil, "", err |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if err := s.check(ctx); err != nil { |
| + return "", err |
| + } |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if err := s.check(ctx); err != nil { |
| + return nil, err |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
| + |
| +func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) { |
| + if err := s.check(ctx); err != nil { |
| + return nil, err |
| + } |
| + return s.Store.ListDirs(ctx, prefix) |
| +} |
| + |
| +func (s deadlineStore) Delete(ctx context.Context, key string) error { |
| + if err := s.check(ctx); err != nil { |
| + return err |
| + } |
| + return s.Store.Delete(ctx, key) |
| +} |
| +```` |
| + |
| +`internal/hostactors/groupref.go`: |
| + |
| +````go |
| +package hostactors |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| +) |
| + |
| +// GroupBoxReference is the reference of a box its region's group launched |
| +// (census ruling region-ha-2b): "g-" and the first 20 hex digits of |
| +// sha256("carlos-box-ref-v1|" + account + "|" + instance). Status labels |
| +// come from 1,600 values and collide as groups replace their boxes; |
| +// instance ids never repeat in an account, and 80 bits of their digest |
| +// make a collision negligible. It is a pseudonym, so the public status |
| +// feed never shows an instance id. The box derives it to publish under it, |
| +// and the console derives it again from the instance it proved (the |
| +// attempt's, then the roster's pinned one) to read and admit it, never |
| +// trusting the key a box chose. Boxes an operator enrolled keep their |
| +// labels. |
| +func GroupBoxReference(account, instance string) string { |
| + sum := sha256.Sum256([]byte("carlos-box-ref-v1|" + account + "|" + instance)) |
| + return "g-" + hex.EncodeToString(sum[:])[:20] |
| +} |
| ```` |
| |
| `internal/hostactors/enrollment_confirmation.go`: |
| @@ -2677,7 +3075,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| "github.com/carlosframework/platform/internal/release" |
| -@@ -22,97 +21,62 @@ |
| +@@ -22,97 +21,76 @@ |
| // group takes (roles.go): "" (default) or legacy. |
| Role string `json:"role,omitempty"` |
| } |
| @@ -2715,19 +3113,26 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| - TerminatedEnrollments []TerminatedEnrollment `json:"terminated_enrollments"` |
| +// EnrollAutomatic admits a box its region's group launched (H16, H28). The |
| +// console confirms the box's group membership itself, here: Member is |
| -+// asked, through r, immediately before the enrolling write, and the write |
| -+// is refused if that read is more than a minute old when it is made. No |
| -+// box writes or contributes membership evidence, and no caller can hand |
| -+// a confirmation in: membershipread.go builds it from r's answer alone. |
| ++// asked, through r, and the enrolling write is issued no later than a |
| ++// minute after that read (writeBound): every store call of the write, |
| ++// checkMembership's reads and Replace's Put included, runs under that |
| ++// deadline, and the check before the roster write is made again just |
| ++// before it. No box writes or contributes membership evidence, and no |
| ++// caller can hand a confirmation in: membershipread.go builds it from r's |
| ++// answer alone. The evidence comes back whenever the cloud answered, so |
| ++// every read can be logged. |
| +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) { |
| + pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now) |
| + if err != nil { |
| -+ return Observed{}, MembershipEvidence{}, err |
| ++ return Observed{}, c.evidence(), err |
| + } |
| -+ next, err := d.enroll(ctx, sg, o, v, pinned, raw, true, func() error { return c.fresh(now()) }) |
| ++ bd, bctx, cancel := writeBound(ctx, d, c, now) |
| ++ defer cancel() |
| ++ next, err := bd.enroll(bctx, sg, o, v, pinned, raw, true, func() error { return c.atWrite(now()) }) |
| + if err != nil { |
| -+ return Observed{}, MembershipEvidence{}, err |
| ++ return Observed{}, c.evidence(), err |
| + } |
| ++ // Callers go on with the store itself, not the bound one. |
| + return next, c.evidence(), nil |
| } |
| |
| @@ -2736,14 +3141,16 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| -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) { |
| +// CheckConfirmed is EnrollAutomatic's admission check, made without |
| +// writing: the attempt in the roster matches the request and the record, |
| -+// and a describe made now confirms the attempt's instance is a current |
| -+// member of the region's group. It returns in pinned to that instance and |
| -+// what the read saw. The console's enrollment pass asks it immediately |
| -+// before it invites a box. |
| ++// the request's reference is the instance's group reference |
| ++// (GroupBoxReference), and a describe made now confirms the attempt's |
| ++// instance is a current member of the region's group. It returns in |
| ++// pinned to that instance and what the read saw. The console's enrollment |
| ++// pass asks it immediately before it invites a box, and makes the |
| ++// invitation under the read's deadline (autoenroll.go). |
| +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, MembershipEvidence, error) { |
| + pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now) |
| + if err != nil { |
| -+ return EnrollmentRequest{}, MembershipEvidence{}, err |
| ++ return EnrollmentRequest{}, c.evidence(), err |
| + } |
| + return pinned, c.evidence(), nil |
| +} |
| @@ -2756,6 +3163,11 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| region, ok := o.Roster.Regions[in.Region] |
| if !ok || region.Account != v.Account || region.AWSRegion != in.AWSRegion || region.GroupARN == "" || region.GroupName == "" || !segment(instance) || !segment(in.Reference) || in.ExistingReference { |
| - return Observed{}, ErrRoster |
| ++ return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster |
| ++ } |
| ++ // A group box is enrolled only under the reference derived from its |
| ++ // instance, never one it chose (census ruling region-ha-2b). |
| ++ if in.Reference != GroupBoxReference(region.Account, instance) { |
| + return EnrollmentRequest{}, membershipConfirmation{}, ErrRoster |
| } |
| attempt, ok := o.Roster.EnrollmentAttempts[in.Reference] |
| @@ -2767,7 +3179,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| + c, err := confirmMembership(ctx, r, region, instance, now) |
| if err != nil { |
| - return Observed{}, err |
| -+ return EnrollmentRequest{}, membershipConfirmation{}, err |
| ++ return EnrollmentRequest{}, c, err |
| } |
| - var c MembershipConfirmation |
| - 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 { |
| @@ -2801,7 +3213,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| - } |
| - } |
| + if err := c.fresh(now()); err != nil { |
| -+ return EnrollmentRequest{}, membershipConfirmation{}, err |
| ++ return EnrollmentRequest{}, c, err |
| } |
| // Pin the verified IMDS identity to the instance that membership proved. |
| in.TofuInstanceID = instance |
| @@ -2814,7 +3226,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| } |
| |
| type EnrollmentAttempt struct { |
| -@@ -126,10 +90,6 @@ |
| +@@ -126,10 +104,6 @@ |
| GroupName string `json:"group_name"` |
| GroupARN string `json:"group_arn"` |
| Consumed bool `json:"consumed"` |
| @@ -2832,7 +3244,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| ````diff |
| --- a/internal/hostactors/enroll.go |
| +++ b/internal/hostactors/enroll.go |
| -@@ -21,11 +21,15 @@ |
| +@@ -21,11 +21,16 @@ |
| } |
| |
| // Enroll uses operator-approved configuration, not values in the identity |
| @@ -2844,6 +3256,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| + return d.enroll(ctx, sg, o, v, in, raw, false, nil) |
| } |
| -func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, automatic bool) (Observed, error) { |
| ++ |
| +// fresh, when set, is asked immediately before the roster write and stops |
| +// it on an error: automatic enrollment's membership read must still be |
| +// under a minute old then (membershipread.go). |
| @@ -2851,7 +3264,7 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| if (!automatic && in.Operator == "") || in.AWSRegion == "" || in.Reference == "" { |
| return Observed{}, ErrRoster |
| } |
| -@@ -77,6 +81,11 @@ |
| +@@ -77,6 +82,11 @@ |
| box.Group, box.Role = g.GroupName, g.Role |
| } |
| r.Boxes[in.Reference] = box |
| @@ -2865,6 +3278,33 @@ func (c membershipConfirmation) evidence() MembershipEvidence { |
| |
| ```` |
| |
| +`internal/hostactors/private.go`: |
| + |
| +````diff |
| +--- a/internal/hostactors/private.go |
| ++++ b/internal/hostactors/private.go |
| +@@ -7,9 +7,18 @@ |
| + "strings" |
| + ) |
| + |
| ++// PrivateBoxReport reads the private status report of box ref. A box its |
| ++// region's group enrolled (b.Group set) is read under the reference the |
| ++// console derives from the instance the roster pins, never under the key |
| ++// the box chose (GroupBoxReference); every other box under ref, as before. |
| + func (d Directory) PrivateBoxReport(ctx context.Context, ref string, b Box) (Report, error) { |
| + key := "status/boxes/" + ref + ".json" |
| +- if strings.HasPrefix(b.Namespace, "fleet:") { |
| ++ if b.Group != "" { |
| ++ if b.Cloud.Account == "" || !segment(b.Cloud.InstanceID) { |
| ++ return Report{}, ErrRoster |
| ++ } |
| ++ key = "status/boxes/" + GroupBoxReference(b.Cloud.Account, b.Cloud.InstanceID) + ".json" |
| ++ } else if strings.HasPrefix(b.Namespace, "fleet:") { |
| + fleet := strings.TrimPrefix(b.Namespace, "fleet:") |
| + if !segment(fleet) || !segment(b.Label) { |
| + return Report{}, ErrRoster |
| +```` |
| + |
| `internal/hostactors/awsmembership/awsmembership.go`: |
| |
| ````go |
| @@ -2971,15 +3411,16 @@ Expected: PASS, main's tests included. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -33,6 +33,7 @@ |
| +@@ -33,6 +33,8 @@ |
| "internal/hostenv/hostenv.go", |
| "internal/hostenv/cli.go", |
| "internal/host/installchannel.go", |
| + "internal/hostactors/membershipread.go", |
| ++ "internal/hostactors/groupref.go", |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| -@@ -41,6 +42,7 @@ |
| +@@ -41,6 +43,7 @@ |
| "internal/host/awsaddr.go", |
| "internal/console/awsregionmetric.go", |
| "internal/hostenv/awsssm.go", |
| @@ -2987,7 +3428,7 @@ Expected: PASS, main's tests included. |
| } |
| |
| // regionHAPackages are the packages spec 2 touches. Every S3 client built |
| -@@ -53,6 +55,7 @@ |
| +@@ -53,6 +56,7 @@ |
| "internal/edgesfile", |
| "internal/host", |
| "internal/hostactors", |
| @@ -3002,7 +3443,24 @@ Expected: PASS. |
| |
| - [ ] **Step 7: Census** |
| |
| -No root-read unit is added or changed: the read is the cloud's, through the reader, and `EnrollAutomatic` no longer reads the store. Only the two `@coverage` rows move, and `.` for go.mod. The seal stands. |
| +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: |
| + |
| +`internal/provision/plan9_b8_census_test.go`: |
| + |
| +````diff |
| +--- a/internal/provision/plan9_b8_census_test.go |
| ++++ b/internal/provision/plan9_b8_census_test.go |
| +@@ -814,6 +814,9 @@ |
| + {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"}, |
| + {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"}, |
| + {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"}, |
| ++ // Region HA plan 2b (Task 4): the store a membership read binds to its |
| ++ // deadline; it forwards Put, and refuses it once the deadline passes. |
| ++ {"internal/hostactors/membershipread.go", "deadlineStore.Put", "nonrecord", "def:Put,sel:Put", "eeb1721847a5046504981da20bed2b454efc0723ecded7e98b85d55906df23af"}, |
| + {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"}, |
| + {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"}, |
| + {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"}, |
| +```` |
| |
| `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| |
| @@ -3011,16 +3469,16 @@ No root-read unit is added or changed: the read is the cloud's, through the read |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -1,8 +1,11 @@ |
| # 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. |
| - # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| - # Task 3: carlos system install-channel (internal/host installchannel.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and hands the cache directories it creates under the data dir to the service user, as bootstrap does. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| + # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| + # 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). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| -@coverage . be7c96bb2ff00b5b018b436f976f0d33bbcc8765c17e357baa6cd0290f59f42d |
| -+# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader and the unexported confirmation; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves, and the box-written confirmation object is removed; enroll.go: the freshness check before the roster write; 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. |
| ++# Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write under its deadline, and the box-written confirmation object is removed; enroll.go: the check before the roster write; 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). |
| +@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| - @coverage internal/host f3b2fc63654dbe9e41ec940bce86acae3c39ced0258d42d6ed5dfa0d26d9e8f9 |
| + @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| 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 |
| -+@coverage internal/hostactors 12b588f47fa72da1798815cd44815b131415c15f7c3f7f8afeba31303cf690eb |
| ++@coverage internal/hostactors 3b314e63b03a49d88c24b71c13dff924deabcad571b7b0007c638cc97285c6d4 |
| +@coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| ```` |
| |
| @@ -3030,7 +3488,7 @@ Expected: PASS. |
| - [ ] **Step 8: Commit** |
| |
| ```bash |
| -git add internal/hostactors go.mod go.sum region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +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 |
| git commit -m "region HA 2b: group membership, read by the console" |
| ``` |
| |
| @@ -3046,15 +3504,15 @@ git commit -m "region HA 2b: group membership, read by the console" |
| - Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal) |
| |
| **Interfaces:** |
| -- Consumes: `Directory.Read`, `Invite`, `BeginEnrollmentAttempt`, `CheckConfirmed` and `EnrollAutomatic` (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`). |
| +- Consumes: `Directory.Read`, `Invite`, `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`). |
| - 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`. |
| - 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`. |
| |
| -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 while the read is under a minute old; 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 immediately before the enrolling write. 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. |
| +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 that read's deadline (`writeBound`), so `Invite`'s own read, its signature's `Put` and any read-back are refused once the read is a minute old; 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 immediately before the enrolling write. 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. |
| |
| -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). 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. Each invitation and enrolment logs the read's two request ids and time (`Logf`), the audit main's roster has no field for. |
| +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 that missed its deadline (`ErrWriteDeadline`, S1): nothing past the deadline was issued, and the next pass reads back (an invitation or an enrolment that did land is seen then) and describes again. 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. |
| |
| -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 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. |
| +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. |
| |
| 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 claim-authority census (R6): `newAutoEnroller`, `runEnrollPass` and `EnrollPass` join its selected names, and the three sites they produce are appended to `claim-authority-sites.txt` (appended, never re-sorted; the test sorts both sides). Removing or duplicating any of the three turns the census red. |
| |
| @@ -3078,19 +3536,22 @@ import ( |
| |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/boxid/boxidtest" |
| + "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| |
| // enrollWorld is plan9Fixture's published deployment (boxes a, b and w, |
| // active in ie under a published host floor) with ie's group, and a box |
| -// "new" that the group launched: its identity record is published, and |
| -// nothing else. The console reads the cloud through cloud. |
| +// (instance i-new, reference newRef) that the group launched: its |
| +// identity record is published, and nothing else. The console reads the |
| +// cloud through cloud. |
| type enrollWorld struct { |
| plan9World |
| - e *AutoEnroller |
| - cloud *fakeMembership |
| - clock time.Time |
| - logs []string |
| + e *AutoEnroller |
| + cloud *fakeMembership |
| + signer *boxidtest.Signer |
| + clock time.Time |
| + logs []string |
| } |
| |
| func newEnrollWorld(t *testing.T, role string) *enrollWorld { |
| @@ -3100,11 +3561,11 @@ func newEnrollWorld(t *testing.T, role string) *enrollWorld { |
| GroupARN: "arn:aws:autoscaling:eu-west-1:123:autoScalingGroup:x:autoScalingGroupName/routers-ie", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}} |
| }) |
| signer := boxidtest.NewSigner(t) |
| - _, raw := signer.Record(t, "new", "123", "eu-west-1", "i-new") |
| - if _, err := w.d.Store.Put(context.Background(), boxid.Key("new"), raw, ""); err != nil { |
| + _, raw := signer.Record(t, newRef, "123", "eu-west-1", "i-new") |
| + if _, err := w.d.Store.Put(context.Background(), boxid.Key(newRef), raw, ""); err != nil { |
| t.Fatal(err) |
| } |
| - ew := &enrollWorld{plan9World: w, clock: time.Now().UTC(), |
| + ew := &enrollWorld{plan9World: w, clock: time.Now().UTC(), signer: signer, |
| cloud: &fakeMembership{members: map[string]Membership{"i-new": member("routers-ie")}}} |
| ew.e = &AutoEnroller{Directory: w.d, Signer: w.sg, Evidence: ExactEvidence{PublicKey: w.d.PublicKey, Store: w.d.Store, Boxes: w.d.PrivateBoxReport}, |
| Members: ew.cloud, Certs: signer.Certs(), Namespace: "platform", Pool: "default", Now: func() time.Time { return ew.clock }, Patience: 2 * time.Minute, |
| @@ -3120,7 +3581,7 @@ func (w *enrollWorld) pass(t *testing.T) string { |
| t.Fatal(err) |
| } |
| for _, s := range steps { |
| - if s.Box == "new" { |
| + if s.Box == newRef { |
| return s.Step |
| } |
| } |
| @@ -3134,23 +3595,23 @@ func (w *enrollWorld) guardReady(t *testing.T, enrollment string, ready bool) { |
| box := status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: enrollment, InstanceID: "i-new", Release: "v1.0.0", ReleaseTag: "v1.0.0", |
| Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: ready, SystemChannel: "stable"}} |
| raw, _ := json.Marshal(box) |
| - _, etag, _ := w.d.Store.Get(context.Background(), "status/boxes/new.json") |
| - if _, err := w.d.Store.Put(context.Background(), "status/boxes/new.json", raw, etag); err != nil { |
| + _, etag, _ := w.d.Store.Get(context.Background(), "status/boxes/"+newRef+".json") |
| + if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+newRef+".json", raw, etag); err != nil { |
| t.Fatal(err) |
| } |
| } |
| |
| func (w *enrollWorld) invitation(t *testing.T) (Invitation, bool) { |
| t.Helper() |
| - if _, _, err := w.d.Store.Get(context.Background(), "control/host-actor-invitations/new.json"); err != nil { |
| + if _, _, err := w.d.Store.Get(context.Background(), "control/host-actor-invitations/"+newRef+".json"); err != nil { |
| return Invitation{}, false |
| } |
| - raw, err := boxid.Load(context.Background(), w.d.Store, "new") |
| + raw, err := boxid.Load(context.Background(), w.d.Store, newRef) |
| if err != nil { |
| t.Fatal(err) |
| } |
| // Invite returns the stored invitation, verified, and writes nothing. |
| - got, err := w.d.Invite(context.Background(), w.sg, "new", raw) |
| + got, err := w.d.Invite(context.Background(), w.sg, newRef, raw) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -3163,7 +3624,7 @@ func (w *enrollWorld) attempt(t *testing.T) EnrollmentAttempt { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - return o.Roster.EnrollmentAttempts["new"] |
| + return o.Roster.EnrollmentAttempts[newRef] |
| } |
| |
| // The whole sequence (spec 2 §5 step 5, §11): attempt, the console's own |
| @@ -3202,18 +3663,18 @@ func TestAutomaticEnrollmentFromRecordToRoster(t *testing.T) { |
| t.Fatalf("ready: %q", got) |
| } |
| // The enrollment read membership again, itself. |
| - if len(w.cloud.calls) != 2 || len(w.logs) != 2 || !strings.Contains(w.logs[1], "new enrolled") || !strings.Contains(w.logs[1], "asg-req, ec2-req") { |
| + if len(w.cloud.calls) != 2 || len(w.logs) != 2 || !strings.Contains(w.logs[1], "attempt "+w.attempt(t).AttemptID+", instance i-new, request ids asg-req, ec2-req") || !strings.HasSuffix(w.logs[1], "): enrolled") { |
| t.Fatalf("calls %v, logs %q", w.cloud.calls, w.logs) |
| } |
| o, err := w.d.Read(context.Background()) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - b, ok := o.Roster.Boxes["new"] |
| + b, ok := o.Roster.Boxes[newRef] |
| if !ok || b.State != "active" || b.Enrollment != inv.Enrollment || b.Cloud.InstanceID != "i-new" || b.Group != "routers-ie" || b.Role != RoleLegacy || b.Namespace != "platform" || b.Pool != "default" { |
| t.Fatalf("roster entry %+v", b) |
| } |
| - if !o.Roster.EnrollmentAttempts["new"].Consumed { |
| + if !o.Roster.EnrollmentAttempts[newRef].Consumed { |
| t.Fatal("the attempt was not consumed") |
| } |
| // Enrolled: the pass has nothing left to do for it. |
| @@ -3241,7 +3702,7 @@ func TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled(t *testing.T) { |
| if _, ok := w.invitation(t); ok { |
| t.Fatal("an instance outside the group was invited") |
| } |
| - if o, _ := w.d.Read(context.Background()); o.Roster.Boxes["new"].Enrollment != "" { |
| + if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" { |
| t.Fatal("an instance outside the group was enrolled") |
| } |
| }) |
| @@ -3257,7 +3718,7 @@ func TestAnInstanceOutsideTheGroupIsNeverInvitedOrEnrolled(t *testing.T) { |
| if got := w.pass(t); !strings.Contains(got, "not confirmed") { |
| t.Fatalf("left the group: %q", got) |
| } |
| - if o, _ := w.d.Read(context.Background()); o.Roster.Boxes["new"].Enrollment != "" { |
| + if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" { |
| t.Fatal("an instance that left the group was enrolled") |
| } |
| } |
| @@ -3275,8 +3736,8 @@ func TestAMembershipReadThatFailsKeepsTheAttempt(t *testing.T) { |
| t.Fatalf("failed read: %q", got) |
| } |
| } |
| - if got := w.attempt(t).AttemptID; got != first || w.e.attempts["new"].tries != 1 { |
| - t.Fatalf("a failed read superseded the attempt (%s, was %s) or grew its wait (%d)", got, first, w.e.attempts["new"].tries) |
| + if got := w.attempt(t).AttemptID; got != first || w.e.attempts[newRef].tries != 1 { |
| + t.Fatalf("a failed read superseded the attempt (%s, was %s) or grew its wait (%d)", got, first, w.e.attempts[newRef].tries) |
| } |
| w.cloud.err = nil |
| if got := w.pass(t); got != "invited" || w.attempt(t).AttemptID != first { |
| @@ -3287,17 +3748,158 @@ func TestAMembershipReadThatFailsKeepsTheAttempt(t *testing.T) { |
| // C2: the invitation is written only while the console's read is under a |
| // minute old. Here every look at the clock takes 31 seconds: the read is |
| // fresh when CheckConfirmed has it, and over a minute old by the time the |
| -// invitation would be written. |
| +// invitation would be written. That is unknown: the attempt and its wait |
| +// stand. |
| func TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite(t *testing.T) { |
| w := newEnrollWorld(t, "") |
| w.pass(t) |
| + first := w.attempt(t).AttemptID |
| w.e.Now = func() time.Time { w.clock = w.clock.Add(31 * time.Second); return w.clock } |
| - if got := w.pass(t); !strings.Contains(got, "more than a minute old") { |
| + if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") { |
| t.Fatalf("slow pass: %q", got) |
| } |
| if _, ok := w.invitation(t); ok { |
| t.Fatal("invited on a read over a minute old") |
| } |
| + if w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != 1 || w.e.attempts[newRef].refused != "" { |
| + t.Fatalf("a missed deadline counted against the attempt: %+v", w.e.attempts[newRef]) |
| + } |
| +} |
| + |
| +// slowStore is the deployment bucket with one slow read: the first Get of |
| +// slow after armed is set moves the console's clock on by 61 seconds, as |
| +// a bucket that answers late would. It honours its context, as the S3 |
| +// store does, and records every Put. |
| +type slowStore struct { |
| + release.Store |
| + slow string |
| + armed bool |
| + late func() |
| + puts []string |
| +} |
| + |
| +func (s *slowStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if err := ctx.Err(); err != nil { |
| + return nil, "", err |
| + } |
| + if s.armed && key == s.slow { |
| + s.armed = false |
| + s.late() |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func (s *slowStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if err := ctx.Err(); err != nil { |
| + return "", err |
| + } |
| + s.puts = append(s.puts, key) |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// slowAfterRead makes the store slow on key after the console's next |
| +// membership read. |
| +func (w *enrollWorld) slowAfterRead(key string) *slowStore { |
| + ss := &slowStore{Store: w.e.Directory.Store, slow: key, late: func() { w.clock = w.clock.Add(61 * time.Second) }} |
| + w.e.Directory.Store = ss |
| + w.cloud.during = func() { ss.armed = true } |
| + return ss |
| +} |
| + |
| +// S1: a read inside the sealed Invite that answers late (here, its own |
| +// read of the invitation key) leaves the invitation unwritten: no Put is |
| +// issued after the deadline. The attempt and its wait stand, and the next |
| +// pass, with a fresh read, invites. |
| +func TestASlowReadInsideInviteWritesNothing(t *testing.T) { |
| + w := newEnrollWorld(t, "") |
| + w.pass(t) |
| + first := w.attempt(t).AttemptID |
| + ss := w.slowAfterRead("control/host-actor-invitations/" + newRef + ".json") |
| + if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") { |
| + t.Fatalf("slow invite: %q", got) |
| + } |
| + if len(ss.puts) != 0 { |
| + t.Fatalf("written after the deadline: %v", ss.puts) |
| + } |
| + if _, ok := w.invitation(t); ok || w.attempt(t).AttemptID != first || w.e.attempts[newRef].tries != 1 { |
| + t.Fatalf("invited %v, attempt %+v", ok, w.e.attempts[newRef]) |
| + } |
| + w.cloud.during = nil |
| + if got := w.pass(t); got != "invited" || w.attempt(t).AttemptID != first { |
| + t.Fatalf("next pass: %q", got) |
| + } |
| +} |
| + |
| +// S1: a read inside the sealed checkMembership that answers late (the |
| +// guard-ready report Replace checks before its CAS) leaves the roster |
| +// unwritten. The attempt stays unconsumed, and the next pass enrolls. |
| +func TestASlowReadInsideTheRosterCheckWritesNothing(t *testing.T) { |
| + w := newEnrollWorld(t, "") |
| + w.pass(t) |
| + if got := w.pass(t); got != "invited" { |
| + t.Fatal(got) |
| + } |
| + inv, _ := w.invitation(t) |
| + w.guardReady(t, inv.Enrollment, true) |
| + first, tries := w.attempt(t).AttemptID, w.e.attempts[newRef].tries |
| + ss := w.slowAfterRead("status/boxes/" + newRef + ".json") |
| + if got := w.pass(t); !strings.Contains(got, "more than a minute after its membership read") { |
| + t.Fatalf("slow enrolment: %q", got) |
| + } |
| + if len(ss.puts) != 0 { |
| + t.Fatalf("written after the deadline: %v", ss.puts) |
| + } |
| + if a := w.attempt(t); a.AttemptID != first || a.Consumed || w.e.attempts[newRef].tries != tries || w.e.attempts[newRef].refused != "" { |
| + t.Fatalf("attempt %+v, seen %+v", a, w.e.attempts[newRef]) |
| + } |
| + if o, _ := w.d.Read(context.Background()); o.Roster.Boxes[newRef].Enrollment != "" { |
| + t.Fatal("enrolled after the deadline") |
| + } |
| + w.cloud.during = nil |
| + w.guardReady(t, inv.Enrollment, true) |
| + if got := w.pass(t); !strings.HasPrefix(got, "enrolled in ie") { |
| + t.Fatalf("next pass: %q", got) |
| + } |
| +} |
| + |
| +// The ruling's second test: a box cannot satisfy another box's report |
| +// check. A report under newRef's key that another instance wrote is not |
| +// newRef's guard-ready report, and a record under a key that is not its |
| +// instance's group reference is never invited. |
| +func TestABoxCannotStandInForAnother(t *testing.T) { |
| + w := newEnrollWorld(t, "") |
| + w.pass(t) |
| + if got := w.pass(t); got != "invited" { |
| + t.Fatal(got) |
| + } |
| + inv, _ := w.invitation(t) |
| + box := status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: inv.Enrollment, InstanceID: "i-other", Release: "v1.0.0", ReleaseTag: "v1.0.0", |
| + Platform: "linux-amd64", BinarySHA256: w.hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: w.floor.Digest, FloorGuardReady: true, SystemChannel: "stable"}} |
| + raw, _ := json.Marshal(box) |
| + if _, err := w.d.Store.Put(context.Background(), "status/boxes/"+newRef+".json", raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := w.pass(t); got != "waiting for its guard-ready report" { |
| + t.Fatalf("another instance's report: %q", got) |
| + } |
| + // A record its box published under a key of its own choosing. |
| + v := newEnrollWorld(t, "") |
| + _, chosen := v.signer.Record(t, "chosen", "123", "eu-west-1", "i-chosen") |
| + if _, err := v.d.Store.Put(context.Background(), boxid.Key("chosen"), chosen, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + steps, err := v.e.EnrollPass(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, s := range steps { |
| + if s.Box == "chosen" && !strings.Contains(s.Step, "not under its group reference") { |
| + t.Fatalf("chosen key: %q", s.Step) |
| + } |
| + } |
| + if _, _, err := v.d.Store.Get(context.Background(), "control/host-actor-invitations/chosen.json"); err == nil { |
| + t.Fatal("a box with a chosen key was invited") |
| + } |
| } |
| |
| // A refused attempt is kept, with no more reads, for its wait, then begun |
| @@ -3445,7 +4047,7 @@ func TestEnrollPassLogsFailuresSparingly(t *testing.T) { |
| |
| - [ ] **Step 2: Run them to see them fail** |
| |
| -Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestARefusedAttempt|TestEnrollPass' -count=1` |
| +Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass' -count=1` |
| Expected: FAIL, neither package builds (`undefined: AutoEnroller`, `undefined: newAutoEnroller`). |
| |
| - [ ] **Step 3: Write the pass and start it** |
| @@ -3647,6 +4249,12 @@ func (e *AutoEnroller) step(ctx context.Context, o Observed, floor hostfloor.Ver |
| return Observed{}, fmt.Sprintf("its identity record does not verify: %v", err) |
| } |
| instance := ev.Cloud.InstanceID |
| + // A group box publishes under the reference its instance derives; |
| + // the pass derives it again and never takes the key the box chose |
| + // (census ruling region-ha-2b). |
| + if ref != GroupBoxReference(region.Account, instance) { |
| + return Observed{}, "its record is not under its group reference, so it is not enrolled automatically" |
| + } |
| in := EnrollmentRequest{Reference: ref, Namespace: e.Namespace, Label: ref, Region: name, Pool: e.Pool, AWSRegion: region.AWSRegion} |
| |
| _, _, err = d.Store.Get(ctx, "control/host-actor-invitations/"+ref+".json") |
| @@ -3659,7 +4267,9 @@ func (e *AutoEnroller) step(ctx context.Context, o Observed, floor hostfloor.Ver |
| if err != nil { |
| return Observed{}, fmt.Sprintf("its invitation is not for this record: %v", err) |
| } |
| - rep, err := e.Evidence.BoxReport(ctx, ref, Box{Namespace: e.Namespace, Label: ref}) |
| + // The report is read under the reference derived from the |
| + // attempt's proven instance (PrivateBoxReport, Group set). |
| + rep, err := e.Evidence.BoxReport(ctx, ref, Box{Namespace: e.Namespace, Label: ref, Group: region.GroupName, Cloud: ev.Cloud}) |
| if err != nil { |
| return Observed{}, "waiting for its guard-ready report" |
| } |
| @@ -3720,33 +4330,61 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| d := e.Directory |
| if invited { |
| next, ev, err := d.EnrollAutomatic(ctx, e.Signer, o, v, in, raw, instance, e.Members, e.Now) |
| + e.logRead(in, instance, ev, "enrolled", err) |
| if err != nil { |
| return Observed{}, fmt.Sprintf("it could not be enrolled: %v", err), err |
| } |
| - e.logRead(in.Reference, "enrolled", ev) |
| return next, fmt.Sprintf("enrolled in %s with the group's role, %s", name, RoleName(region.Role)), nil |
| } |
| _, ev, err := d.CheckConfirmed(ctx, o, v, in, raw, instance, e.Members, e.Now) |
| if err != nil { |
| + e.logRead(in, instance, ev, "", err) |
| return Observed{}, fmt.Sprintf("its membership could not be confirmed: %v", err), err |
| } |
| - // Invite is main's, and writes as soon as it has read the record: the |
| - // read is checked here, immediately before it. |
| - if err := (membershipConfirmation{observedAt: ev.ObservedAt}).fresh(e.Now()); err != nil { |
| + // Invite is main's and sealed. It runs on the store bound to the |
| + // read's deadline (writeBound), so its record read, its signature's |
| + // Put and any read-back are refused once the read is a minute old. |
| + // The check just before it is the same bound; nothing that waits sits |
| + // between the two. |
| + c := membershipConfirmation{observedAt: ev.ObservedAt} |
| + bd, bctx, cancel := writeBound(ctx, d, c, e.Now) |
| + defer cancel() |
| + if err := c.atWrite(e.Now()); err != nil { |
| + e.logRead(in, instance, ev, "", err) |
| return Observed{}, fmt.Sprintf("it could not be invited: %v", err), err |
| } |
| - if _, err := d.Invite(ctx, e.Signer, in.Reference, raw); err != nil { |
| + _, err = bd.Invite(bctx, e.Signer, in.Reference, raw) |
| + e.logRead(in, instance, ev, "invited", err) |
| + if err != nil { |
| return Observed{}, fmt.Sprintf("it could not be invited: %v", err), err |
| } |
| - e.logRead(in.Reference, "invited", ev) |
| return Observed{}, "invited", nil |
| } |
| |
| -// logRead records one membership read's request ids. |
| -func (e *AutoEnroller) logRead(ref, step string, ev MembershipEvidence) { |
| - if e.Logf != nil { |
| - e.Logf("console: automatic box enrollment: %s %s on the console's membership read at %s (request ids %s)", ref, step, ev.ObservedAt.Format(time.RFC3339), strings.Join(ev.RequestIDs, ", ")) |
| +// logRead records one membership read: the box, its attempt, its instance, |
| +// the cloud's two request ids and the read's time, and what came of it |
| +// (C2's audit: the roster has no field for them). A call that failed |
| +// before the cloud answered has no request ids and says so. |
| +func (e *AutoEnroller) logRead(in EnrollmentRequest, instance string, ev MembershipEvidence, done string, err error) { |
| + if e.Logf == nil { |
| + return |
| + } |
| + ids := "none" |
| + if len(ev.RequestIDs) > 0 { |
| + ids = strings.Join(ev.RequestIDs, ", ") |
| } |
| + at := "never" |
| + if !ev.ObservedAt.IsZero() { |
| + at = ev.ObservedAt.Format(time.RFC3339) |
| + } |
| + outcome := done |
| + if err != nil { |
| + outcome = "not " + done + ": " + err.Error() |
| + if done == "" { |
| + outcome = err.Error() |
| + } |
| + } |
| + e.Logf("console: automatic box enrollment: membership read for %s (attempt %s, instance %s, request ids %s, read at %s): %s", in.Reference, in.AttemptID, instance, ids, at, outcome) |
| } |
| |
| // groupRegion is the one roster region whose group could have launched |
| @@ -3930,7 +4568,7 @@ func consoleMembershipReader(ctx context.Context) (hostactors.MembershipReader, |
| |
| - [ ] **Step 4: Run the tests** |
| |
| -Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestARefusedAttempt|TestEnrollPass|TestEnrollAutomatic' -count=1` |
| +Run: `nice -n 19 go test ./internal/hostactors/ ./internal/console/ -run 'TestAutomaticEnrollment|TestAnInstanceOutside|TestAMembershipReadThatFails|TestAnInvitationNeeds|TestASlowRead|TestABoxCannotStandIn|TestARefusedAttempt|TestEnrollPass|TestEnrollAutomatic' -count=1` |
| Expected: PASS. |
| |
| - [ ] **Step 5: The provider boundary** |
| @@ -3940,16 +4578,16 @@ Expected: PASS. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -34,6 +34,8 @@ |
| - "internal/hostenv/cli.go", |
| +@@ -35,6 +35,8 @@ |
| "internal/host/installchannel.go", |
| "internal/hostactors/membershipread.go", |
| + "internal/hostactors/groupref.go", |
| + "internal/hostactors/autoenroll.go", |
| + "internal/console/enrollpass.go", |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| -@@ -43,6 +45,7 @@ |
| +@@ -44,6 +46,7 @@ |
| "internal/console/awsregionmetric.go", |
| "internal/hostenv/awsssm.go", |
| "internal/hostactors/awsmembership/awsmembership.go", |
| @@ -4012,17 +4650,17 @@ Expected: PASS. With the selected names added and the sites not yet appended, it |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -2,10 +2,14 @@ |
| - # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| - # Task 3: carlos system install-channel (internal/host installchannel.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and hands the cache directories it creates under the data dir to the service user, as bootstrap does. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| - # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader and the unexported confirmation; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves, and the box-written confirmation object is removed; enroll.go: the freshness check before the roster write; 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. |
| + # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. One new root-read unit, runHostEnv, whose one read site is seed's --from file; write runs as root, reads no file, and writes /etc/carlos/host.env 0600 from the parameters. Census ruling region-ha-2b is asked to class it (authority A,J4 proposed). The legacy seal stands. |
| + # 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). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write under its deadline, and the box-written confirmation object is removed; enroll.go: the check before the roster write; 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). |
| +# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Census ruling region-ha-2b is asked to accept it as legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| - @coverage internal/host f3b2fc63654dbe9e41ec940bce86acae3c39ced0258d42d6ed5dfa0d26d9e8f9 |
| + @coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| 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 |
| --@coverage internal/hostactors 12b588f47fa72da1798815cd44815b131415c15f7c3f7f8afeba31303cf690eb |
| -+@coverage internal/hostactors 7252eaf5a0789d874d76c117ca1ef5ab6fa30e743010e30922621dca3af40b83 |
| +-@coverage internal/hostactors 3b314e63b03a49d88c24b71c13dff924deabcad571b7b0007c638cc97285c6d4 |
| ++@coverage internal/hostactors 671e0952a044382542d1d130dfd1dfe7219a67c93c7fe25c2eb058aed825117a |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| +@coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| +@legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| @@ -4057,19 +4695,22 @@ git commit -m "region HA 2b: the console's automatic enrolment pass" |
| |
| --- |
| |
| -### Task 6: Mode selection |
| +### Task 6: Mode selection and group references |
| |
| **Files:** |
| -- Create: `internal/host/joinmode.go`, `internal/host/joinmode_test.go` |
| -- Modify: `region_ha_boundary_test.go`, the overlay |
| +- Create: `internal/host/joinmode.go`, `internal/host/joinmode_test.go`, `internal/host/boxreference.go`, `internal/host/boxreference_test.go` |
| +- Modify: `internal/host/metrics_loop.go` (`metricsBoxLabel`), `internal/host/boxrecord.go` (`boxRecordLoop`), `internal/host/agent.go` (`statusLoop`, under census ruling region-ha-2b) |
| +- Modify: `region_ha_boundary_test.go`, the overlay, `internal/provision/plan14_root_reads_test.go` (the seal) |
| |
| **Interfaces:** |
| -- Consumes: `hostfloor.ReadEnrollment`, `hostfloor.Disk.Running` (through `floorDisk`), `readGuardRoster`, `openDeployStoreEnv`, `openStatusStore`, `metricsBoxLabel`, `readMachineID`, `readIdentityRecord` (unchanged), `boxid.Load`, `status.Box`. |
| -- Produces: `agentMode` (`modeHosting`, `modeJoining`); `joinStage` (`stageNoInvitation`, `stageInvited`, `stageGuardInstalled`, `stageGuardReady`, `stageHeld`); `modeDecision{Mode, Stage, Box, Why}`; `errModeUnknown`; `modeFacts{box; enrollment; roster; guard; invited; report; relaunch; now}`; `selectMode(ctx, modeFacts) (modeDecision, error)`; `guardReadyReport`; `relaunched(row hostactors.Box, enrolled []byte, current boxid.Record) string`; `recordPendingTime`; `productionModeFacts(ctx, getenv) (modeFacts, error)`; `invitationKey(box string) string`; `const hostActorPath`, `reportFreshness`; `var agentHosting atomic.Bool` (set by Task 8's gate, read by its claim wiring). |
| +- Consumes: `hostfloor.ReadEnrollment`, `hostfloor.Disk.Running` (through `floorDisk`), `readGuardRoster`, `openDeployStoreEnv`, `openStatusStore`, `metricsBoxLabel`, `readMachineID`, `readIdentityRecord` (unchanged), `boxid.Load`, `status.Box`; `hostactors.GroupBoxReference` (Task 4); `hostenv.BoxReference` and `GroupReference` (Task 2). |
| +- Produces: `agentMode` (`modeHosting`, `modeJoining`); `joinStage` (`stageNoInvitation`, `stageInvited`, `stageGuardInstalled`, `stageGuardReady`, `stageHeld`); `modeDecision{Mode, Stage, Box, Why}`; `errModeUnknown`; `modeFacts{box; enrollment; roster; guard; invited; report; relaunch; now}`; `selectMode(ctx, modeFacts) (modeDecision, error)`; `guardReadyReport`; `relaunched(row hostactors.Box, enrolled []byte, current boxid.Record) string`; `recordPendingTime`; `productionModeFacts(ctx, getenv) (modeFacts, error)`; `invitationKey(box string) string`; `const hostActorPath`, `reportFreshness`; `var agentHosting atomic.Bool` (set by Task 8's gate, read by its claim wiring); `groupReferenced(getenv) bool`, `groupBoxReference() string`, `groupStatusLabel(getenv) (string, bool)`, `var groupIdentity` (the IMDS read, a seam). |
| |
| The decision every agent start makes (A2-4), as a pure function over seven facts, so each joining state and each unreadable fact can be tested without a box. It hosts only when `host-actor.json` names this box, the roster lists that enrollment and instance as active, and the guard is armed under the roster's floor for this binary. Anything else is joining, at the stage the facts show: no invitation; invited (also when guard-arm wrote `host-actor.json` but was cut off before it armed); guard installed; guard-ready reported (a fresh report of this enrollment and image is published); or held (the roster names the box, but not as an active entry for its enrollment and armed guard). A fact that cannot be read is `errModeUnknown`: a roster that fails to read is never "not enrolled". |
| |
| -**A group box started again (C3).** A box its region's group launched (its roster entry names a `Group`) hosts only on the launch it was enrolled on. Without that rule a stopped instance that is started again finds `host-actor.json`, its armed guard and its active entry all intact, and would host on a stale enrolment. EC2 sets the identity document's `pendingTime` each time the instance enters `pending`: at launch and at every start, never at a reboot. So the hosting decision reads this instance's identity document from IMDS and the record published under its reference, requires that record to be the one the roster entry binds (by digest), and holds the box when the two `pendingTime`s differ. A held group box answers 503 and its group replaces it; it cannot prove membership again under the same reference while its entry stands. An unreadable launch is unknown. A box an operator enrolled (no `Group`) is not asked: a fixed box is stopped and started for maintenance. |
| +**A group box started again (C3).** A box its region's group launched (its roster entry names a `Group`) hosts only on the launch it was enrolled on. Without that rule a stopped instance that is started again finds `host-actor.json`, its armed guard and its active entry all intact, and would host on a stale enrolment. EC2 sets the identity document's `pendingTime` each time the instance enters `pending`: at launch and at every start, never at a reboot (Open question 7, an activation gate). So the hosting decision reads this instance's identity document from IMDS and the record published under its reference, requires that record to be the one the roster entry binds (by digest), and holds the box when the two `pendingTime`s differ. A held group box answers 503 and its group replaces it; it cannot prove membership again under the same reference while its entry stands. An unreadable launch is unknown. A box an operator enrolled (no `Group`) is not asked: a fixed box is stopped and started for maintenance. |
| + |
| +**Group references (S3, census ruling region-ha-2b).** A box whose host.env says `CARLOS_BOX_REFERENCE=group` (Task 2's `write` adds it) names itself by `GroupBoxReference` of the account and instance in its signed identity document, read from IMDS once and kept (`groupBoxReference`). `metricsBoxLabel` answers it, so every caller that names this box (the identity record, mode selection, root's join, the edges file, metrics, layout and rollout) agrees. An identity IMDS has not given is `""`, never a label in its place: `productionModeFacts` treats it as unknown, and `boxRecordLoop` asks again each minute instead of giving up as it would for a reference that is not a label. `statusLoop`, accepted-legacy, gains the one branch platform-1e approved: before the fleet label, a group box's status report is published under its group reference (`groupStatusLabel`), the key the console derives (Task 4's `PrivateBoxReport`). Every other box, fleet boxes included, takes the old branches with today's label and key byte for byte, and the public `status/fleet.json` (main's `BuildFleet`, unchanged) shows a group box only by its pseudonym. The joining report (Task 8) is written under the same reference. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -4332,12 +4973,107 @@ func TestRelaunchedComparesTheLaunchTheRosterBinds(t *testing.T) { |
| } |
| ```` |
| |
| +`internal/host/boxreference_test.go`: |
| + |
| +````go |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostenv" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// withGroupIdentity makes IMDS answer account and instance (or fail) for |
| +// one test, with no reference kept from before. |
| +func withGroupIdentity(t *testing.T, account, instance string, err error) { |
| + t.Helper() |
| + was := groupIdentity |
| + groupIdentity = func() (string, string, error) { return account, instance, err } |
| + groupRef.Lock() |
| + groupRef.ref = "" |
| + groupRef.Unlock() |
| + t.Cleanup(func() { |
| + groupIdentity = was |
| + groupRef.Lock() |
| + groupRef.ref = "" |
| + groupRef.Unlock() |
| + }) |
| +} |
| + |
| +func groupEnv(vals map[string]string) func(string) string { |
| + return func(k string) string { return vals[k] } |
| +} |
| + |
| +// A box its region's group launched takes its group reference everywhere |
| +// it names itself: the identity record, the status report, mode |
| +// selection, root's join. An identity IMDS has not given is no reference, |
| +// never a label in its place, and is asked again. |
| +func TestAGroupBoxTakesItsGroupReference(t *testing.T) { |
| + group := groupEnv(map[string]string{hostenv.BoxReference: hostenv.GroupReference}) |
| + withGroupIdentity(t, "", "", errors.New("IMDS answered 500")) |
| + if got := metricsBoxLabel(group, "machine"); got != "" { |
| + t.Fatalf("no identity: %q", got) |
| + } |
| + if ref, ok := groupStatusLabel(group); !ok || ref != "" { |
| + t.Fatalf("status label with no identity: %q %v", ref, ok) |
| + } |
| + withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil) |
| + want := hostactors.GroupBoxReference("123456789012", "i-0a61f580df1acfa65") |
| + if got := metricsBoxLabel(group, "machine"); got != want { |
| + t.Fatalf("group box: %q, want %q", got, want) |
| + } |
| + if ref, ok := groupStatusLabel(group); !ok || ref != want { |
| + t.Fatalf("status label: %q %v", ref, ok) |
| + } |
| +} |
| + |
| +// The ruling's third test: a box that is not a group box keeps its label |
| +// and its status key byte for byte, and statusLoop takes its old branches. |
| +func TestABoxThatIsNotAGroupBoxKeepsItsLabelAndKey(t *testing.T) { |
| + withGroupIdentity(t, "123456789012", "i-0a61f580df1acfa65", nil) |
| + for name, env := range map[string]map[string]string{ |
| + "no marker": {}, |
| + "another value": {hostenv.BoxReference: "instance-id"}, |
| + "a platform fleet": {"CARLOS_REGION_HA": "1", "CARLOS_HOST_CLAIM": "1"}, |
| + } { |
| + getenv := groupEnv(env) |
| + if got := metricsBoxLabel(getenv, "machine-1"); got != status.Label("machine-1") { |
| + t.Fatalf("%s: label %q, want %q", name, got, status.Label("machine-1")) |
| + } |
| + if _, ok := groupStatusLabel(getenv); ok { |
| + t.Fatalf("%s: statusLoop would override its label", name) |
| + } |
| + } |
| + fleet := groupEnv(map[string]string{"CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "kitchen"}) |
| + if got := metricsBoxLabel(fleet, "machine-1"); got != "kitchen" { |
| + t.Fatalf("fleet box: %q", got) |
| + } |
| + if _, ok := groupStatusLabel(fleet); ok { |
| + t.Fatal("a fleet box's label overridden") |
| + } |
| + // The key its report lands under is CollectBox's, as on main. |
| + s := release.NewDirStore(t.TempDir()) |
| + if err := status.WriteBox(context.Background(), s, status.CollectBox(nil, status.Probes{MachineID: "machine-1"}, joinNow)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := s.Get(context.Background(), "status/boxes/"+status.Label("machine-1")+".json"); err != nil { |
| + t.Fatalf("the status key moved: %v", err) |
| + } |
| +} |
| +```` |
| + |
| - [ ] **Step 2: Run them to see them fail** |
| |
| -Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched' -count=1` |
| -Expected: FAIL, the package does not build (`undefined: modeFacts`). |
| +Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched|TestAGroupBox|TestABoxThatIsNot' -count=1` |
| +Expected: FAIL, the package does not build (`undefined: modeFacts`, `undefined: groupIdentity`). |
| |
| -- [ ] **Step 3: Write the selection** |
| +- [ ] **Step 3: Write the selection and the reference** |
| |
| `internal/host/joinmode.go`: |
| |
| @@ -4596,6 +5332,9 @@ func productionModeFacts(ctx context.Context, getenv func(string) string) (modeF |
| } |
| bucket := getenv("CARLOS_DEPLOYMENT_BUCKET") |
| box := metricsBoxLabel(getenv, readMachineID()) |
| + if box == "" { |
| + return modeFacts{}, errors.New("this box's group reference could not be read") |
| + } |
| imds := &http.Client{Timeout: 2 * time.Second} |
| return modeFacts{ |
| box: box, |
| @@ -4650,9 +5389,170 @@ func invitationKey(box string) string { return "control/host-actor-invitations/" |
| var agentHosting atomic.Bool |
| ```` |
| |
| +`internal/host/boxreference.go`: |
| + |
| +````go |
| +package host |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostenv" |
| +) |
| + |
| +// A box its region's group launched is referenced by its group reference |
| +// (census ruling region-ha-2b): hostactors.GroupBoxReference of its account |
| +// and instance, "g-" and 20 hex digits. Its host.env says so: |
| +// carlos system host-env write adds CARLOS_BOX_REFERENCE=group. Status |
| +// labels come from 1,600 values and identity records are never deleted, so |
| +// as a group replaces its boxes, labels collide and a box whose label |
| +// another record holds can never publish its own; group references don't |
| +// collide, and are a pseudonym, so the public status feed shows no |
| +// instance id. Every other box, fleet boxes included, keeps its label and |
| +// its status key exactly as before. |
| + |
| +// groupReferenced reports whether this box takes its group reference. |
| +func groupReferenced(getenv func(string) string) bool { |
| + return strings.TrimSpace(getenv(hostenv.BoxReference)) == hostenv.GroupReference |
| +} |
| + |
| +// groupIdentity reads this box's account and instance from its signed |
| +// identity document (IMDS); a seam for tests. |
| +var groupIdentity = func() (account, instance string, err error) { |
| + rec, err := readIdentityRecord(&http.Client{Timeout: 2 * time.Second}, imdsBase, "", time.Now()) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + var d struct { |
| + AccountID string `json:"accountId"` |
| + InstanceID string `json:"instanceId"` |
| + } |
| + if err := json.Unmarshal(doc, &d); err != nil { |
| + return "", "", err |
| + } |
| + if d.AccountID == "" || d.InstanceID == "" { |
| + return "", "", errors.New("the identity document names no account or instance") |
| + } |
| + return d.AccountID, d.InstanceID, nil |
| +} |
| + |
| +var groupRef struct { |
| + sync.Mutex |
| + ref string |
| +} |
| + |
| +// groupBoxReference is this box's group reference, read once and kept. "" |
| +// while IMDS has not answered: never a label in its place. |
| +func groupBoxReference() string { |
| + groupRef.Lock() |
| + defer groupRef.Unlock() |
| + if groupRef.ref != "" { |
| + return groupRef.ref |
| + } |
| + account, instance, err := groupIdentity() |
| + if err != nil { |
| + return "" |
| + } |
| + groupRef.ref = hostactors.GroupBoxReference(account, instance) |
| + return groupRef.ref |
| +} |
| + |
| +// groupStatusLabel is statusLoop's label for a box its region's group |
| +// launched: its group reference, and true. For every other box it is |
| +// false, and statusLoop chooses its label exactly as before (a fleet box's |
| +// channel label, or CollectBox's hashed pseudonym). |
| +func groupStatusLabel(getenv func(string) string) (string, bool) { |
| + if !groupReferenced(getenv) { |
| + return "", false |
| + } |
| + return groupBoxReference(), true |
| +} |
| +```` |
| + |
| +`internal/host/metrics_loop.go`: |
| + |
| +````diff |
| +--- a/internal/host/metrics_loop.go |
| ++++ b/internal/host/metrics_loop.go |
| +@@ -715,6 +715,10 @@ |
| + // label rather than disabling metrics outright, mirroring statusLoop's own |
| + // degrade-don't-disable choice. |
| + func metricsBoxLabel(getenv func(string) string, machineID string) string { |
| ++ if groupReferenced(getenv) { |
| ++ // A box its region's group launched (boxreference.go). |
| ++ return groupBoxReference() |
| ++ } |
| + if identity, err := resolveFleetIdentity(getenv); err == nil { |
| + return identity.Label |
| + } else if !errors.Is(err, errNotFleetBox) { |
| +```` |
| + |
| +`internal/host/boxrecord.go`: |
| + |
| +````diff |
| +--- a/internal/host/boxrecord.go |
| ++++ b/internal/host/boxrecord.go |
| +@@ -170,11 +170,17 @@ |
| + log.Printf("agent: box record not published: %v", err) |
| + return |
| + } |
| +- box := metricsBoxLabel(os.Getenv, readMachineID()) |
| + client := &http.Client{Timeout: 2 * time.Second} |
| +- read := func() (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) } |
| + for attempt := 1; ; attempt++ { |
| +- done, err := publishBoxRecord(ctx, store, box, read) |
| ++ // A group box's reference comes from its identity document, which |
| ++ // IMDS may not answer yet: that is a retry, never a refusal |
| ++ // (boxreference.go). |
| ++ box := metricsBoxLabel(os.Getenv, readMachineID()) |
| ++ read := func() (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) } |
| ++ done, err := false, errors.New("this box's group reference could not be read") |
| ++ if box != "" { |
| ++ done, err = publishBoxRecord(ctx, store, box, read) |
| ++ } |
| + if done { |
| + if err != nil { |
| + log.Printf("agent: box record for %s: %v (region HA cannot enroll this box until an operator resolves it)", box, err) |
| +```` |
| + |
| +`statusLoop`'s one branch, under census ruling region-ha-2b: |
| + |
| +`internal/host/agent.go`: |
| + |
| +````diff |
| +--- a/internal/host/agent.go |
| ++++ b/internal/host/agent.go |
| +@@ -3506,7 +3506,13 @@ |
| + // loudly, to arm the binding-publish machinery that actually needs the |
| + // label to be dial-able). |
| + fleetLabel := "" |
| +- if identity, err := resolveFleetIdentity(os.Getenv); err == nil { |
| ++ if ref, ok := groupStatusLabel(os.Getenv); ok { |
| ++ // A box its region's group launched publishes under its group |
| ++ // reference, the key the console derives from its instance (census |
| ++ // ruling region-ha-2b, boxreference.go). Every other box takes the |
| ++ // branches below, exactly as before. |
| ++ fleetLabel = ref |
| ++ } else if identity, err := resolveFleetIdentity(os.Getenv); err == nil { |
| + fleetLabel = identity.Label |
| + } else if !errors.Is(err, errNotFleetBox) { |
| + log.Printf("agent: %v — heartbeat will publish under the hashed pseudonym instead", err) |
| +```` |
| + |
| - [ ] **Step 4: Run the tests** |
| |
| -Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched' -count=1` |
| +Run: `nice -n 19 go test ./internal/host/ -run 'TestSelectMode|TestRelaunched|TestAGroupBox|TestABoxThatIsNot|TestPublishBoxRecord|TestMetricsBoxLabel' -count=1` |
| Expected: PASS. |
| |
| - [ ] **Step 5: The provider boundary** |
| @@ -4662,11 +5562,12 @@ Expected: PASS. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -36,6 +36,7 @@ |
| - "internal/hostactors/membershipread.go", |
| +@@ -37,6 +37,8 @@ |
| + "internal/hostactors/groupref.go", |
| "internal/hostactors/autoenroll.go", |
| "internal/console/enrollpass.go", |
| + "internal/host/joinmode.go", |
| ++ "internal/host/boxreference.go", |
| } |
| |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
| @@ -4677,26 +5578,47 @@ Expected: PASS. |
| |
| - [ ] **Step 6: Census** |
| |
| -No root-read unit is added: the facts read through `hostfloor.ReadEnrollment`, `readGuardRoster`, `Disk.Running`, `boxid.Load` and `readIdentityRecord`, unchanged. |
| +`statusLoop` is re-sealed as platform-1e ruled: its `@legacy` row is replaced in the 2b overlay with basis `census-ruling-region-ha-2b`, its new kinds (the same read sites, offsets moved) and digest, and the seal moves. No other root-read unit is added: the facts read through `hostfloor.ReadEnrollment`, `readGuardRoster`, `Disk.Running`, `boxid.Load` and `readIdentityRecord`, unchanged, and `metricsBoxLabel` and `boxRecordLoop` are not census units. |
| |
| `internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv`: |
| |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -3,10 +3,11 @@ |
| - # Task 3: carlos system install-channel (internal/host installchannel.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and hands the cache directories it creates under the data dir to the service user, as bootstrap does. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| - # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader and the unexported confirmation; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves, and the box-written confirmation object is removed; enroll.go: the freshness check before the roster write; 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. |
| +@@ -3,13 +3,16 @@ |
| + # 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). Census ruling region-ha-2b is asked to class it (authority F,J4 proposed). stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write under its deadline, and the box-written confirmation object is removed; enroll.go: the check before the roster write; 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). |
| # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Census ruling region-ha-2b is asked to accept it as legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| -+# Task 6: mode selection (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held). It reads through hostfloor.ReadEnrollment, readGuardRoster, hostfloor.Disk.Running, boxid.Load and readIdentityRecord, all unchanged, and adds no root-read unit. Envelope-only, and the seal stands. |
| ++# Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference). statusLoop, accepted-legacy, is re-sealed under census ruling region-ha-2b: one branch before the fleet label publishes a group box's status report under its group reference; every other box takes the old branches. No other root-read unit is added or changed. The seal moves. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| --@coverage internal/host f3b2fc63654dbe9e41ec940bce86acae3c39ced0258d42d6ed5dfa0d26d9e8f9 |
| -+@coverage internal/host 8e9eedb8c9716f46de0e4db3b3a5df79a7c6dc8391211e4001cfc885ce85c204 |
| +-@coverage internal/host 64ee4f561f2a28d584a6f7c82e068e57db4822f6cbb130116284bca583d2d390 |
| ++@coverage internal/host 04a0538e3195ab9d51926e7b4cc4df82cbdc96408272d0dd5e710d6368ab56cc |
| 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 |
| - @coverage internal/hostactors 7252eaf5a0789d874d76c117ca1ef5ab6fa30e743010e30922621dca3af40b83 |
| + @coverage internal/hostactors 671e0952a044382542d1d130dfd1dfe7219a67c93c7fe25c2eb058aed825117a |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| + @coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| + @legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| + internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| ++@legacy e90d59e6fb8f86be00ae9d594f3f391af984aaf32e176f1fa94f9f59358d2ea5 internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with one branch added before the fleet label, so a box its region's group launched (host.env's CARLOS_BOX_REFERENCE=group) publishes its status report under its group reference, the key the console derives from the instance its roster entry pins; every other box, fleet boxes included, takes the old branches with today's label and key; read sites unchanged, offsets moved whole:Route@6474,whole:Route@6501,whole:Route@7743,whole:Route@9757 eb4850f84227ea329eba4584e882cdc433afc68d581de644d502f61e1ec91cc1 |
| ++internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with one branch added before the fleet label, so a box its region's group launched (host.env's CARLOS_BOX_REFERENCE=group) publishes its status report under its group reference, the key the console derives from the instance its roster entry pins; every other box, fleet boxes included, takes the old branches with today's label and key; read sites unchanged, offsets moved whole:Route@6474,whole:Route@6501,whole:Route@7743,whole:Route@9757 eb4850f84227ea329eba4584e882cdc433afc68d581de644d502f61e1ec91cc1 |
| +```` |
| + |
| +`internal/provision/plan14_root_reads_test.go`: |
| + |
| +````diff |
| +--- a/internal/provision/plan14_root_reads_test.go |
| ++++ b/internal/provision/plan14_root_reads_test.go |
| +@@ -816,7 +816,7 @@ |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| +- seal = "8afff5c9874f6d31079ffb2e722274b0b1f82eef7ae228e3c816245c239a93e1" |
| ++ seal = "78fb616cf767c984dd500a80c18bedfca0d4faeedb6d8b2681658ce826fc0cd1" |
| + errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| + errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| + if len(errs) > 0 { |
| ```` |
| |
| Run: `nice -n 19 go test ./internal/provision/ -run 'TestPlan9B8RecordWriteCensus|TestPlan14RootReadCensus$' -count=1 && nice -n 19 go test ./internal/console/ -run TestClaimAuthoritySourceCensus -count=1` |
| @@ -4705,8 +5627,8 @@ Expected: PASS. |
| - [ ] **Step 7: Commit** |
| |
| ```bash |
| -git add internal/host/joinmode.go internal/host/joinmode_test.go region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -git commit -m "region HA 2b: mode selection" |
| +git add internal/host/joinmode.go internal/host/joinmode_test.go internal/host/boxreference.go internal/host/boxreference_test.go internal/host/metrics_loop.go internal/host/boxrecord.go internal/host/agent.go region_ha_boundary_test.go internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv internal/provision/plan14_root_reads_test.go |
| +git commit -m "region HA 2b: mode selection and group references" |
| ``` |
| |
| --- |
| @@ -5179,10 +6101,10 @@ Expected: PASS. |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -37,6 +37,7 @@ |
| - "internal/hostactors/autoenroll.go", |
| +@@ -39,6 +39,7 @@ |
| "internal/console/enrollpass.go", |
| "internal/host/joinmode.go", |
| + "internal/host/boxreference.go", |
| + "internal/host/joinroot.go", |
| } |
| |
| @@ -5224,22 +6146,23 @@ The two unit files are new root inputs, which census ruling region-ha-2b is aske |
| ````diff |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| -@@ -4,13 +4,17 @@ |
| - # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader and the unexported confirmation; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves, and the box-written confirmation object is removed; enroll.go: the freshness check before the roster write; 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. |
| +@@ -4,10 +4,11 @@ |
| + # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write under its deadline, and the box-written confirmation object is removed; enroll.go: the check before the roster write; 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). |
| # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Census ruling region-ha-2b is asked to accept it as legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| - # Task 6: mode selection (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held). It reads through hostfloor.ReadEnrollment, readGuardRoster, hostfloor.Disk.Running, boxid.Load and readIdentityRecord, all unchanged, and adds no root-read unit. Envelope-only, and the seal stands. |
| + # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference). statusLoop, accepted-legacy, is re-sealed under census ruling region-ha-2b: one branch before the fleet label publishes a group box's status report under its group reference; every other box takes the old branches. No other root-read unit is added or changed. The seal moves. |
| +# Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs; census ruling region-ha-2b is asked to class them as carlos-system-update.path and .service are (authority F,J4). The legacy seal stands. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| --@coverage internal/host 8e9eedb8c9716f46de0e4db3b3a5df79a7c6dc8391211e4001cfc885ce85c204 |
| -+@coverage internal/host b3fe66df6001f644f7f3f3e2a2d4cb4ee1169391761da736dc9fa901d8690b4f |
| +-@coverage internal/host 04a0538e3195ab9d51926e7b4cc4df82cbdc96408272d0dd5e710d6368ab56cc |
| ++@coverage internal/host f46c78584a9977e8d050a57a1242ed7210a03a73a9e3c585ccaf6fae4f47fca5 |
| 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 |
| - @coverage internal/hostactors 7252eaf5a0789d874d76c117ca1ef5ab6fa30e743010e30922621dca3af40b83 |
| + @coverage internal/hostactors 671e0952a044382542d1d130dfd1dfe7219a67c93c7fe25c2eb058aed825117a |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| - @coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| - @legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| +@@ -16,3 +17,6 @@ |
| internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |
| + @legacy e90d59e6fb8f86be00ae9d594f3f391af984aaf32e176f1fa94f9f59358d2ea5 internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with one branch added before the fleet label, so a box its region's group launched (host.env's CARLOS_BOX_REFERENCE=group) publishes its status report under its group reference, the key the console derives from the instance its roster entry pins; every other box, fleet boxes included, takes the old branches with today's label and key; read sites unchanged, offsets moved whole:Route@6474,whole:Route@6501,whole:Route@7743,whole:Route@9757 eb4850f84227ea329eba4584e882cdc433afc68d581de644d502f61e1ec91cc1 |
| + internal/host/agent.go statusLoop legacy accepted-legacy accepted-legacy;rev3=census-ruling-region-ha-2b;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with one branch added before the fleet label, so a box its region's group launched (host.env's CARLOS_BOX_REFERENCE=group) publishes its status report under its group reference, the key the console derives from the instance its roster entry pins; every other box, fleet boxes included, takes the old branches with today's label and key; read sites unchanged, offsets moved whole:Route@6474,whole:Route@6501,whole:Route@7743,whole:Route@9757 eb4850f84227ea329eba4584e882cdc433afc68d581de644d502f61e1ec91cc1 |
| +@coverage internal/host/units 18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d |
| +internal/host/units/carlos-system-join.path external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; root takes nothing from the marker file:external@0 8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c |
| +internal/host/units/carlos-system-join.service external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept file:external@0 98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b |
| @@ -5270,7 +6193,7 @@ git commit -m "region HA 2b: root's half of joining" |
| |
| `RunAgent` asks the gate first. With claim mode or region HA off it returns "hosting" at once and `runAgent` starts as on main; `runAgent` itself is not changed. Otherwise the gate selects the mode, waiting out an unknown one, and on joining runs joining until the box can host, then returns, so `RunAgent` ends and systemd (`Restart=always`) starts the agent again through the guard's drop-in, as the claim agent. |
| |
| -Joining runs the identity record loop, a listener on the edge's `-http` address that answers only `/.carlos/lb-health` (503: `lbhealth.Default` is cleared at the start and never set), and a check every 15 seconds. Invited, it touches `/data/system-join-requested` (Task 7's path unit), at most once a minute. Guard installed or reported, it publishes the guard-ready report: the status report every hosting box publishes, with the guard's facts from `claimReport`, once a minute so it stays inside the console's 15-minute window. It never starts the edges file loop, opens no registry and starts no activator. |
| +Joining runs the identity record loop, a listener on the edge's `-http` address that answers only `/.carlos/lb-health` (503: `lbhealth.Default` is cleared at the start and never set), and a check every 15 seconds. Invited, it touches `/data/system-join-requested` (Task 7's path unit), at most once a minute. Guard installed or reported, it publishes the guard-ready report: the status report every hosting box publishes, with the guard's facts from `claimReport`, once a minute so it stays inside the console's 15-minute window. A group box writes it under its group reference (Task 6), the key the console reads it from. It never starts the edges file loop, opens no registry and starts no activator. |
| |
| On a hosting box, `ClaimWiring.startRegional` now reports the gate's decision to the health answer, before the claim worker starts, and clears it when the worker ends (2a's seam). A hosting box does nothing for membership: the console reads it (H28). |
| |
| @@ -5698,7 +6621,13 @@ func productionJoining(args []string, facts modeFacts, getenv func(string) strin |
| p := status.Probes{MachineID: readMachineID(), InstanceID: cachedInstanceID(), Region: strings.TrimSpace(getenv("CARLOS_REGION")), |
| Release: version.Build, Platform: runtime.GOOS + "-" + runtime.GOARCH, SystemChannel: boxSystemChannel(getenv)} |
| claimReport(&p) |
| - return status.WriteBox(ctx, store, status.CollectBox(nil, p, time.Now())) |
| + box := status.CollectBox(nil, p, time.Now()) |
| + if groupReferenced(getenv) { |
| + // Under the group reference the console derives (census |
| + // ruling region-ha-2b), as statusLoop publishes it. |
| + box.Label = facts.box |
| + } |
| + return status.WriteBox(ctx, store, box) |
| }, |
| requestArm: func() error { |
| return os.WriteFile(filepath.Join(dataDir(), joinRequestMarker), []byte(time.Now().UTC().Format(time.RFC3339Nano)+"\n"), 0o644) |
| @@ -5855,9 +6784,9 @@ Expected: PASS. The joining test listens on a loopback port; in a sandbox with n |
| ````diff |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| -@@ -38,6 +38,7 @@ |
| - "internal/console/enrollpass.go", |
| +@@ -40,6 +40,7 @@ |
| "internal/host/joinmode.go", |
| + "internal/host/boxreference.go", |
| "internal/host/joinroot.go", |
| + "internal/host/joining.go", |
| } |
| @@ -5879,16 +6808,16 @@ No root-read unit changed or was added: `runAgent` is untouched, `RunAgent` and |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -5,10 +5,11 @@ |
| # Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Census ruling region-ha-2b is asked to accept it as legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| - # Task 6: mode selection (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held). It reads through hostfloor.ReadEnrollment, readGuardRoster, hostfloor.Disk.Running, boxid.Load and readIdentityRecord, all unchanged, and adds no root-read unit. Envelope-only, and the seal stands. |
| + # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference). statusLoop, accepted-legacy, is re-sealed under census ruling region-ha-2b: one branch before the fleet label publishes a group box's status report under its group reference; every other box takes the old branches. No other root-read unit is added or changed. The seal moves. |
| # Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs; census ruling region-ha-2b is asked to class them as carlos-system-update.path and .service are (authority F,J4). The legacy seal stands. |
| +# Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| --@coverage internal/host b3fe66df6001f644f7f3f3e2a2d4cb4ee1169391761da736dc9fa901d8690b4f |
| -+@coverage internal/host 5c03d6315a3be9d2e37fdac00ffbd2484e3a67fe2559ebe04bde976ce1e188f5 |
| +-@coverage internal/host f46c78584a9977e8d050a57a1242ed7210a03a73a9e3c585ccaf6fae4f47fca5 |
| ++@coverage internal/host c4a036b72f7dbcb964c13a0f822f05269f41116feff68562e987477ad6217600 |
| 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 |
| - @coverage internal/hostactors 7252eaf5a0789d874d76c117ca1ef5ab6fa30e743010e30922621dca3af40b83 |
| + @coverage internal/hostactors 671e0952a044382542d1d130dfd1dfe7219a67c93c7fe25c2eb058aed825117a |
| @coverage internal/hostactors/awsmembership a3defad7179245492a3c1dcfce5e20f0f85db2e240b474c654acf211da16df98 |
| ```` |
| |
| @@ -6044,7 +6973,7 @@ Expected: PASS. |
| +carlos system host-env seed --deployment prod --region ie --kms-key alias/carlos-prod-config |
| +``` |
| + |
| -+Seeding needs ssm:PutParameter and kms:Encrypt on the deployment's configuration key. |
| ++Seeding needs ssm:GetParametersByPath and ssm:PutParameter on the deployment's two parameter paths, and kms:Encrypt and kms:Decrypt on its configuration key. |
| + |
| ### carlos move |
| |
| @@ -6084,16 +7013,16 @@ Expected: PASS once the rows are approved. While any built row is pending, `Test |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -6,7 +6,8 @@ |
| - # Task 6: mode selection (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held). It reads through hostfloor.ReadEnrollment, readGuardRoster, hostfloor.Disk.Running, boxid.Load and readIdentityRecord, all unchanged, and adds no root-read unit. Envelope-only, and the seal stands. |
| + # Task 6: mode selection and group references (internal/host joinmode.go: selectMode, productionModeFacts, relaunched: a group box stopped and started since its enrollment is held; boxreference.go, metrics_loop.go's metricsBoxLabel and boxrecord.go's boxRecordLoop: a group box takes its group reference). statusLoop, accepted-legacy, is re-sealed under census ruling region-ha-2b: one branch before the fleet label publishes a group box's status report under its group reference; every other box takes the old branches. No other root-read unit is added or changed. The seal moves. |
| # Task 7: root's half of joining (internal/host joinroot.go: carlos system join, which runs guard-arm in process; units carlos-system-join.path and .service; bootstrap's enabledUnits arms the path). joinroot.go adds no root-read unit: RunSystemGuardArm, authority F,J4, is called and not changed. The two unit files are new root inputs; census ruling region-ha-2b is asked to class them as carlos-system-update.path and .service are (authority F,J4). The legacy seal stands. |
| # Task 8: joining mode and the gate (internal/host joining.go; RunAgent calls hostingGate before runAgent; ClaimWiring.startRegional sets the health answer's hosting condition from the gate's decision and clears it when the worker ends). runAgent, authority B,C,D,F,J4, is not changed. No root-read unit changed or was added. Envelope-only, and the seal stands. |
| -@coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| +# Task 9 (system.go): the install-channel, join and host-env verbs join systemVerb's list and runSystem's dispatch, and the usage gains their lines. The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added. |
| +@coverage . 374a07016556167813b331508ee60f696872c43a09ba10904b6f3d949e0b70a6 |
| - @coverage internal/hostenv 14f5ebed5c32d04bd2c2a4071d72b28fa89372f3e1166d25155c0be1661a0598 |
| + @coverage internal/hostenv be875f5a525686ee08c5959863cf17231f1ad27aa5202f79aa0fc7cd1408f388 |
| 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, from the deployment's parameters, 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 file:ReadFile@1387 d74d22c3b23ff864adfef72f14708a6b9c9f2b0ee0be414a087b3f444b328fbf |
| - @coverage internal/host 5c03d6315a3be9d2e37fdac00ffbd2484e3a67fe2559ebe04bde976ce1e188f5 |
| -@@ -19,3 +20,5 @@ |
| + @coverage internal/host c4a036b72f7dbcb964c13a0f822f05269f41116feff68562e987477ad6217600 |
| +@@ -21,3 +22,5 @@ |
| @coverage internal/host/units 18a7bb1fe57b2044517e7acd3f97780beae35fd55e695c593f87a4ae7f3af27d |
| internal/host/units/carlos-system-join.path external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) starts carlos-system-join.service when the joining agent touches /data/system-join-requested; root takes nothing from the marker file:external@0 8af1bea945a0ec5a475b766e6b55d7168bf6eff1bf963bd5c7d46f90130f4c5c |
| internal/host/units/carlos-system-join.service external authority F,J4 rev3=census-ruling-region-ha-2b;basis=systemd (PID 1) runs carlos system join as root: it verifies the signed invitation and this box's identity record itself, then runs guard-arm with the identity install-channel kept file:external@0 98c405ad69c7fd3e265ddfcaf176b0a5b98f0e61e35c4ae29a553211f63d230b |
| @@ -6147,7 +7076,7 @@ What 2c builds, and exactly what 2b needs from it: |
| - **No** Auto Scaling or EC2 describe, and **no** write under `control/box-enrollment/` (H28, C4). §4's rev 15 box grant of `autoscaling:DescribeAutoScalingInstances` and `DescribeAutoScalingGroups` is withdrawn; 2c must not add it. |
| - S3 in the deployment bucket: `GetObject` on `control/host-actor-invitations/*`, `control/boxes/*`, `control/host-actors.json`, `control/moves-protocol.json`, `control/moves-protocol-auth.json`, `control/system/router/*` (the channel pointer and release identities), `bootstrap/router/*` (artifacts), `bootstrap/carlos` (the script's fetch), `control/box-fences/*`, and `status/boxes/*`; `PutObject` on the box's own `status/boxes/<label>.json` (as today) and `control/boxes/<label>.json` (create-only, as today). Most of these the box role already has on main; 2c should check the list against the module rather than add duplicates. |
| - **The negative check, 2c's infrastructure test (C4).** With the box role's credentials, both of these must be denied: creating an object under `control/box-enrollment/` (`aws s3api put-object --bucket <deployment bucket> --key control/box-enrollment/probe/probe/probe.json --if-none-match '*'`), and `aws autoscaling describe-auto-scaling-instances --instance-ids <a group instance>` in the region's AWS region. `aws ec2 describe-instances` should be checked the same way. |
| -4. **IAM, operator seeding:** `ssm:PutParameter` (with and without overwrite) and `ssm:GetParametersByPath` on the two paths, and `kms:Encrypt` on the configuration key. |
| +4. **IAM, operator seeding** (S4, Astra round 2 new #2): `ssm:PutParameter` (with and without overwrite) and `ssm:GetParametersByPath` on the two paths; `kms:Encrypt` on the configuration key; and `kms:Decrypt` on it, conditioned on `kms:ViaService = ssm.<region>.amazonaws.com` and the `PARAMETER_ARN` encryption context matching the two paths. `seed` reads the shared values decrypted (`ByPath` asks with `WithDecryption`) to compare them, and reads one back after a lost create race, so without `kms:Decrypt` seeding a second region, or resolving a race, fails. RHB-C61 names all four. |
| 5. **IAM, console role** (H28, C4): `autoscaling:DescribeAutoScalingInstances` and `ec2:DescribeInstances`, read-only, conditioned on `aws:RequestedRegion` being one of the deployment's AWS regions (every region the roster's `Regions` name, so an emptied region recovers the same way). Neither action takes a resource ARN, so the region condition is the limit. No other new AWS call. The enrolment pass reads and writes the deployment bucket as the console already does: `control/host-actors.json` (CAS replace), `control/host-actor-invitations/*` (create-only), and reads `control/boxes/*`, `status/boxes/*`, `control/moves-protocol*.json`, `control/system/router/releases/*`. It no longer reads `control/box-enrollment/*`. 2c should confirm the console role's bucket statement covers the invitation prefix, and add the two describes. |
| 6. **The health check and the grace period.** A joining box answers `/.carlos/lb-health` on port 80 with 503 until it restarts as the claim agent. The grace period must cover the boot, the install, and joining: the console's pass every 10 seconds (an attempt, then its membership read and the invitation on the next pass), root's arm on the path unit, the report within a minute, the console's second read and the enrolment, and the restart. A few minutes on dev; §4's 20 minutes is ample, and dev should measure it. Dev should also stop and start one enrolled group box once and see it held at 503 and replaced, and reboot one and see it host again (Open question 7). |
| 7. **The roster.** Each region with a group needs, in the signed roster, `Region{Account, AWSRegion, GroupName, GroupARN, Namespaces (the console's namespace), Pools ("default")}`, and the deployment's host floor published. 2b reads these; the `region-group` phase writes the group's fields (Open question 1). |
| @@ -6156,11 +7085,8 @@ What 2c builds, and exactly what 2b needs from it: |
| |
| 1. **Who writes a region's group into the roster?** Answered (2026-10-16): a `region-group` phase of `carlos system claims run`, in a later, separate plan built on `feat/claim-activation-tools` once that branch merges. It takes the group's name and ARN from tofu's outputs (2c) and writes them, with the region's account, AWS region, namespaces and pools, in one signed roster write. 2b does not write it. Until it lands, no region has a group, the pass begins no attempt, and automatic enrolment stays dormant even with region HA on. |
| 2. **Namespace and pool.** The pass enrolls a group box into the console's own namespace and the `default` pool, and refuses a region that does not serve both. Is that right for every carloku.com region, or does a region's group need its own pool? |
| -3. **Census classes.** The proposed classes in the census table (A,J4 for `runHostEnv`, F,J4 for `channelInstall.run` and the join units, accepted-legacy for `AutoEnroller.inputs`, reviewed sites for the claim-authority census) follow the nearest precedents; platform-1e may class them otherwise. In particular `runHostEnv` has no close precedent: it writes the root-owned host.env every unit reads. |
| -4. **Box references outside the label space (review round 1, finding 3): blocked by a legacy-sealed unit.** A box's reference is its status label (`status.Label`, 1,600 values), and identity records are never deleted, so as groups replace boxes, labels collide; a box whose label another record holds never publishes its record and is replaced at the end of the grace period. The smallest fix is to reference a group box by its EC2 instance id (unique in the account, never reused, and already a valid box reference). The dry run built that much: `host-env write` adding `CARLOS_BOX_REFERENCE=instance-id`, and `metricsBoxLabel` answering the instance id for such a box, so the identity record, mode selection and root's join agree. It stops at one unit: |
| - - **The obstacle.** The console reads every box's admission and guard-ready report at `status/boxes/<reference>.json` (`PrivateBoxReport`, `internal/hostactors/private.go:11`). A hosting box publishes that report from `statusLoop` (`internal/host/agent.go`, accepted-legacy, `@legacy 913ce49e582231b530e28169fabe10b66e4fb94fcc2d73887bfff97b557b906b`), under `status.CollectBox`'s label (`status.Label(machineID)`), overridden only by a fleet box's channel label. A group box referenced by its instance id would publish its hosting report under the wrong key, and the console's census and membership checks (`VerifyReport`) would never find it. `statusLoop` has to pass the reference as its label override: one line in a legacy-sealed unit, which this plan may not change. |
| - - **What else moves with it.** `status.BuildFleet` copies each box's label into the public `status/fleet.json` (`internal/status/fleet.go:173`); the label is a hashed pseudonym precisely so that feed reveals no machine identity. An instance id there would be new public information, so the same change should keep instance-referenced boxes' labels out of the public feed or hash them there. |
| - - **What 2b does meanwhile.** Nothing: references stay labels. Collisions heal slowly (the group replaces the box that cannot publish), and the eligible-capacity alarm fires meanwhile. A ruling from platform-1e on `statusLoop` (and Paul on the public feed) lets a later plan build the fix in one task. |
| +3. **Census classes.** The proposed classes in the census table (A,J4 for `runHostEnv`, F,J4 for `channelInstall.run` and the join units, accepted-legacy for `AutoEnroller.inputs`, the re-sealed `statusLoop` row, nonrecord for `deadlineStore.Put`, reviewed sites for the claim-authority census) follow the nearest precedents; platform-1e may class them otherwise. In particular `runHostEnv` has no close precedent: it writes the root-owned host.env every unit reads. |
| +4. **Box references outside the label space.** Answered and built (round 2). platform-1e approved the `statusLoop` change round 1 stopped at, as a re-sealed `@legacy` row under census ruling region-ha-2b, on four conditions, all met: only group boxes take the override; the reference is `g-` and 20 hex digits of `sha256("carlos-box-ref-v1|" + account + "|" + instance)`, a pseudonym in the public feed; the console derives the key itself from the pinned instance (`PrivateBoxReport`, the pass, `checkConfirmed`) and never trusts the box's; and the four tests (Review Focus 13). See "Global Constraints: Box references" and Task 6. Automatic enrolment stays off for a region until a release with it is rolled there ("Activation gates", 1). |
| 5. **`DescribeAutoScalingGroups`.** Not needed: the console's read binds to the group name and ARN the signed roster names. Spec rev 16 drops it from §4. |
| 6. **The invitation's confirmation.** Resolved by H28: the console reads membership itself immediately before the invitation and again before the enrolment, so the same attempt serves both and no fresh attempt is begun after the guard-ready report. Spec rev 16's §5 says so. |
| -7. **`pendingTime` on a start.** The held-after-restart rule (C3) rests on EC2 setting the identity document's `pendingTime` at every start and never at a reboot. That is EC2's documented meaning of the field, but it should be seen on dev before the first region: stop and start a group box (expect held, 503, replaced) and reboot one (expect hosting). |
| +7. **`pendingTime` on a start: an activation gate.** The held-after-restart rule (C3) rests on EC2 setting the identity document's `pendingTime` at every start and never at a reboot. AWS documents the field as the launch time, but not that exact invariant (Astra round 2). Verify on dev before the first region: stop and start a group box (expect held, 503, replaced) and reboot one (expect hosting). Until then automatic enrolment stays off ("Activation gates", 2). |