Merge remote-tracking branch 'origin/main' into s3-polling-336
17 files changed,
+1193
−37
.amadan/test-weights.tsv+2 −2docs/site/changelog-links.json+60 −0docs/site/changelog.json+601 −0internal/console/claim_invariant_fixtures_test.go+1 −0internal/console/claim_invariant_probes_nonapp_test.go+4 −1internal/console/claim_invariant_probes_read_test.go+9 −3internal/console/claim_invariant_probes_test.go+33 −5internal/console/claim_invariant_probes_worker_test.go+5 −1internal/console/claim_invariant_regionha_test.go+1 −1internal/console/claim_invariant_test.go+113 −16internal/console/claim_invariant_world_test.go+13 −6internal/console/regioncandidates_test.go+5 −1internal/edge/purge_listen_linux.go+92 −0internal/edge/purge_listen_linux_test.go+230 −0internal/edge/purge_listen_unix.go+1 −1internal/provision/plan14_root_reads_test.go+17 −0internal/provision/testdata/plan14-root-reads-securelisten-337.tsv+6 −0
diff --git a/.amadan/test-weights.tsv b/.amadan/test-weights.tsv| index d994e6ff6..eb56aab72 100644 |
| --- a/.amadan/test-weights.tsv |
| +++ b/.amadan/test-weights.tsv |
| @@ -1,8 +1,8 @@ |
| # package test seconds (.amadan/test-weights.sh, 2026-10-21) |
| -github.com/carlosframework/platform/internal/console TestClaimConsoleMutationInvariant 199.7 |
| +github.com/carlosframework/platform/internal/console TestClaimConsoleMutationInvariant 60.1 |
| +github.com/carlosframework/platform/internal/console TestClaimConsoleMutationInvariantWitnesses 12.3 |
| github.com/carlosframework/platform/internal/console TestRetiringAnInstancePurgesItsMoveLogRows 9.3 |
| github.com/carlosframework/platform/internal/console TestDeclaredLostIsLoggedByTheConsole 7.2 |
| -github.com/carlosframework/platform/internal/console TestClaimConsoleMutationInvariantWitnesses 6.6 |
| github.com/carlosframework/platform/internal/console TestClaimMailPushCountsOutboundEffects 6.4 |
| github.com/carlosframework/platform/internal/console TestEconomicsPassWritesCensusOnce 5.0 |
| github.com/carlosframework/platform/internal/console TestCallbackPostsToSelectedHost 3.8 |
diff --git a/docs/site/changelog-links.json b/docs/site/changelog-links.json| index 3082e8c6a..80ec9b2e2 100644 |
| --- a/docs/site/changelog-links.json |
| +++ b/docs/site/changelog-links.json |
| @@ -1,5 +1,65 @@ |
| { |
| "//": "For the changelog: each release-note bullet's bold lead, mapped to the branches behind it. Kept by hand, one release at a time, as step 10 of the release recipe in the README. go run ./cmd/changelog fails on a lead no bullet has, or a branch that did not land in that release.", |
| + "v0.28.7": { |
| + "`carlos logs` now shows each run.": [ |
| + "run-lifecycle-logs-325" |
| + ], |
| + "An app can ask for one uploaded object to be scanned.": [ |
| + "scan-demand-revival" |
| + ], |
| + "The console's account switcher has a search box.": [ |
| + "console-switcher-filter" |
| + ], |
| + "An offline move can copy most of its data before the instance goes offline.": [ |
| + "moves-offline-precopy" |
| + ], |
| + "Every box in both regions must run this release before `stage` works.": [ |
| + "moves-offline-precopy" |
| + ], |
| + "Moves and replica copies now run eight object copies at once.": [ |
| + "moves-offline-precopy" |
| + ], |
| + "On-demand scanning needs `CARLOS_SCAN_WEBHOOK_KEY` only for callbacks.": [ |
| + "scan-demand-revival" |
| + ] |
| + }, |
| + "v0.28.6": { |
| + "A guard-armed box's protected console can now be replaced.": [ |
| + "install-console-upgrade" |
| + ], |
| + "A nested instance's `config.env` is written as soon as its directory appears.": [ |
| + "moves-offline-fixes" |
| + ], |
| + "Each offline move step now sees a current box report.": [ |
| + "moves-offline-fixes" |
| + ], |
| + "A source box's scheduler leaves a moved instance alone.": [ |
| + "moves-offline-fixes" |
| + ] |
| + }, |
| + "v0.28.5": { |
| + "`carlos move offline` moves one instance to another region, with planned downtime.": [ |
| + "moves-offline" |
| + ], |
| + "Roll every box in both regions before you start an offline move.": [ |
| + "moves-offline" |
| + ], |
| + "A box whose guard is armed on v0.28.4 needs one update by hand.": [ |
| + "armed-box-updates" |
| + ], |
| + "The guarded updater reads only what a candidate writes to standard output.": [ |
| + "armed-box-updates" |
| + ], |
| + "`carlos system guard-arm` makes its directories readable whatever your umask is.": [ |
| + "claim-guard-umask" |
| + ], |
| + "Routes with their own unit now restart on boxes whose agent doesn't run as root.": [ |
| + "adopt-unit-root-pass-315" |
| + ], |
| + "`carlos system status` warns when a new box would boot an older build.": [ |
| + "bootstrap-carlos-follows-316" |
| + ] |
| + }, |
| "v0.28.4": { |
| "Console invites now arrive.": [ |
| "design-console-invite-delivery" |
diff --git a/docs/site/changelog.json b/docs/site/changelog.json| index a15fa98c9..8b790967a 100644 |
| --- a/docs/site/changelog.json |
| +++ b/docs/site/changelog.json |
| @@ -2,6 +2,607 @@ |
| "//": "Generated by go run ./cmd/changelog from git, the published release notes and Amadan. Do not edit; regenerate.", |
| "repo": "https://amadan.net/carlos/platform", |
| "releases": [ |
| + { |
| + "tag": "v0.28.7", |
| + "date": "2026-10-21", |
| + "sha": "771ca01b6be00cec299a8e265c34136daa1fe0d1", |
| + "published": true, |
| + "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.7", |
| + "notes": { |
| + "form": "bullets", |
| + "sections": [ |
| + { |
| + "title": "", |
| + "bullets": [ |
| + { |
| + "md": "**`carlos logs` now shows each run.** It shows when each `carlos run` started, who asked for it and how it ended. The command's output is not kept there.", |
| + "branches": [ |
| + "run-lifecycle-logs-325" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-logs", |
| + "/docs/cli#carlos-run" |
| + ] |
| + }, |
| + { |
| + "md": "**An app can ask for one uploaded object to be scanned.** Where scanning is enabled for the deployment and an operator has granted the store, the app requests a scan with an app-scoped `service` credential and polls for the result, or receives a signed callback. See [Receiving scan results](https://carlosframework.com/docs/scan-callbacks).", |
| + "branches": [ |
| + "scan-demand-revival" |
| + ] |
| + }, |
| + { |
| + "md": "**The console's account switcher has a search box.** It helps when you belong to many accounts.", |
| + "branches": [ |
| + "console-switcher-filter" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "title": "For operators", |
| + "bullets": [ |
| + { |
| + "md": "**Roll the boxes first, then the console.** Coming from v0.28.4, read the [v0.28.5 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.5) first. Coming from v0.28.5, upgrade the console with `carlos system claims install-console --upgrade` as the [v0.28.6 notes](https://github.com/carlosframework/releases/releases/tag/v0.28.6) describe.", |
| + "docs": [ |
| + "/docs/cli#carlos-system-claims" |
| + ] |
| + }, |
| + { |
| + "md": "**An offline move can copy most of its data before the instance goes offline.** Run `carlos move offline stage --host <host> --to <region>` while the instance keeps serving. It copies the instance's replica to the target region, and running it again copies only what is new. `start` then takes the instance offline as before, and the seal copies only what changed since the last stage. Until `start`, `abort` removes the staged copy and the instance is never offline. While a move is staged, the instance refuses `carlos run` and deletion, and no other instance can move offline.", |
| + "branches": [ |
| + "moves-offline-precopy" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-move", |
| + "/docs/cli#carlos-run" |
| + ] |
| + }, |
| + { |
| + "md": "**Every box in both regions must run this release before `stage` works.** Each box now publishes `offline-stage-1`, and `stage` refuses while any box in either region lacks it.", |
| + "branches": [ |
| + "moves-offline-precopy" |
| + ] |
| + }, |
| + { |
| + "md": "**Moves and replica copies now run eight object copies at once.** Before, they copied one object at a time, so a large replica took minutes to copy while the instance was offline.", |
| + "branches": [ |
| + "moves-offline-precopy" |
| + ] |
| + }, |
| + { |
| + "md": "**On-demand scanning needs `CARLOS_SCAN_WEBHOOK_KEY` only for callbacks.** Polling works without it. To rotate it, keep the old key in `CARLOS_SCAN_WEBHOOK_KEY_PREVIOUS` and run `carlos system antivirus rotate-webhook-key`. Apps whose delivered secret changes restart.", |
| + "branches": [ |
| + "scan-demand-revival" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-system" |
| + ] |
| + }, |
| + { |
| + "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS`, `CARLOS_REQUIRE_POINTER_DIGEST` and `CARLOS_PLAN_CATALOG` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.", |
| + "docs": [ |
| + "/docs/cli#carlos-channels-digests" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| + "changes": [ |
| + { |
| + "sha": "771ca01b6be00cec299a8e265c34136daa1fe0d1", |
| + "date": "2026-10-21", |
| + "subject": "moves-offline-precopy: carlos move offline stage (pre-copy while serving) and parallel mirror copies", |
| + "branch": "moves-offline-precopy", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline-precopy", |
| + "commits": 10, |
| + "docs": [ |
| + "/docs/cli#carlos-move" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "f550afa21b8098460cbcf0bac2cac6748e52b802", |
| + "date": "2026-10-21", |
| + "subject": "spec: plans, caps and packs for the first paid launch (draft)", |
| + "branch": "design-plans-and-caps", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/design-plans-and-caps", |
| + "commits": 15, |
| + "discussions": [ |
| + { |
| + "id": 280, |
| + "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)", |
| + "state": "open" |
| + } |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-accounts-plan", |
| + "/docs/plans#turning-plans-on", |
| + "/docs/plans#the-catalog", |
| + "/docs/plans#assigning-a-plan", |
| + "/docs/plans#sponsored-accounts" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "aaa6a9498092d0e24910991127b45a9b21cdf7b4", |
| + "date": "2026-10-21", |
| + "subject": "root tests: the carlos binary is built once per test run, not once per test", |
| + "branch": "test-suite-speed", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/test-suite-speed", |
| + "commits": 3, |
| + "discussions": [ |
| + { |
| + "id": 291, |
| + "title": "Test suite speed: where the CI gate's 375 seconds go, and a ranked plan", |
| + "state": "open" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "512baa591dc275b5b3a01a353d6993fb19509a0b", |
| + "date": "2026-10-21", |
| + "subject": "Prompts:", |
| + "branch": "test-suite-debt", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/test-suite-debt", |
| + "commits": 4, |
| + "discussions": [ |
| + { |
| + "id": 226, |
| + "title": "Root package tests read the operator's real ~/.carlos/config (TestShipVerbAPIMode aborts the package)", |
| + "state": "open" |
| + }, |
| + { |
| + "id": 258, |
| + "title": "Root CLI tests read the operator's real ~/.carlos/config when TMPDIR is under HOME", |
| + "state": "open" |
| + }, |
| + { |
| + "id": 263, |
| + "title": "Hibernating instances: keep 7 days of snapshot history like awake ones", |
| + "state": "closed" |
| + }, |
| + { |
| + "id": 266, |
| + "title": "Flaky: sim/litestreamfacts TestGate_AnchoredLineageThroughStore under load", |
| + "state": "open" |
| + }, |
| + { |
| + "id": 274, |
| + "title": "Flaky CI test: stopevidence TestFDIdentityFindsAHardLinkHeldByAChild", |
| + "state": "open" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "f54aa2fc50b8481b9ba4b6bfe09a21bc7c7172e2", |
| + "date": "2026-10-21", |
| + "subject": "carlos run: a run's start and end reach carlos logs (#325)", |
| + "branch": "run-lifecycle-logs-325", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/run-lifecycle-logs-325", |
| + "commits": 5, |
| + "discussions": [ |
| + { |
| + "id": 325, |
| + "title": "Run a one-off command with one instance's delivered config (carlos exec)", |
| + "state": "closed" |
| + } |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-run" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "3d444a1b63c6e853527292cd8845f1347fdbb544", |
| + "date": "2026-10-21", |
| + "subject": "Merge reviewed on-demand object scanning revival", |
| + "branch": "scan-demand-revival", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/scan-demand-revival", |
| + "commits": 1, |
| + "docs": [ |
| + "/docs/cli#carlos-store-scan", |
| + "/docs/cli#deployment-wide-object-scanning", |
| + "/docs/scan-callbacks#asking-for-a-scan", |
| + "/docs/scan-callbacks#the-states", |
| + "/docs/scan-callbacks#callbacks-take-a-path" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "ce8c425ae5930e1c2807018bde1880b93b0947cd", |
| + "date": "2026-10-20", |
| + "subject": "console: searchable, scrolling account switcher", |
| + "branch": "console-switcher-filter", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/console-switcher-filter", |
| + "commits": 2, |
| + "discussions": [ |
| + { |
| + "id": 280, |
| + "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)", |
| + "state": "open" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "tag": "v0.28.6", |
| + "date": "2026-10-20", |
| + "sha": "e0986eabc5bc2a7ae5e17e58b19c9a6b92beecfe", |
| + "published": true, |
| + "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.6", |
| + "notes": { |
| + "form": "bullets", |
| + "sections": [ |
| + { |
| + "title": "", |
| + "bullets": [ |
| + { |
| + "md": "**Nothing changes for people deploying apps with the CLI.**" |
| + } |
| + ] |
| + }, |
| + { |
| + "title": "For operators", |
| + "bullets": [ |
| + { |
| + "md": "**Roll the boxes first, then the console.**" |
| + }, |
| + { |
| + "md": "**Coming from v0.28.4, read the [v0.28.5 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.5) first.** They all still apply, including the one update by hand for a box whose guard is armed on v0.28.4." |
| + }, |
| + { |
| + "md": "**A guard-armed box's protected console can now be replaced.** Once the box's router is on this release, run `carlos system claims install-console --upgrade --from <sha256>` as root, where the sha256 is the console that is running now. It refuses unless that build is what the box holds, then installs the new console and keeps the old one as `console.prev`. No hand recovery is needed. A box upgraded this way can't be armed again with `carlos system guard-arm` until `install-console` has been run without `--upgrade`, because `guard-arm` expects both copies to be the same build.", |
| + "branches": [ |
| + "install-console-upgrade" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-system-claims", |
| + "/docs/cli#carlos-system" |
| + ] |
| + }, |
| + { |
| + "md": "**A nested instance's `config.env` is written as soon as its directory appears.** Before, a move could leave the instance without it for up to an hour.", |
| + "branches": [ |
| + "moves-offline-fixes" |
| + ] |
| + }, |
| + { |
| + "md": "**Each offline move step now sees a current box report.** While a move is running, the source boxes and the sealing box report again every 15 seconds. A move takes under a minute of downtime when its steps run back to back. A step that meets a stale report now says the report is not current and to try again. Before, `flip` could answer that a process was still running.", |
| + "branches": [ |
| + "moves-offline-fixes" |
| + ] |
| + }, |
| + { |
| + "md": "**A source box's scheduler leaves a moved instance alone.** After `open`, it no longer writes a stray token file into the instance it gave up.", |
| + "branches": [ |
| + "moves-offline-fixes" |
| + ] |
| + }, |
| + { |
| + "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS` and `CARLOS_REQUIRE_POINTER_DIGEST` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.", |
| + "docs": [ |
| + "/docs/cli#carlos-channels-digests" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| + "changes": [ |
| + { |
| + "sha": "e0986eabc5bc2a7ae5e17e58b19c9a6b92beecfe", |
| + "date": "2026-10-20", |
| + "subject": "moves-offline-fixes: harness Run 9 defects. Config sync writes a nested row's config.env as soon as its instance directory appears, keeping it pending until delivered (6); each offline door step sees a current report: source boxes and the sealer refresh reports every 15 s while a move is active without renewing the tombstone, and a stale report answers MV-O178 (7); a source box's scheduler leaves a held or moved host alone, excluding it before the batch limit (8). Astra reviewed (two rounds); census-ruling-moves-offline-fixes (platform-1c); MV-O178 approved by Paul.", |
| + "branch": "moves-offline-fixes", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline-fixes", |
| + "commits": 9, |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "2185035698a1111c487651bddb1bf2500cd92ccc", |
| + "date": "2026-10-20", |
| + "subject": "install-console --upgrade --from <sha256>: replace a guard-armed box's protected console", |
| + "branch": "install-console-upgrade", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/install-console-upgrade", |
| + "commits": 4, |
| + "docs": [ |
| + "/docs/cli#carlos-system-claims" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "tag": "v0.28.5", |
| + "date": "2026-10-20", |
| + "sha": "54ae7fb93820f37f123bbdcbcb57f4f2efb25dc9", |
| + "published": true, |
| + "notes_url": "https://github.com/carlosframework/releases/releases/tag/v0.28.5", |
| + "notes": { |
| + "form": "bullets", |
| + "sections": [ |
| + { |
| + "title": "", |
| + "bullets": [ |
| + { |
| + "md": "**Nothing changes for people deploying apps with the CLI.**" |
| + } |
| + ] |
| + }, |
| + { |
| + "title": "For operators", |
| + "bullets": [ |
| + { |
| + "md": "**Roll the boxes first, then the console.**" |
| + }, |
| + { |
| + "md": "**Coming from v0.28.3, read the [v0.28.4 operator notes](https://github.com/carlosframework/releases/releases/tag/v0.28.4) first.** They all still apply." |
| + }, |
| + { |
| + "md": "**`carlos move offline` moves one instance to another region, with planned downtime.** It is for operators, works only while host claims and Phase 1 moves are off, and takes one step per command: `start`, `seal`, `flip`, `open`, then `finish`. The instance is offline from `start` until `open`. Until `open`, `abort` puts it back where it was with nothing lost.", |
| + "branches": [ |
| + "moves-offline" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-move" |
| + ] |
| + }, |
| + { |
| + "md": "**Roll every box in both regions before you start an offline move.** `seal` waits until every box in the instance's current region has stopped it and said so, and a box on an earlier release never says so.", |
| + "branches": [ |
| + "moves-offline" |
| + ] |
| + }, |
| + { |
| + "md": "**A box whose guard is armed on v0.28.4 needs one update by hand.** Its updater reads a candidate's progress messages as part of the answer, so `carlos system update` refuses every candidate on that box. Stop `carlos-system-update.path`, put the v0.28.5 binary in a root-only file after checking it against `SHA256SUMS.txt`, run it as root as `system apply` with the box's `host.env`, check the box, then start `carlos-system-update.path` again. Do one box at a time. From then on the box updates as usual.", |
| + "branches": [ |
| + "armed-box-updates" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-system" |
| + ] |
| + }, |
| + { |
| + "md": "**The guarded updater reads only what a candidate writes to standard output.** Progress and warnings no longer break its checks, so this is the last update a guard-armed box needs by hand.", |
| + "branches": [ |
| + "armed-box-updates" |
| + ] |
| + }, |
| + { |
| + "md": "**`carlos system guard-arm` makes its directories readable whatever your umask is.** Before, a root shell with a strict umask left them unreadable to the box's updater.", |
| + "branches": [ |
| + "claim-guard-umask" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-system" |
| + ] |
| + }, |
| + { |
| + "md": "**Routes with their own unit now restart on boxes whose agent doesn't run as root.** The agent now hands those restarts to root. Before, the restart was refused, the agent read that as a broken build and rolled back.", |
| + "branches": [ |
| + "adopt-unit-root-pass-315" |
| + ] |
| + }, |
| + { |
| + "md": "**`carlos system status` warns when a new box would boot an older build.** It compares `bootstrap/carlos` with stable's build and says which file to copy over it.", |
| + "branches": [ |
| + "bootstrap-carlos-follows-316" |
| + ], |
| + "docs": [ |
| + "/docs/cli#carlos-system" |
| + ] |
| + }, |
| + { |
| + "md": "**Every dormant switch stays off.** Leave `CARLOS_MOVES_PHASE1`, `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS`, `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS`, `CARLOS_CONSOLE_ID`, `CARLOS_REGION_HA`, `CONSOLE_APP_REGIONS`, `CARLOS_TEMPORARY_APPS` and `CARLOS_REQUIRE_POINTER_DIGEST` unset, don't put `CARLOS_BOX_REFERENCE=group` in any host.env, and don't run `carlos channels digests on`. Keep `CARLOS_SWAP` as it is: a box armed for zero-downtime deploys stays armed.", |
| + "docs": [ |
| + "/docs/cli#carlos-channels-digests" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| + "changes": [ |
| + { |
| + "sha": "54ae7fb93820f37f123bbdcbcb57f4f2efb25dc9", |
| + "date": "2026-10-20", |
| + "subject": "armed-box-updates: guard-armed boxes can take a release by the ruled re-arm. Cherry-picks 8376a468 (rootaws: an inherited disabled sentinel is not an override) and 8a313548 (hostfloor: a guarded run returns stdout only) from claim-empty-cohort-done, Astra-reviewed there; census-ruling-claim-rootaws-sentinel, confirmed for main by platform-1c. For v0.28.5.", |
| + "branch": "armed-box-updates", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/armed-box-updates", |
| + "commits": 3, |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "7af881553052fb0b1a26308f49e446df5bcf71be", |
| + "date": "2026-10-20", |
| + "subject": "moves-offline: operator-run offline region move (claims-off, planned downtime): holds and fences every source box, seals the source with a create-only tombstone after a final checkpoint, lands every dependency on the target proven against an immutable seal.json, opens, then retires the source by rename. Reviewed task by task and by Fable and Astra; census-ruling-moves-offline; copy approved by Paul 2026-10-20. Dormant until an operator runs carlos move offline.", |
| + "branch": "moves-offline", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/moves-offline", |
| + "commits": 40, |
| + "docs": [ |
| + "/docs/cli#carlos-move" |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "57569bfffa089b9e0ac9c61b9d6f1a03ccf4fc97", |
| + "date": "2026-10-20", |
| + "subject": "changelog-v0.28.4: release step 10 for v0.28.1 to v0.28.4", |
| + "branch": "changelog-v0.28.4", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/changelog-v0.28.4", |
| + "commits": 1, |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "cd2dd6e1a3f208b31932d28721bfa0eedb7e7a76", |
| + "date": "2026-10-20", |
| + "subject": "system status: say when bootstrap/carlos is not stable's build (#316)", |
| + "branch": "bootstrap-carlos-follows-316", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/bootstrap-carlos-follows-316", |
| + "commits": 2, |
| + "discussions": [ |
| + { |
| + "id": 280, |
| + "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)", |
| + "state": "open" |
| + }, |
| + { |
| + "id": 316, |
| + "title": "Keep bootstrap/carlos in step with the fleet: system update mirrors releases but new boxes still boot a stale binary", |
| + "state": "open" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "d7a5b8eb640535f54dd7e0380eda59e9bcc6c2c6", |
| + "date": "2026-10-20", |
| + "subject": "adopt: an agent that is not root hands named-unit routes to root's pass (#315)", |
| + "branch": "adopt-unit-root-pass-315", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/adopt-unit-root-pass-315", |
| + "commits": 3, |
| + "discussions": [ |
| + { |
| + "id": 280, |
| + "title": "Root trusts service-writable hostguard and health records under /data (claim root paths)", |
| + "state": "open" |
| + }, |
| + { |
| + "id": 315, |
| + "title": "Agent's in-process adopt rolls back a console update when it can't restart console.service itself (\"Interactive authentication required\")", |
| + "state": "closed" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + }, |
| + { |
| + "sha": "96db0efe4a0dc94f26cc8e01d5ed8c5c07e3296a", |
| + "date": "2026-10-19", |
| + "subject": "claim-guard-umask: guard-arm makes its guard, system and lock directories 0755 whatever the caller's umask (#317)", |
| + "branch": "claim-guard-umask", |
| + "branch_url": "https://amadan.net/carlos/platform/-/branch/claim-guard-umask", |
| + "commits": 2, |
| + "discussions": [ |
| + { |
| + "id": 317, |
| + "title": "guard-arm's directories inherit the caller's umask: umask 077 broke the guarded console (exit 203)", |
| + "state": "open" |
| + } |
| + ], |
| + "credit": [ |
| + { |
| + "person": "Paul Campbell", |
| + "agents": [ |
| + "claude" |
| + ] |
| + } |
| + ] |
| + } |
| + ] |
| + }, |
| { |
| "tag": "v0.28.4", |
| "date": "2026-10-18", |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index d09d1d291..8919640e9 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1876,6 +1876,7 @@ var claimFixtures = map[string]claimFixture{ |
| "worker StartMailEventPush": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| setup: func(t *testing.T, cx *claimCase) { |
| + claimMustRunAlone(t, "worker StartMailEventPush") |
| old := mailPushClient.Transport |
| mailPushClient.Transport = claimOKTransport{rec: cx.rec} |
| t.Cleanup(func() { mailPushClient.Transport = old }) |
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go| index 9e7c1697e..8e16fe554 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -814,7 +814,8 @@ var claimNonAppProbes = map[string]claimProbe{ |
| }, |
| }, |
| "route PUT /api/cli/system/geoip": { |
| - setup: func(t *testing.T, cx *claimCase) { withFixtureProbes(t) }, |
| + serial: "withFixtureProbes swaps the package variable geoipProbeAddrs", |
| + setup: func(t *testing.T, cx *claimCase) { claimMustRunAlone(t, "geoip probe"); withFixtureProbes(t) }, |
| do: func(t *testing.T, cx *claimCase) claimReply { |
| return cx.apiReply(t, http.MethodPut, "/api/cli/system/geoip", string(fixture(t, "GeoIP2-City-Test.mmdb"))) |
| }, |
| @@ -834,7 +835,9 @@ var claimNonAppProbes = map[string]claimProbe{ |
| }, |
| }, |
| "route DELETE /api/cli/system/geoip": { |
| + serial: "withFixtureProbes swaps the package variable geoipProbeAddrs", |
| setup: func(t *testing.T, cx *claimCase) { |
| + claimMustRunAlone(t, "geoip probe") |
| withFixtureProbes(t) |
| if _, err := cx.srv.geoip.Publish(context.Background(), strings.NewReader(string(fixture(t, "GeoIP2-City-Test.mmdb"))), geoipPublishOpts{Origin: "manual"}); err != nil { |
| t.Fatalf("stage a published geoip database: %v", err) |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index 307543163..5c814388d 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -84,6 +84,7 @@ func claimWorldApply(t *testing.T, cx *claimCase) { |
| func claimWorldGeoip(t *testing.T, cx *claimCase) { |
| t.Helper() |
| + claimMustRunAlone(t, "claimWorldGeoip") |
| withFixtureProbes(t) |
| if _, err := cx.srv.geoip.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}); err != nil { |
| t.Fatalf("stage a published geoip database: %v", err) |
| @@ -200,8 +201,9 @@ var claimReadProbes = map[string]claimProbe{ |
| "/api/cli/apps/bab/titogo/pending", "<pending>"), |
| "route GET /api/cli/apps/{account}/{app}/pipeline": claimAPIRead(nil, "/api/cli/apps/bab/titogo/pipeline", `"pipeline"`), |
| "route GET /api/cli/apps/{account}/{app}/regions": claimAPIRead(nil, "/api/cli/apps/bab/titogo/regions", `"regions"`), |
| - "route GET /api/cli/apps/{account}/{app}/relay": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) }, |
| - "/api/cli/apps/bab/titogo/relay", `"app":"titogo"`, `"enabled":true`), |
| + "route GET /api/cli/apps/{account}/{app}/relay": claimSerialProbe("relayEnabled stages the region's relay master through t.Setenv: process environment", |
| + claimAPIRead(func(t *testing.T, cx *claimCase) { cx.relayEnabled(t) }, |
| + "/api/cli/apps/bab/titogo/relay", `"app":"titogo"`, `"enabled":true`)), |
| "route GET /api/cli/apps/{account}/{app}/releases": claimAPIRead(nil, "/api/cli/apps/bab/kass/releases", `"version":"v1"`), |
| "route GET /api/cli/apps/{account}/{app}/releases/{version}/authorize": claimAPIRead(nil, |
| "/api/cli/apps/bab/kass/releases/v1/authorize", `"version":"v1"`, `"manifest_digest":"`), |
| @@ -232,7 +234,11 @@ var claimReadProbes = map[string]claimProbe{ |
| "route GET /api/cli/accounts/{account}/plan": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| "/api/cli/accounts/bab/plan", `"plan":"free"`), |
| "route GET /api/cli/system/antivirus": claimAPIRead(nil, "/api/cli/system/antivirus", `"wired":true`), |
| - "route GET /api/cli/system/geoip": claimAPIRead(claimWorldGeoip, "/api/cli/system/geoip", `"pointer"`, "GeoIP2-City"), |
| + "route GET /api/cli/system/geoip": func() claimProbe { |
| + p := claimAPIRead(claimWorldGeoip, "/api/cli/system/geoip", `"pointer"`, "GeoIP2-City") |
| + p.serial = "claimWorldGeoip swaps the package variable geoipProbeAddrs (withFixtureProbes)" |
| + return p |
| + }(), |
| "route GET /api/cli/system/jobs/{id}": { |
| setup: func(t *testing.T, cx *claimCase) { cx.vars["job"] = cx.systemRelease(t, "canary", "66587fa") }, |
| do: claimGetAPI("/api/cli/system/jobs/<job>"), |
diff --git a/internal/console/claim_invariant_probes_test.go b/internal/console/claim_invariant_probes_test.go| index 3e1ed8121..77914218c 100644 |
| --- a/internal/console/claim_invariant_probes_test.go |
| +++ b/internal/console/claim_invariant_probes_test.go |
| @@ -80,6 +80,16 @@ type claimProbe struct { |
| // effect proves from the world that the handler did its work, and returns |
| // "" when it did. It is required for every nonApp entry. |
| effect func(t *testing.T, cx *claimCase, r claimReply) string |
| + // serial, when set, says why this probe touches process-wide state (the |
| + // environment, the log output, a package variable). Such a probe runs |
| + // alone, before any parallel probe starts. |
| + serial string |
| +} |
| + |
| +// claimSerialProbe marks p serial, with the reason. |
| +func claimSerialProbe(reason string, p claimProbe) claimProbe { |
| + p.serial = reason |
| + return p |
| } |
| // claimWatched are the apps whose SQL and bucket state every probe must leave |
| @@ -417,18 +427,36 @@ func claimProbeKeys(fixtures map[string]claimFixture) []string { |
| // runClaimProbes runs every read and nonApp probe on its own console, so no |
| // probe's world (a closed session, a deleted account) disturbs another's. |
| -func runClaimProbes(t *testing.T, fixtures map[string]claimFixture, probes map[string]claimProbe, opts ...func(*Config)) { |
| +// With parallel above zero, at most that many run at once; a serial probe |
| +// still runs alone, before the parallel ones start (they wait until this |
| +// function returns). The caller's t.Run returns when all of them are done. |
| +func runClaimProbes(t *testing.T, fixtures map[string]claimFixture, probes map[string]claimProbe, parallel int, opts ...func(*Config)) { |
| t.Helper() |
| + run := func(t *testing.T, key string, p claimProbe) { |
| + cx := newClaimCaseWith(t, opts...) |
| + for _, problem := range runClaimProbe(t, cx, key, p) { |
| + t.Error(problem) |
| + } |
| + } |
| + var deferred []string |
| for _, key := range claimProbeKeys(fixtures) { |
| p, ok := probes[key] |
| if !ok { |
| continue // the census names it |
| } |
| + if parallel > 0 && strings.TrimSpace(p.serial) == "" { |
| + deferred = append(deferred, key) |
| + continue |
| + } |
| + t.Run(key, func(t *testing.T) { run(t, key, p) }) |
| + } |
| + slots := make(chan struct{}, max(parallel, 1)) |
| + for _, key := range deferred { |
| + p := probes[key] |
| t.Run(key, func(t *testing.T) { |
| - cx := newClaimCaseWith(t, opts...) |
| - for _, problem := range runClaimProbe(t, cx, key, p) { |
| - t.Error(problem) |
| - } |
| + t.Parallel() |
| + claimParallelSlot(t, slots) |
| + run(t, key, p) |
| }) |
| } |
| } |
diff --git a/internal/console/claim_invariant_probes_worker_test.go b/internal/console/claim_invariant_probes_worker_test.go| index c75eb79e2..765f8db9a 100644 |
| --- a/internal/console/claim_invariant_probes_worker_test.go |
| +++ b/internal/console/claim_invariant_probes_worker_test.go |
| @@ -32,8 +32,10 @@ import ( |
| // it must have said about a real finding. |
| func claimLogWorkerProbe(name string, setup func(*testing.T, *claimCase), want ...string) claimProbe { |
| return claimProbe{ |
| - setup: setup, |
| + serial: "captures the process-wide log output (captureLogTo): another case's log lines would land in its answer", |
| + setup: setup, |
| do: func(t *testing.T, cx *claimCase) claimReply { |
| + claimMustRunAlone(t, "log worker probe "+name) |
| var logs strings.Builder |
| restore := captureLogTo(&logs) |
| reply := claimWorkerReply(name)(t, cx) |
| @@ -155,7 +157,9 @@ var claimWorkerProbes = map[string]claimProbe{ |
| }, |
| }, |
| "worker StartHostReports": { |
| + serial: "swaps the package variable hostRunningImage", |
| setup: func(t *testing.T, cx *claimCase) { |
| + claimMustRunAlone(t, "worker StartHostReports") |
| // The box's guard files are not here; the seam says what this |
| // process is running, as the guard would. |
| prev := hostRunningImage |
diff --git a/internal/console/claim_invariant_regionha_test.go b/internal/console/claim_invariant_regionha_test.go| index a8e5dcfc9..f1c797977 100644 |
| --- a/internal/console/claim_invariant_regionha_test.go |
| +++ b/internal/console/claim_invariant_regionha_test.go |
| @@ -158,5 +158,5 @@ func TestClaimRegionHAConsoleRouteCensus(t *testing.T) { |
| // TestClaimRegionHAEntriesAreProbed drives every region-HA-only entry's |
| // probe on a console built with region HA on. |
| func TestClaimRegionHAEntriesAreProbed(t *testing.T) { |
| - runClaimProbes(t, claimRegionHAFixtures, claimRegionHAProbes, claimRegionHAOn) |
| + runClaimProbes(t, claimRegionHAFixtures, claimRegionHAProbes, 0, claimRegionHAOn) |
| } |
diff --git a/internal/console/claim_invariant_test.go b/internal/console/claim_invariant_test.go| index 6f674b7eb..5e438dd64 100644 |
| --- a/internal/console/claim_invariant_test.go |
| +++ b/internal/console/claim_invariant_test.go |
| @@ -53,6 +53,8 @@ import ( |
| "slices" |
| "sort" |
| "strings" |
| + "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -178,6 +180,74 @@ var claimCoordinators = map[string]string{ |
| "and the function's own writes reach Put, Delete or DeleteIfMatch)", |
| } |
| +// claimCaseParallelism bounds how many door, refusal and probe subtests run |
| +// at once. Each one builds its own console and measures what a request left |
| +// behind by waiting for quiet windows (quiesce). Too many at once starve the |
| +// CPU, and a handler goroutine that misses its window is measured with the |
| +// wrong request; the bound keeps that contention down. |
| +const claimCaseParallelism = 8 |
| + |
| +// claimParallelCases counts the claim cases running under t.Parallel. A |
| +// fixture that swaps process-wide state (a package variable, the log |
| +// output, a shared client's transport) calls claimMustRunAlone, which |
| +// fails the case if any parallel case is running, itself included. A swap |
| +// added to a fixture that is not listed serial therefore fails loudly |
| +// instead of leaking into a neighbour's measurement. |
| +var claimParallelCases atomic.Int32 |
| + |
| +func claimMustRunAlone(t *testing.T, what string) { |
| + t.Helper() |
| + if n := claimParallelCases.Load(); n > 0 { |
| + t.Fatalf("%s swaps process-wide state but runs beside %d parallel claim case(s): list it serial (claimSerialDoors, or the probe's serial field)", what, n) |
| + } |
| +} |
| + |
| +// claimParallelSlot takes a slot for a parallel claim case and counts it |
| +// as running until its own cleanup is done. |
| +func claimParallelSlot(t *testing.T, slots chan struct{}) { |
| + slots <- struct{}{} |
| + // Registered first, so it runs last: the slot is held until the case's |
| + // own cleanup (servers, store) is done. |
| + t.Cleanup(func() { <-slots }) |
| + claimParallelCases.Add(1) |
| + t.Cleanup(func() { claimParallelCases.Add(-1) }) |
| +} |
| + |
| +// claimSerialDoors are the door and refusal fixtures that must not run in |
| +// parallel, each with the reason. They run first, one at a time, before any |
| +// parallel door starts. A fixture whose staging touches process-wide state |
| +// (the environment, the log output, a package variable) belongs here. |
| +var claimSerialDoors = map[string]string{ |
| + "route POST /api/cli/relay/rotate": "relayEnabled and relayStageMaster stage the region's relay masters " + |
| + "through t.Setenv: process environment, which t.Parallel forbids", |
| + "route POST /api/cli/apps/{account}/{app}/relay": "relayArmed stages the region's relay master through t.Setenv: " + |
| + "process environment, which t.Parallel forbids", |
| + "route POST /api/cli/apps/{account}/{app}/relay/adopted": "relayEnabled stages the region's relay master through t.Setenv: " + |
| + "process environment, which t.Parallel forbids", |
| + "route POST /api/cli/apps/{account}/{app}/relay/disable": "relayEnabled stages the region's relay master through t.Setenv: " + |
| + "process environment, which t.Parallel forbids", |
| + "worker StartRelay": "relayEnabled stages the region's relay master through t.Setenv: " + |
| + "process environment, which t.Parallel forbids", |
| + "worker StartMailEventPush": "swaps mailPushClient.Transport, a package variable, for its recorder", |
| +} |
| + |
| +// claimSerialDoorCensus: every serial entry names a door or refusal fixture |
| +// and carries its reason. |
| +func claimSerialDoorCensus(fixtures map[string]claimFixture, serial map[string]string) []string { |
| + var problems []string |
| + for key, reason := range serial { |
| + f, ok := fixtures[key] |
| + switch { |
| + case !ok || (f.door == nil && f.refused == nil): |
| + problems = append(problems, fmt.Sprintf("claimSerialDoors names %s, which is not a door or refusal fixture", key)) |
| + case strings.TrimSpace(reason) == "": |
| + problems = append(problems, fmt.Sprintf("claimSerialDoors names %s with no reason", key)) |
| + } |
| + } |
| + sort.Strings(problems) |
| + return problems |
| +} |
| + |
| func TestClaimConsoleMutationInvariant(t *testing.T) { |
| census := newClaimCase(t) |
| if !census.srv.cfg.HostClaim { |
| @@ -209,30 +279,56 @@ func TestClaimConsoleMutationInvariant(t *testing.T) { |
| } |
| sort.Strings(keys) |
| - reached := map[string]bool{} |
| - for _, key := range keys { |
| - f := claimFixtures[key] |
| - if f.refused != nil { |
| - t.Run(key, func(t *testing.T) { |
| + if problems := claimSerialDoorCensus(claimFixtures, claimSerialDoors); len(problems) != 0 { |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| + t.FailNow() |
| + } |
| + |
| + // Doors and refusals, each on its own console. The group returns only |
| + // when every one of them has finished, parallel ones included, so the |
| + // writer check below sees every door's open run. |
| + var ( |
| + reachedMu sync.Mutex |
| + reached = map[string]bool{} |
| + ) |
| + t.Run("doors", func(t *testing.T) { |
| + slots := make(chan struct{}, claimCaseParallelism) |
| + run := func(t *testing.T, f claimFixture) { |
| + if f.refused != nil { |
| for _, p := range runClaimRefusal(t, f.refused) { |
| t.Error(p) |
| } |
| - }) |
| - continue |
| - } |
| - if f.door == nil { |
| - continue |
| - } |
| - t.Run(key, func(t *testing.T) { |
| + return |
| + } |
| problems, writers := runClaimDoor(t, f.door) |
| for _, p := range problems { |
| t.Error(p) |
| } |
| + reachedMu.Lock() |
| for _, w := range writers { |
| reached[w] = true |
| } |
| - }) |
| - } |
| + reachedMu.Unlock() |
| + } |
| + // The serial ones first, one at a time, while nothing else runs: |
| + // a parallel subtest only starts once this function has returned. |
| + for _, key := range keys { |
| + if f := claimFixtures[key]; (f.door != nil || f.refused != nil) && claimSerialDoors[key] != "" { |
| + t.Run(key, func(t *testing.T) { run(t, f) }) |
| + } |
| + } |
| + for _, key := range keys { |
| + if f := claimFixtures[key]; (f.door != nil || f.refused != nil) && claimSerialDoors[key] == "" { |
| + t.Run(key, func(t *testing.T) { |
| + t.Parallel() |
| + claimParallelSlot(t, slots) |
| + run(t, f) |
| + }) |
| + } |
| + } |
| + }) |
| // Lifecycle variants: further actions on an entry above whose only |
| // effect is a host claim written under their own registration grant |
| @@ -242,9 +338,10 @@ func TestClaimConsoleMutationInvariant(t *testing.T) { |
| // Reads and nonApp entries are probed with their own valid request on |
| // their own console (claim_invariant_probes_test.go): a request the |
| // handler turned away is not coverage, and a mutator proves its effect |
| - // from the world. |
| + // from the world. They run in parallel too, after every door, except the |
| + // probes marked serial (claimProbe.serial), which run alone first. |
| t.Run("probes", func(t *testing.T) { |
| - runClaimProbes(t, claimFixtures, claimProbes) |
| + runClaimProbes(t, claimFixtures, claimProbes, claimCaseParallelism) |
| }) |
| var unreached []string |
diff --git a/internal/console/claim_invariant_world_test.go b/internal/console/claim_invariant_world_test.go| index 980a96b04..6b56214f3 100644 |
| --- a/internal/console/claim_invariant_world_test.go |
| +++ b/internal/console/claim_invariant_world_test.go |
| @@ -718,18 +718,25 @@ func (cx *claimCase) foreign(t *testing.T, ref claimAppRef) func() { |
| } |
| } |
| -// quiesce waits until no raw effect has arrived for one 100ms window (at |
| -// most 5s), so a goroutine a handler started is measured with its request. |
| +// quiesce waits until no raw effect has arrived for two consecutive 100ms |
| +// windows (at most 5s), so a goroutine a handler started is measured with its |
| +// request. Two windows, not one: the invariant runs its doors in parallel |
| +// (claimCaseParallelism), and a goroutine starved of CPU for one window must |
| +// still land in its own request's measurement, not the next one's. |
| func (cx *claimCase) quiesce() { |
| + const window = 100 * time.Millisecond |
| deadline := time.Now().Add(5 * time.Second) |
| - n := cx.rec.mark() |
| + n, quiet := cx.rec.mark(), 0 |
| for time.Now().Before(deadline) { |
| - time.Sleep(100 * time.Millisecond) |
| + time.Sleep(window) |
| m := cx.rec.mark() |
| - if m == n { |
| + if m != n { |
| + n, quiet = m, 0 |
| + continue |
| + } |
| + if quiet++; quiet == 2 { |
| return |
| } |
| - n = m |
| } |
| } |
diff --git a/internal/console/regioncandidates_test.go b/internal/console/regioncandidates_test.go| index 6c598227a..c33f31773 100644 |
| --- a/internal/console/regioncandidates_test.go |
| +++ b/internal/console/regioncandidates_test.go |
| @@ -433,7 +433,11 @@ func TestLiveEdgesSnapshotLoadFailureIsNotFreshAndLoggedOnce(t *testing.T) { |
| t.Fatalf("round %d: snapshots unloadable, uk = %+v, want ineligible", i, c) |
| } |
| } |
| - if n := strings.Count(buf.String(), "box snapshots"); n > 1 { |
| + // Count the load-failure line itself. "box snapshots" alone also |
| + // matches the request-level incomplete-read line, which has its own |
| + // once-per-process latch: the test passed only when an earlier test in |
| + // the same process had already tripped it. |
| + if n := strings.Count(buf.String(), "could not load box snapshots"); n > 1 { |
| t.Fatalf("load failure logged %d times, want at most once: %s", n, buf.String()) |
| } |
| } |
diff --git a/internal/edge/purge_listen_linux.go b/internal/edge/purge_listen_linux.go| new file mode 100644 |
| index 000000000..e6ac55bee |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_linux.go |
| @@ -0,0 +1,92 @@ |
| +//go:build linux |
| + |
| +package edge |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "net" |
| + "os" |
| + "syscall" |
| +) |
| + |
| +// SecureUnixListen binds socketPath as a unix socket reachable only by its |
| +// owner, removing any stale file left behind by a crashed previous instance. |
| +// |
| +// The owner-only guarantee is the whole point of the function and the reason |
| +// it is split out per-OS rather than shimmed at the syscall: a purge socket |
| +// that anyone on the box can connect to hands out exactly the wake-storm |
| +// lever this feature exists to remove. Making that guarantee unrepresentable |
| +// on an OS without umask is safer than making it a no-op there. |
| +// |
| +// On Linux the socket is made 0600 before it is bound: bind creates the |
| +// file with the socket's own mode less the umask, so it appears owner-only |
| +// at socketPath with no window in between. The file is bound where it will |
| +// be served because unixdial checks the path a listener was bound under |
| +// against the path it dialled, so binding elsewhere and renaming is out. |
| +// This replaces setting the process umask to 0177 around the bind: the |
| +// umask belongs to the whole process, so every file and directory any |
| +// other goroutine made in that window lost its group and other bits, and a |
| +// directory lost its execute bit with them (amadan #337). Nothing here |
| +// touches the umask. |
| +func SecureUnixListen(socketPath string) (net.Listener, error) { |
| + // An abstract address (a leading @) has no file and so no permissions: |
| + // any uid could connect to it. Refuse it rather than serve it. |
| + if socketPath == "" || socketPath[0] == '@' { |
| + return nil, fmt.Errorf("unix socket %q: need a filesystem path, which is what makes it owner-only", socketPath) |
| + } |
| + if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return nil, err |
| + } |
| + fd, err := syscall.Socket(syscall.AF_UNIX, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0) |
| + if err != nil { |
| + return nil, os.NewSyscallError("socket", err) |
| + } |
| + f := os.NewFile(uintptr(fd), socketPath) |
| + defer f.Close() // net.FileListener holds its own duplicate |
| + if err := syscall.Fchmod(fd, 0600); err != nil { |
| + return nil, os.NewSyscallError("fchmod", err) |
| + } |
| + if err := syscall.Bind(fd, &syscall.SockaddrUnix{Name: socketPath}); err != nil { |
| + return nil, &net.OpError{Op: "listen", Net: "unix", Addr: &net.UnixAddr{Name: socketPath, Net: "unix"}, Err: os.NewSyscallError("bind", err)} |
| + } |
| + bound := true |
| + defer func() { |
| + if bound { |
| + os.Remove(socketPath) |
| + } |
| + }() |
| + // The kernel caps the backlog at net.core.somaxconn. |
| + if err := syscall.Listen(fd, 4096); err != nil { |
| + return nil, os.NewSyscallError("listen", err) |
| + } |
| + // Fail closed if the file came out reachable by group or other: that |
| + // would mean the kernel ignored the socket's mode, and the window this |
| + // function exists to close was open. |
| + fi, err := os.Lstat(socketPath) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if fi.Mode().Type() != os.ModeSocket || fi.Mode().Perm()&0o077 != 0 { |
| + return nil, fmt.Errorf("unix socket %s was created %v, not owner-only", socketPath, fi.Mode()) |
| + } |
| + // The umask can only have taken bits away; give the owner back read |
| + // and write so it can connect. |
| + if err := os.Chmod(socketPath, 0600); err != nil { |
| + return nil, err |
| + } |
| + fl, err := net.FileListener(f) |
| + if err != nil { |
| + return nil, err |
| + } |
| + ln, ok := fl.(*net.UnixListener) |
| + if !ok { |
| + fl.Close() |
| + return nil, fmt.Errorf("unix socket %s: listener is %T, not a unix listener", socketPath, fl) |
| + } |
| + // A listener made from a file does not remove its path on Close; one |
| + // from net.Listen("unix") does, and callers rely on it. |
| + ln.SetUnlinkOnClose(true) |
| + bound = false |
| + return ln, nil |
| +} |
diff --git a/internal/edge/purge_listen_linux_test.go b/internal/edge/purge_listen_linux_test.go| new file mode 100644 |
| index 000000000..70b086839 |
| --- /dev/null |
| +++ b/internal/edge/purge_listen_linux_test.go |
| @@ -0,0 +1,230 @@ |
| +//go:build linux |
| + |
| +package edge |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// amadan #337: binding must not change the process umask, so directories |
| +// another goroutine makes while sockets are being bound keep their mode. |
| +// Under the old umask-0177 bind, a 0755 MkdirAll racing it came out 0600. |
| +// Not parallel: it sets the process umask to a known value for its run. |
| +func TestSecureUnixListenLeavesTheUmaskAlone(t *testing.T) { |
| + old := syscall.Umask(0o022) |
| + t.Cleanup(func() { syscall.Umask(old) }) |
| + root := t.TempDir() |
| + if err := os.MkdirAll(filepath.Join(root, "l"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The directory maker runs for as long as the binder does, starting |
| + // together, so the two always overlap. |
| + const rounds = 300 |
| + start := make(chan struct{}) |
| + binding := make(chan struct{}) |
| + var wg sync.WaitGroup |
| + wg.Add(2) |
| + go func() { |
| + defer wg.Done() |
| + defer close(binding) |
| + <-start |
| + for i := range rounds { |
| + ln, err := SecureUnixListen(filepath.Join(root, "l", "s"+strconv.Itoa(i)+".sock")) |
| + if err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + ln.Close() |
| + } |
| + }() |
| + var dirs []string |
| + go func() { |
| + defer wg.Done() |
| + <-start |
| + for i := 0; ; i++ { |
| + select { |
| + case <-binding: |
| + return |
| + default: |
| + } |
| + d := filepath.Join(root, "d", strconv.Itoa(i)) |
| + if err := os.MkdirAll(d, 0o755); err != nil { |
| + t.Error(err) |
| + return |
| + } |
| + dirs = append(dirs, d) |
| + } |
| + }() |
| + close(start) |
| + wg.Wait() |
| + if len(dirs) == 0 { |
| + t.Fatal("no directory was made while the binds ran") |
| + } |
| + for _, d := range dirs { |
| + fi, err := os.Stat(d) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := fi.Mode().Perm(); got != 0o755 { |
| + t.Fatalf("%s is %o, want 0755: a bind changed the process umask", d, got) |
| + } |
| + } |
| + if got := syscall.Umask(0o022); got != 0o022 { |
| + t.Fatalf("umask after the binds = %o, want 022", got) |
| + } |
| +} |
| + |
| +// The listener is reached at socketPath, reports it, removes it on Close, |
| +// and leaves no private directory behind. |
| +func TestSecureUnixListenServesAtItsPathAndLeavesNothingBehind(t *testing.T) { |
| + t.Parallel() |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "c.sock") |
| + ln, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := ln.Addr().String(); got != sock { |
| + t.Errorf("Addr = %s, want %s", got, sock) |
| + } |
| + accepted := make(chan error, 1) |
| + go func() { |
| + c, err := ln.Accept() |
| + if err == nil { |
| + c.Close() |
| + } |
| + accepted <- err |
| + }() |
| + c, err := net.Dial("unix", sock) |
| + if err != nil { |
| + t.Fatalf("dial %s: %v", sock, err) |
| + } |
| + c.Close() |
| + if err := <-accepted; err != nil { |
| + t.Fatal(err) |
| + } |
| + if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" { |
| + t.Fatalf("directory holds %v, want only c.sock", entries) |
| + } |
| + if err := ln.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if entries := names(t, dir); len(entries) != 0 { |
| + t.Fatalf("after Close the directory holds %v, want nothing", entries) |
| + } |
| +} |
| + |
| +// A path that cannot be cleared (here: a directory with something in it) |
| +// fails before anything is bound, and is left as it was. |
| +func TestSecureUnixListenRefusesAPathItCannotClear(t *testing.T) { |
| + t.Parallel() |
| + dir := t.TempDir() |
| + sock := filepath.Join(dir, "c.sock") |
| + if err := os.MkdirAll(filepath.Join(sock, "occupied"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ln, err := SecureUnixListen(sock); err == nil { |
| + ln.Close() |
| + t.Fatal("bound over a non-empty directory") |
| + } |
| + if entries := names(t, dir); len(entries) != 1 || entries[0] != "c.sock" { |
| + t.Fatalf("directory holds %v after a failed bind, want only the original c.sock", entries) |
| + } |
| +} |
| + |
| +// An abstract address has no file, so no permissions: refused, not bound. |
| +func TestSecureUnixListenRefusesAnAbstractAddress(t *testing.T) { |
| + t.Parallel() |
| + for _, p := range []string{"@carlos-test-abstract", ""} { |
| + if ln, err := SecureUnixListen(p); err == nil { |
| + ln.Close() |
| + t.Errorf("SecureUnixListen(%q) bound; want a refusal", p) |
| + } |
| + } |
| +} |
| + |
| +// The listener is a *net.UnixListener, as net.Listen("unix") gives, so |
| +// callers keep SetDeadline, File and SetUnlinkOnClose. |
| +func TestSecureUnixListenGivesAUnixListener(t *testing.T) { |
| + t.Parallel() |
| + ln, err := SecureUnixListen(filepath.Join(t.TempDir(), "c.sock")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer ln.Close() |
| + if _, ok := ln.(*net.UnixListener); !ok { |
| + t.Fatalf("listener is %T, want *net.UnixListener", ln) |
| + } |
| +} |
| + |
| +// A second Close must not remove a socket a later listener has since bound |
| +// at the same path. |
| +func TestSecureUnixListenClosesItsPathOnce(t *testing.T) { |
| + t.Parallel() |
| + sock := filepath.Join(t.TempDir(), "c.sock") |
| + first, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first.Close() |
| + second, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer second.Close() |
| + first.Close() |
| + if _, err := os.Stat(sock); err != nil { |
| + t.Fatalf("the second listener's socket is gone after the first closed again: %v", err) |
| + } |
| +} |
| + |
| +func names(t *testing.T, dir string) []string { |
| + t.Helper() |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []string |
| + for _, e := range entries { |
| + out = append(out, e.Name()) |
| + } |
| + return out |
| +} |
| + |
| +// A socket path at the longest length the kernel accepts still binds: the |
| +// private directory's name is sized so the bind inside it is no longer |
| +// than socketPath, even for a short socket name. |
| +func TestSecureUnixListenBindsAtTheLengthLimit(t *testing.T) { |
| + t.Parallel() |
| + limit := 107 // sun_path is 108 bytes on Linux, including the NUL |
| + if runtime.GOOS != "linux" { |
| + limit = 103 // 104 on the BSDs and darwin |
| + } |
| + dir := t.TempDir() |
| + pad := limit - len(dir) - len("/") - len("/c.sock") |
| + if pad < 1 { |
| + t.Skipf("temp dir %s leaves no room under %d bytes", dir, limit) |
| + } |
| + long := filepath.Join(dir, strings.Repeat("p", pad)) |
| + if err := os.Mkdir(long, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + sock := filepath.Join(long, "c.sock") |
| + if len(sock) != limit { |
| + t.Fatalf("built a %d-byte path, want %d", len(sock), limit) |
| + } |
| + ln, err := SecureUnixListen(sock) |
| + if err != nil { |
| + t.Fatalf("a %d-byte socket path did not bind: %v", len(sock), err) |
| + } |
| + ln.Close() |
| +} |
diff --git a/internal/edge/purge_listen_unix.go b/internal/edge/purge_listen_unix.go| index d4a8cdb3b..857ae5c89 100644 |
| --- a/internal/edge/purge_listen_unix.go |
| +++ b/internal/edge/purge_listen_unix.go |
| @@ -1,4 +1,4 @@ |
| -//go:build !windows |
| +//go:build !windows && !linux |
| package edge |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 217c65123..495304a7c 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1101,6 +1101,23 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "3737ebbd0ee87811839624724e552e0af02f63d70baaa0516a024440d848fdae" |
| + // Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen |
| + // makes its socket owner-only with fchmod before bind instead of setting |
| + // the process umask around it), loaded last, after ci-speed-activator, |
| + // under census-ruling-securelisten-337 (Paul, as census owner, |
| + // 2026-10-21, accepted as proposed). SecureUnixListen in purge_listen_linux.go is new |
| + // accepted legacy at J4 root-input (as pendingDirs.appeared and its |
| + // caller ListenControl): one Lstat of the socket the agent has just |
| + // bound for itself to serve. The internal/edge envelope is re-pinned. |
| + // One frozen declaration is added, so the seal moves; |
| + // ci-speed-activator's is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-securelisten-337.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "33a8ff46dd9fcf85664c42c42eaf01585af6c92806cb7830fa8f30fd0950c430" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv| new file mode 100644 |
| index 000000000..a402400b2 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-securelisten-337.tsv |
| @@ -0,0 +1,6 @@ |
| +# Census overlay securelisten-337 (amadan #337: edge.SecureUnixListen makes its socket owner-only with fchmod before bind instead of setting the process umask to 0177 around the bind), loaded LAST, after ci-speed-activator. RULED as census-ruling-securelisten-337 (Paul, as census owner, 2026-10-21), accepted as proposed. |
| +# SecureUnixListen in internal/edge/purge_listen_linux.go (new file, //go:build linux) is a new unit with one read site: an Lstat of the socket it has just bound, to fail closed if group or other bits are set. It runs in the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos) on the sockets the agent itself is about to serve, and makes no root decision (it also refuses an empty or abstract @ path before bind, and the listener it returns removes its path on Close through SetUnlinkOnClose), so it is accepted legacy at J4 root-input, the disposition census-ruling-moves-offline-fixes gave pendingDirs.appeared (the agent's own Lstat of paths it made) and plan14-root-reads.tsv gives ListenControl, its caller. Its callers (listenLogs, listenCmdStream, ListenControl) are unchanged. The old umask implementation stays in purge_listen_unix.go for other unix systems (build tag now !windows && !linux); the census scans linux only and finds no read there. |
| +# Pinned at branch commit 8fb4ccb1 (Astra review: the listener returned is the *net.UnixListener itself with SetUnlinkOnClose(true); empty and abstract paths refused). One frozen declaration is added, so the seal moves; ci-speed-activator's is checked first. The internal/edge envelope is re-pinned for the new file and the build tag change. |
| +@legacy 670864719cd404f826f3ba1365d8867d0cbb1c0b2eda867676c34968493fd4d7 internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6 |
| +internal/edge/purge_listen_linux.go SecureUnixListen legacy accepted-legacy accepted-legacy;rev3=census-ruling-securelisten-337;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=root-input (J4, as pendingDirs.appeared under census-ruling-moves-offline-fixes and as ListenControl, its caller); new on Linux (amadan #337), replacing the umask bind, which set the process umask to 0177 and so stripped group and other bits from files other goroutines made meanwhile: one Lstat, which never follows a link, of the unix socket this call has just bound at socketPath for the agent itself to serve (an instance's purge control socket through ListenControl, a role's log socket through listenLogs, a cmd unit's stdout and stderr sockets through listenCmdStream), so the bind fails closed unless the file is a socket with no group or other bits (an empty or abstract @ path is refused before bind, since it has no file and so no permissions); it reads no file content, decides only whether this bind fails, and makes no root decision. startIsolated (claim mode, rolelaunch_root_linux.go) also calls listenLogs, as the agent and outside its l.Work.With step, before it asks root to run the reservation; root never opens or reads the socket file:Lstat@1490 2b037b125506ef5e89c9c2ffedca1474c23ecd30113eac3e7586fc41af6af6b6 |
| +@coverage internal/edge e8f21100bb0132adc26fb49e12143780e9fbcfecd33ea8e91b6ad7db89198021 |