claim-prev-rehearsal: install-channel --policy arms an updater-installed box; the activation witness starts from a box the platform made and rehearses steps 6 and 7
Squashed from claim-prev-rehearsal into main.
16 files changed,
+1947
−254
approved_region_ha_2b_copy_test.go+9 −1docs/site/cli.md+2 −0docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md+68 −0docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md+1 −1internal/host/activation_console_binary_rootrunner_linux_test.go+4 −0internal/host/activation_console_rootrunner_linux_test.go+627 −228internal/host/installchannel.go+305 −24internal/host/installchannel_linux.go+32 −0internal/host/installchannel_other.go+2 −0internal/host/installchannel_policy_linux_test.go+46 −0internal/host/installchannel_policy_rootrunner_linux_test.go+83 −0internal/host/installchannel_policy_test.go+724 −0internal/provision/plan14_root_reads_test.go+26 −0internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv+8 −0internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv+3 −0internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv+7 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.013s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 335.189s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.845s
ok github.com/carlosframework/platform/cmd/moveprobe 92.283s
ok github.com/carlosframework/platform/cmd/zddprobe 0.709s
ok github.com/carlosframework/platform/internal/activator 251.662s
ok github.com/carlosframework/platform/internal/agentsock 0.029s
ok github.com/carlosframework/platform/internal/alerting 0.005s
ok github.com/carlosframework/platform/internal/analytics 0.030s
ok github.com/carlosframework/platform/internal/appconfig 2.625s
ok github.com/carlosframework/platform/internal/apperr 0.114s
ok github.com/carlosframework/platform/internal/applog 0.198s
ok github.com/carlosframework/platform/internal/awsinv 0.007s
ok github.com/carlosframework/platform/internal/boxchoice 0.018s
ok github.com/carlosframework/platform/internal/boxid 0.774s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.021s
ok github.com/carlosframework/platform/internal/caps 0.034s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.729s
ok github.com/carlosframework/platform/internal/changelog 0.342s
ok github.com/carlosframework/platform/internal/claimactivation 0.853s
ok github.com/carlosframework/platform/internal/clamd 0.066s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 410.358s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.074s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 2.443s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 45.177s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.019s
ok github.com/carlosframework/platform/internal/fleetapi 0.158s
ok github.com/carlosframework/platform/internal/gate 1.679s
ok github.com/carlosframework/platform/internal/genselect 0.028s
ok github.com/carlosframework/platform/internal/geo 0.050s
ok github.com/carlosframework/platform/internal/geoip 0.057s
ok github.com/carlosframework/platform/internal/historyreadback 0.200s
ok github.com/carlosframework/platform/internal/host 186.433s
ok github.com/carlosframework/platform/internal/hostactors 5.457s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.015s
ok github.com/carlosframework/platform/internal/hostbox 2.709s
ok github.com/carlosframework/platform/internal/hostclaim 0.608s
ok github.com/carlosframework/platform/internal/hostenv 0.015s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.756s
ok github.com/carlosframework/platform/internal/hostfence 0.028s
ok github.com/carlosframework/platform/internal/hostfiles 10.498s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.009s
ok github.com/carlosframework/platform/internal/hostops 1.657s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.015s
ok github.com/carlosframework/platform/internal/hostquarantine 0.014s
ok github.com/carlosframework/platform/internal/hostrelocate 0.474s
ok github.com/carlosframework/platform/internal/hostresolve 0.142s
ok github.com/carlosframework/platform/internal/hostwrite 0.014s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 5.224s
ok github.com/carlosframework/platform/internal/instanceexec 2.744s
ok github.com/carlosframework/platform/internal/instancefs 0.019s
ok github.com/carlosframework/platform/internal/instancekey 0.016s
ok github.com/carlosframework/platform/internal/instanceupdates 0.021s
ok github.com/carlosframework/platform/internal/layout 1.606s
ok github.com/carlosframework/platform/internal/lbhealth 0.016s
ok github.com/carlosframework/platform/internal/lease 9.813s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.009s
ok github.com/carlosframework/platform/internal/ledger 0.018s
ok github.com/carlosframework/platform/internal/mail 0.183s
ok github.com/carlosframework/platform/internal/maintenance 0.089s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.540s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.070s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.425s
ok github.com/carlosframework/platform/internal/moves/archive 0.788s
ok github.com/carlosframework/platform/internal/objstore 0.206s
ok github.com/carlosframework/platform/internal/ops 0.186s
ok github.com/carlosframework/platform/internal/park 0.212s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 31.574s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.441s
ok github.com/carlosframework/platform/internal/relay 7.227s
ok github.com/carlosframework/platform/internal/release 24.649s
ok github.com/carlosframework/platform/internal/rollout 3.642s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.059s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.570s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.472s
ok github.com/carlosframework/platform/internal/seeds 0.013s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.575s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.992s
ok github.com/carlosframework/platform/internal/status 0.062s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.383s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.006s
ok github.com/carlosframework/platform/internal/unixdial 0.004s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.001s
ok github.com/carlosframework/platform/internal/vet 1.547s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 49414306 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/18 14:32:49 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DFA63FCC94A2B8, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go| index c36232651..d49cf5638 100644 |
| --- a/approved_region_ha_2b_copy_test.go |
| +++ b/approved_region_ha_2b_copy_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "os" |
| "regexp" |
| "strconv" |
| + "strings" |
| "testing" |
| ) |
| @@ -66,5 +67,12 @@ func TestRegionHA2bBuiltCopyIsApproved(t *testing.T) { |
| } |
| func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) { |
| - reportAll(t, checkRunApplied(builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt), ".")) |
| + rows := builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt) |
| + // A table cell holds one line, so in this record a row's text writes a |
| + // newline as \n: RHB-C19, the install-channel usage Paul re-approved on |
| + // 2026-10-18 (claim-prev-rehearsal), is two lines and a blank one. |
| + for i := range rows { |
| + rows[i].Text = strings.ReplaceAll(rows[i].Text, `\n`, "\n") |
| + } |
| + reportAll(t, checkRunApplied(rows, ".")) |
| } |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index b477aeeaa..8638a2d39 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2286,6 +2286,8 @@ carlos system host-env write builds /etc/carlos/host.env from the deployment's p |
| 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. |
| +With --policy FILE, the armed policy carlos system claims run sign-policy wrote, it installs before the host floor is published. The policy's floor stands in for the published one, and once a floor is published the two must be the same. It also arms a box that carlos system update has already moved to the channel's release: carlos is left as it is, and carlos.prev, the release before, is replaced by it, so guard-arm finds the release in both. A box on another release, or with an armed guard, is still refused. |
| + |
| ```sh |
| carlos system host-env write --deployment prod --region ie |
| carlos system install-channel |
diff --git a/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md| new file mode 100644 |
| index 000000000..b7e171cc2 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md |
| @@ -0,0 +1,68 @@ |
| +# Census proposal: claim-prev-rehearsal |
| + |
| +Status: RULED. platform-1e accepted every row as proposed on 2026-10-18 (census-ruling-claim-prev-rehearsal). Overlay `internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv`, loaded last (after `claim-step6-artifacts-fix2`), every row `rev3=census-ruling-claim-prev-rehearsal`. |
| + |
| +## What changed |
| + |
| +`carlos system install-channel` takes `--policy FILE` (controller ruling R1, with platform-00's binding additions). FILE is exactly what `carlos system claims run` sign-policy writes. With it: |
| + |
| +- the policy's floor, verified as guard-arm verifies it (the root-signed moves-protocol authorization, for this deployment's bucket, under `CARLOS_RELEASE_PUBKEY`), stands in for the published floor while none is published; |
| +- a published floor, or either half of one, must be the policy's bytes, or the run is refused; |
| +- one updated box is admitted: `applied.json` names the channel release, `carlos` hashes to its signed artifact, and the guard is not armed. Its `carlos.prev` is refreshed from `carlos` by the updater's own `notePrev`, and the identity is kept beside the slots. |
| + |
| +Without `--policy` nothing changes (ruling 6). |
| + |
| +## Units |
| + |
| +| Unit | Change | Class | Executing identity | |
| +|---|---|---|---| |
| +| `internal/host/installchannel.go` `readChannelPolicy` | new; read site `file:ReadFile` | authority F,J4 | uid 0 at the box's attended root shell, running `carlos system install-channel --policy`. Reads the operator-staged policy file once, before the apply lock or any store read. | |
| +| `internal/host/installchannel.go` `channelInstall.refreshPrev` | new; read site `file:Lstat` | authority F,J4 | the same uid 0 root shell, under root's apply lock. Lstats `carlos.prev` to refuse a symlink or a non-regular file. | |
| +| `internal/host/installchannel.go` `channelInstall.run` | unchanged digest | F,J4 (region-ha-2b) | unchanged | |
| +| `internal/host/installchannel_linux.go` `slotState`, `var openat2`, `removeBeneath` | unchanged digests | F,J4 (region-ha-2b) | unchanged; 2b's rows stand and are not restated | |
| +| `channelInstall.install`, `channelInstall.checkFloor`, `policyFloorPublished`, `channelPolicy`, `RunSystemInstallChannel` | changed or new, no read site of their own | envelope | the same root shell | |
| + |
| +The `internal/host` envelope is re-pinned. The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches either new unit. |
| + |
| +## 9B8 |
| + |
| +No new bucket writer. install-channel writes only local files (`carlos.prev`, its version sidecar through `notePrev`, and `channel-identity.json`). No 9B8 entry is proposed. |
| + |
| +## Test files |
| + |
| +The rewritten root-runner witness (`activation_console_rootrunner_linux_test.go`) and the new `installchannel_policy_test.go` are tests and are outside the census. |
| + |
| +## Fix round 1 (RULED) |
| + |
| +Status: RULED. platform-1e accepted every row as proposed on 2026-10-18 (census-ruling-claim-prev-rehearsal-fix1). Overlay `internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv`, loaded after the ruled overlay, every row `rev3=census-ruling-claim-prev-rehearsal-fix1`. Everything is in the `--policy` path; without `--policy` nothing changes. |
| + |
| +What changed: |
| + |
| +- carlos.prev's version record is mandatory in the `--policy` path (Astra). `completePrev` runs under the prev lock: it removes the old verdicts and record, copies carlos when refreshing, then writes the record and reads it back. Every failure is fatal, then the floor's judgement follows through `notePrev` without a replace. The `--policy` "already installed" answer requires the record, and completes it when it is missing or stale. |
| +- An open update journal, or an update-begun marker beside the guard, refuses through `guardArmIdle` (I1). |
| +- The `--policy` applier reads the root participation census (`rootMovesFloorIdentity`), as the update's does, so a census box takes the root moves floor branch (I2). |
| +- The policy file is opened with O_NOFOLLOW and O_NONBLOCK, checked with fstat to be a regular file, and capped at 64 KiB (M3). |
| + |
| +| Unit | Change | Class | Executing identity | |
| +|---|---|---|---| |
| +| `internal/host/installchannel.go` `channelInstall.run` | digest unchanged; basis corrected (M5): it now also runs with `--policy` at an updated box's root shell | F,J4, kept exactly | unchanged | |
| +| `internal/host/installchannel.go` `channelInstall.refreshPrev` | re-pinned; basis now says it reaches `notePrev`, and through it `judgePrevWith`/`judgeSeedsPrev` (or the root moves floor judge on a census box), which can open the registry as root when a legacy local floor exists | F,J4 | uid 0 at the box's attended root shell | |
| +| `internal/host/installchannel_linux.go` `openChannelPolicy` | new; read sites `file:Open`, `file:Stat`, `file:ReadAll` | F,J4 | uid 0 at the box's attended root shell | |
| +| `internal/host/installchannel.go` `readChannelPolicy` | `@remove`: its read moved into `openChannelPolicy` | | | |
| +| `completePrev`, `prevRecorded`, `newChannelInstall`, `install` | no read site of their own | envelope | the same root shell | |
| + |
| +The internal/host envelope is re-pinned. The seal stands. No 9B8 entry: no new bucket writer. (On a census box, verifyPointer remembers the channel identity in root's guarded identities, as the update already does; that is a local root-state write, not a bucket write.) |
| + |
| +## Fix round 2 (RULED) |
| + |
| +Status: RULED. platform-1e accepted it as proposed on 2026-10-18 (census-ruling-claim-prev-rehearsal-fix2). |
| + |
| +Overlay `internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv`, loaded after fix1. Envelope only: the internal/host envelope is re-pinned, and no root-read unit's digest or read sites change. |
| + |
| +What changed, all in the `--policy` path: |
| + |
| +- "Already installed" always runs carlos.prev's judgement again: `channelPrevJudge`, which is `notePrev` without a replace. It fails on the judgement's error, so a judgement that was cut off or failed is finished or reported, never answered over. `completePrev` ends with the same judgement, after the version record. |
| +- The unfinished-update refusal's reason drops guard-arm's floor prefix. |
| +- `installChannelUsage` is now one literal, of the same value, so the re-approved RHB-C19 can be pinned whole. |
| + |
| +The seal stands. No 9B8 entry. |
diff --git a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md| index fb4ca8457..7284ee8f6 100644 |
| --- a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md |
| +++ b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md |
| @@ -79,7 +79,7 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference. |
| | id | file | text | status | |
| |---|---|---|---| |
| -| RHB-C19 | internal/host/installchannel.go (a fragment of the usage) | Install the release this box's channel names, as root, on a box that has none yet. | approved | |
| +| RHB-C19 | internal/host/installchannel.go (a fragment of the usage, re-approved by Paul 2026-10-18 (claim-prev-rehearsal)) | usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet. | approved | |
| | RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | approved | |
| | RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | approved | |
| | RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | approved | |
diff --git a/internal/host/activation_console_binary_rootrunner_linux_test.go b/internal/host/activation_console_binary_rootrunner_linux_test.go| index 84d0b8407..0a8bba330 100644 |
| --- a/internal/host/activation_console_binary_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_binary_rootrunner_linux_test.go |
| @@ -10,7 +10,11 @@ var activationConsoleBinary []byte |
| //go:embed activation-carlos-runner.bin |
| var activationCarlosBinary []byte |
| +//go:embed activation-carlos-old-runner.bin |
| +var activationCarlosOldBinary []byte |
| + |
| func init() { |
| activationConsoleImage = activationConsoleBinary |
| activationCarlosImage = activationCarlosBinary |
| + activationCarlosOldImage = activationCarlosOldBinary |
| } |
diff --git a/internal/host/activation_console_rootrunner_linux_test.go b/internal/host/activation_console_rootrunner_linux_test.go| index 55383db44..39ebfe63e 100644 |
| --- a/internal/host/activation_console_rootrunner_linux_test.go |
| +++ b/internal/host/activation_console_rootrunner_linux_test.go |
| @@ -5,8 +5,10 @@ package host |
| import ( |
| "bytes" |
| "crypto/ed25519" |
| + "crypto/rand" |
| "debug/buildinfo" |
| "encoding/base64" |
| + "encoding/hex" |
| "encoding/json" |
| "encoding/xml" |
| "errors" |
| @@ -17,6 +19,7 @@ import ( |
| "net/http/httptest" |
| "os" |
| "os/exec" |
| + "os/user" |
| "path/filepath" |
| "reflect" |
| "strings" |
| @@ -36,18 +39,21 @@ import ( |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| -var activationConsoleImage, activationCarlosImage []byte |
| +var activationConsoleImage, activationCarlosImage, activationCarlosOldImage []byte |
| -// activationImagesBuild is how the two embedded images are built, from the |
| +// activationImagesBuild is how the three embedded images are built, from the |
| // worktree root, before `go test -c -tags rootrunner,activationconsole` |
| -// (controller ruling C5). Both are stripped (-s -w), as release builds are: |
| +// (controller ruling C5). All are stripped (-s -w), as release builds are: |
| // an unstripped Go binary carries compressed DWARF sections, which the |
| -// bounded ELF pre-check refuses (F1). Both are stamped v1.0.0 because the identity |
| -// names that version: guard-arm compares the carlos build with it, and the |
| -// console's own floor report (Disk.Running) compares the console's build |
| -// with it, so publish-identity refuses a console stamped otherwise. |
| -// checkActivationImages asserts both images match this command. |
| +// bounded ELF pre-check refuses (F1). The release under test is v1.0.0: |
| +// guard-arm compares the carlos build with the identity, and the console's |
| +// own floor report (Disk.Running) compares the console's build with it, so |
| +// publish-identity refuses a console stamped otherwise. v0.9.0 is the older |
| +// release the box is first installed with, so the update leaves it in |
| +// carlos.prev (prev-rehearsal R2). checkActivationImages asserts all three |
| +// images match this command. |
| const activationImagesBuild = `GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-carlos-runner.bin . |
| +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v0.9.0" -o internal/host/activation-carlos-old-runner.bin . |
| GOOS=linux GOARCH=arm64 CGO_ENABLED=0 GOFLAGS=-buildvcs=false go build -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0" -o internal/host/activation-console-runner.bin ./cmd/console` |
| func checkActivationImages(t *testing.T) { |
| @@ -57,6 +63,7 @@ func checkActivationImages(t *testing.T) { |
| path, ldflag string |
| }{ |
| {activationCarlosImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"}, |
| + {activationCarlosOldImage, "github.com/carlosframework/platform", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v0.9.0"}, |
| {activationConsoleImage, "github.com/carlosframework/platform/cmd/console", "-s -w -X github.com/carlosframework/platform/internal/version.Build=v1.0.0"}, |
| } { |
| info, err := buildinfo.Read(bytes.NewReader(img.raw)) |
| @@ -73,25 +80,82 @@ func checkActivationImages(t *testing.T) { |
| } |
| } |
| -// This test allows only loopback HTTP to its S3 and console fixtures. The |
| -// executable, guard, systemd, proc observation, HTTP auth and reset are real. |
| -// Sophie uses the runner’s real AWS identity; Leo, box telemetry and initial |
| -// app/claim state are fixtures. No deployment bucket or tenant is touched. |
| +// activationInspected is `carlos system claims inspect`'s output. |
| +type activationInspected struct { |
| + Roster hostactors.Roster `json:"roster"` |
| + ETag string `json:"etag"` |
| + SignedRoster json.RawMessage `json:"signed_roster"` |
| +} |
| + |
| +// activationPhaseResult is `carlos system claims run`'s output. |
| +type activationPhaseResult struct { |
| + claimactivation.Result |
| + Out string `json:"out,omitempty"` |
| +} |
| + |
| +// The claim trial's steps 6 and 7, rehearsed on a box the platform made |
| +// (prev-rehearsal ruling R2). Nothing on the box is written by hand but what |
| +// the region-host README's four steps write (the first binary and host.env) |
| +// and the box-only console.service with its database: |
| +// |
| +// - the box is installed at v0.9.0 (README steps 1 to 4: the binary, |
| +// host.env, `carlos bootstrap`, the edge), and the real agent and |
| +// `carlos system apply`, run by carlos-system-update.service, update it |
| +// to v1.0.0, leaving v0.9.0 in carlos.prev, as on the trial's boxes; |
| +// - its AWS identity record is published by `carlos system box |
| +// publish-record` as the agent user, as step 5.0 runs it; |
| +// - the roster is made by `claims run` initialize to enroll-console, and |
| +// the box and its console are enrolled with the runner's real AWS |
| +// identity; |
| +// - the trial's 6.2 stop is reproduced, then `install-channel --policy` |
| +// makes carlos.prev the release (R1), and step 6 and step 7 run as the |
| +// runbook gives them, with the real agent's box reports as evidence. |
| +// |
| +// One AWS instance can be enrolled as one box only (boxid refuses a second |
| +// reference for an instance), so this box is both the trial's hosting box |
| +// and its console box: guard-arm runs in leo's form, then in sophie's. |
| +// Fixtures that remain: the loopback S3 bucket, the console release (shipped |
| +// and promoted in-process with the production functions), publish-identity's |
| +// sums key (the vendor key cannot sign a fixture; the real binary's refusal |
| +// of the fixture sums is checked), the console's versions tree, the console |
| +// unit and database, and the claim state behind the authenticated reset. |
| +// Network: every carlos unit gets a runtime drop-in allowing only loopback |
| +// and the instance metadata service. |
| func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| - if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 { |
| + if os.Geteuid() != 0 || len(activationConsoleImage) == 0 || len(activationCarlosImage) == 0 || len(activationCarlosOldImage) == 0 { |
| t.Fatal("requires root and embedded activation binaries") |
| } |
| checkActivationImages(t) |
| - uid, gid := generationRunnerRegistryUser(t) |
| + if pid1, err := os.ReadFile("/proc/1/comm"); err != nil || strings.TrimSpace(string(pid1)) != "systemd" { |
| + t.Fatal("root runner requires systemd", err) |
| + } |
| instance := status.InstanceID() |
| if instance == "" { |
| t.Fatal("runner instance identity unavailable") |
| } |
| - for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} { |
| + // The box's name is the one its agent publishes under. |
| + box := status.Label(readMachineID()) |
| + if !boxid.ValidBox(box) { |
| + t.Fatal("runner box name", box) |
| + } |
| + const bucket = "activation-fixture" |
| + const consoleID = "dev-console" |
| + opt := release.OptDir() |
| + owned := []string{"/opt/carlos", "/etc/carlos", "/data", "/run/carlos", "/etc/systemd/system/carlos-edge.service", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d"} |
| + for _, p := range owned { |
| if _, err := os.Lstat(p); !os.IsNotExist(err) { |
| t.Fatal("pre-existing fixture path", p, err) |
| } |
| } |
| + if _, err := user.Lookup(serviceUser); err == nil { |
| + t.Fatal("the service user exists before the box is made") |
| + } |
| + // Everything bootstrap and the steps add is found by name afterwards. |
| + listed := []string{"/etc/systemd/system", "/run/systemd/system", "/etc/tmpfiles.d", "/etc/sysusers.d", "/etc/systemd/journald.conf.d"} |
| + before := map[string]map[string]bool{} |
| + for _, dir := range listed { |
| + before[dir] = activationDirNames(dir) |
| + } |
| d := activationRootFixture(t) |
| check := func(err error) { |
| t.Helper() |
| @@ -103,110 +167,314 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| t.Helper() |
| check(os.MkdirAll(filepath.Dir(path), 0755)) |
| check(os.WriteFile(path, b, mode)) |
| + check(os.Chmod(path, mode)) |
| + } |
| + absent := func(path string) { |
| + t.Helper() |
| + if _, e := os.Lstat(path); !os.IsNotExist(e) { |
| + t.Fatal("present", path, e) |
| + } |
| + } |
| + journal := func(unit string, n int) string { |
| + out, _ := exec.Command("journalctl", "-u", unit, "--no-pager", "-n", fmt.Sprint(n)).CombinedOutput() |
| + return string(out) |
| } |
| t.Cleanup(func() { |
| - out, _ := exec.Command("journalctl", "-u", "console.service", "--no-pager", "-n", "45").CombinedOutput() |
| - t.Log(string(out)) |
| - exec.Command("systemctl", "stop", "console.service").Run() |
| - for _, p := range []string{"/opt/carlos", "/etc/carlos", "/etc/systemd/system/console.service", "/etc/systemd/system/console.service.d", "/etc/systemd/system/carlos-edge.service.d", "/etc/systemd/system/carlos-system-update.service.d", "/etc/systemd/system/carlos-system-recover.service.d"} { |
| + for _, u := range []string{"carlos-edge.service", "carlos-system-update.service", "console.service"} { |
| + t.Log(u, "journal:\n"+journal(u, 80)) |
| + } |
| + var added []string |
| + for _, dir := range listed { |
| + for name := range activationDirNames(dir) { |
| + if !before[dir][name] { |
| + added = append(added, filepath.Join(dir, name)) |
| + } |
| + } |
| + } |
| + var unitNames []string |
| + for _, p := range added { |
| + if filepath.Dir(p) == "/etc/systemd/system" && !strings.HasSuffix(p, ".d") && !strings.HasSuffix(p, ".wants") { |
| + unitNames = append(unitNames, filepath.Base(p)) |
| + } |
| + } |
| + exec.Command("systemctl", "stop", "console.service", "carlos-edge.service").Run() |
| + if len(unitNames) > 0 { |
| + exec.Command("systemctl", append([]string{"stop"}, unitNames...)...).Run() |
| + exec.Command("systemctl", append([]string{"disable"}, unitNames...)...).Run() |
| + } |
| + for _, m := range []string{"data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount"} { |
| + exec.Command("systemctl", "stop", m).Run() |
| + } |
| + for _, p := range added { |
| + os.RemoveAll(p) |
| + } |
| + for _, p := range owned { |
| os.RemoveAll(p) |
| } |
| exec.Command("systemctl", "daemon-reload").Run() |
| - exec.Command("systemctl", "reset-failed", "console.service").Run() |
| + exec.Command("systemctl", "reset-failed").Run() |
| + exec.Command("userdel", serviceUser).Run() |
| + exec.Command("groupdel", socketsGroup).Run() |
| }) |
| store := release.NewDirStore(filepath.Join(d, "bucket")) |
| endpoint := activationS3Fixture(t, store) |
| pub, key, _ := ed25519.GenerateKey(nil) |
| signer := release.RootSigner(key) |
| - const bucket = "activation-fixture" |
| + pubArg := base64.StdEncoding.EncodeToString(pub) |
| ctx := t.Context() |
| - directory := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket} |
| - runner := claimactivation.Runner{Directory: directory, Signer: signer} |
| - phase := func(p claimactivation.Plan) { |
| - t.Helper() |
| - p.Bucket = bucket |
| - if o, e := directory.Read(ctx); e == nil { |
| - p.ExpectedRevision = o.Roster.Revision |
| + // host.env (README step 2), with the hibernation block a hosting box |
| + // carries, as leo's does. No release key: a box only verifies. (An agent |
| + // with no CARLOS_HIBERNATE_BUCKET panics at start on this tree: runAgent |
| + // dereferences the nil activator config at registerProductionMovesParks. |
| + // Reported, not fixed here.) |
| + hostEnv := []string{"CARLOS_DEPLOYMENT_BUCKET=" + bucket, "CARLOS_HIBERNATE_BUCKET=" + bucket + "-ie", "CARLOS_HIBERNATE_REGION=eu-west-1", "CARLOS_HIBERNATE_PREFIX=instances", "CARLOS_RELEASE_PUBKEY=" + pubArg, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint} |
| + for _, kv := range hostEnv { |
| + if k, v, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "AWS_") || k == "CARLOS_S3_ENDPOINT" { |
| + t.Setenv(k, v) |
| } |
| - before := activationStoreSnapshot(t, store) |
| - preview, e := runner.Run(ctx, p, true) |
| - check(e) |
| - if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| - t.Fatal("dry run mutated bucket", p.Phase) |
| + } |
| + // A root shell on the box after `set -a; . /etc/carlos/host.env; set +a`, |
| + // as the runbook's box blocks run. The signer's commands get the same |
| + // bucket and credentials. |
| + shellEnv := append([]string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}, hostEnv...) |
| + try := func(bin string, args ...string) (string, string, error) { |
| + t.Helper() |
| + cmd := exec.CommandContext(ctx, bin, args...) |
| + cmd.Env = shellEnv |
| + cmd.Dir = d |
| + var stdout, stderr bytes.Buffer |
| + cmd.Stdout, cmd.Stderr = &stdout, &stderr |
| + err := cmd.Run() |
| + return stdout.String(), stderr.String(), err |
| + } |
| + run := func(want bool, bin string, args ...string) (string, string) { |
| + t.Helper() |
| + stdout, stderr, err := try(bin, args...) |
| + if (err == nil) != want { |
| + t.Fatal(bin, args, "\nstdout:", stdout, "\nstderr:", stderr, err) |
| } |
| - result, e := runner.Run(ctx, p, false) |
| - check(e) |
| - if preview.Revision != result.Revision || len(preview.Writes) != len(result.Writes) { |
| - t.Fatal("preview/apply differ", p.Phase, preview, result) |
| + return stdout, stderr |
| + } |
| + carlos := func(want bool, args ...string) (string, string) { |
| + t.Helper() |
| + return run(want, "/opt/carlos/carlos", args...) |
| + } |
| + ctl := func(args ...string) string { |
| + t.Helper() |
| + out, e := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| + if e != nil { |
| + t.Fatal(args, string(out), e) |
| } |
| - t.Log("phase", p.Phase, "revision", result.Revision, "writes", len(result.Writes)) |
| + return strings.TrimSpace(string(out)) |
| } |
| - phase(claimactivation.Plan{Phase: "initialize"}) |
| - phase(claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "640012953448", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}) |
| - identityRecord, err := readIdentityRecord(&http.Client{Timeout: 3 * time.Second}, imdsBase, "sophie", time.Now()) |
| - check(err) |
| - check(boxid.Publish(ctx, store, identityRecord)) |
| - boxPlan := claimactivation.BoxInput{Reference: "sophie", Namespace: "platform", Label: "sophie", Region: "ie", Pool: "console", AWSRegion: "eu-west-1", InstanceID: instance, Operator: "root-runner"} |
| - phase(claimactivation.Plan{Phase: "invite-box", Box: &boxPlan}) |
| - phase(claimactivation.Plan{Phase: "enroll-box", Box: &boxPlan}) |
| - o, err := directory.Read(ctx) |
| + readers := func() (string, string) { |
| + t.Helper() |
| + cur, _ := run(true, systemBinaryPath(opt), "version") |
| + prev, _ := run(true, systemPrevPath(opt), "version") |
| + return strings.TrimSpace(cur), strings.TrimSpace(prev) |
| + } |
| + |
| + // The box, made by the platform's own install and update path. |
| + // README step 1: the binary. The box's first build is the older release. |
| + write(systemBinaryPath(opt), activationCarlosOldImage, 0755) |
| + // Step 2: host.env. |
| + write("/etc/carlos/host.env", []byte(strings.Join(hostEnv, "\n")+"\n"), 0600) |
| + // Step 3: carlos bootstrap. |
| + out, _ := carlos(true, "bootstrap") |
| + t.Log("bootstrap:", out) |
| + for _, u := range []string{"carlos-edge.service", "carlos-system-update.service", "carlos-system-recover.service", "carlos-adopt.service", "carlos-stage.service", "carlos-restart.service", "carlos-config-sync.service", "carlos-instance-control.service"} { |
| + write("/run/systemd/system/"+u+".d/10-rootrunner-network.conf", []byte("[Service]\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n"), 0644) |
| + } |
| + ctl("daemon-reload") |
| + // The operator promotes v1.0.0 on the box's channel, as the console's |
| + // system update door does. |
| + artifact := release.SystemArtifact{Key: release.SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: hostfloor.Digest(activationCarlosImage)} |
| + _, err := store.Put(ctx, artifact.Key, activationCarlosImage, "") |
| check(err) |
| - next := hostactors.Clone(o.Roster) |
| - next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("2", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "640012953448", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}} |
| - _, err = directory.Replace(ctx, signer, o, next) |
| + _, err = release.PromoteSystem(ctx, store, signer, release.SystemComponentRouter, "stable", release.SystemRelease{Version: "v1.0.0", ReleaseTag: "v1.0.0", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-arm64": artifact}}, release.SystemPromoteOptions{Actor: "root-runner"}) |
| check(err) |
| - c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}} |
| - phase(claimactivation.Plan{Phase: "invite-console", Console: &c}) |
| - phase(claimactivation.Plan{Phase: "enroll-console", Console: &c}) |
| - // The environment every real carlos command below runs with, as host.env |
| - // would give it on a box. In-process S3 clients get the same endpoint. |
| - environment := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "CARLOS_DEPLOYMENT_BUCKET=" + bucket, "AWS_REGION=eu-west-1", "AWS_DEFAULT_REGION=eu-west-1", "AWS_ACCESS_KEY_ID=fixture", "AWS_SECRET_ACCESS_KEY=fixture", "AWS_EC2_METADATA_DISABLED=true", "AWS_CONFIG_FILE=/nonexistent", "AWS_SHARED_CREDENTIALS_FILE=/nonexistent", "CARLOS_S3_ENDPOINT=" + endpoint, "CARLOS_RELEASE_PUBKEY=" + base64.StdEncoding.EncodeToString(pub), "CARLOS_RELEASE_KEY=" + base64.StdEncoding.EncodeToString(key.Seed()), "CARLOS_DATA_DIR=" + d + "/data"} |
| - for _, kv := range environment { |
| - if k, v, _ := strings.Cut(kv, "="); strings.HasPrefix(k, "AWS_") || k == "CARLOS_S3_ENDPOINT" { |
| - t.Setenv(k, v) |
| + // Step 4: the edge. Its agent finds the promote, fetches the build and |
| + // asks root, and carlos-system-update.service applies it. |
| + ctl("start", "carlos-edge.service") |
| + deadline := time.Now().Add(5 * time.Minute) |
| + for { |
| + r, _ := readSystemResult(opt) |
| + if r != nil && r.Version == "v1.0.0" && r.Outcome == systemOutcomeApplied { |
| + break |
| + } |
| + if r != nil && r.Version == "v1.0.0" && r.Outcome != systemOutcomeDeferred { |
| + t.Fatalf("the update to v1.0.0 ended %s: %s\n%s", r.Outcome, r.Reason, journal("carlos-system-update.service", 120)) |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("the update to v1.0.0 did not apply: %+v\n%s\n%s", r, journal("carlos-edge.service", 120), journal("carlos-system-update.service", 120)) |
| } |
| + time.Sleep(2 * time.Second) |
| } |
| - pubArg := base64.StdEncoding.EncodeToString(pub) |
| - carlos := func(want bool, args ...string) string { |
| + if j, _ := readSystemJournal(opt); j != nil { |
| + t.Fatal("the update left its journal", j) |
| + } |
| + cur, _ := os.ReadFile(systemBinaryPath(opt)) |
| + prev, _ := os.ReadFile(systemPrevPath(opt)) |
| + if !bytes.Equal(cur, activationCarlosImage) || !bytes.Equal(prev, activationCarlosOldImage) { |
| + t.Fatal("the update did not leave v1.0.0 in carlos and v0.9.0 in carlos.prev") |
| + } |
| + if applied, e := readSystemApplied(opt); e != nil || applied == nil || applied.Version != "v1.0.0" { |
| + t.Fatal("applied", applied, e) |
| + } |
| + absent(channelIdentityPath(opt)) |
| + if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v0.9.0" { |
| + t.Fatal("readers before install-channel", c, p) |
| + } |
| + if state := ctl("is-active", "carlos-edge.service"); state != "active" { |
| + t.Fatal("edge", state) |
| + } |
| + t.Log("box made: carlos v1.0.0, carlos.prev v0.9.0, by bootstrap and the real update") |
| + |
| + // Step 5.0: the box's AWS identity record, as the agent user. |
| + publishRecord := func() string { |
| + t.Helper() |
| + out, _ := run(true, "systemd-run", "--wait", "--pipe", "--quiet", "-p", "EnvironmentFile=/etc/carlos/host.env", "-p", "User="+serviceUser, "-p", "IPAddressDeny=any", "-p", "IPAddressAllow=127.0.0.1 169.254.169.254", systemBinaryPath(opt), "system", "box", "publish-record") |
| + return strings.TrimSpace(out) |
| + } |
| + if line := publishRecord(); line != fmt.Sprintf("published the identity record for %s (instance %s) to control/boxes/%s.json", box, instance, box) { |
| + t.Fatal("publish-record", line) |
| + } |
| + if line := publishRecord(); !strings.HasPrefix(line, fmt.Sprintf("the record at control/boxes/%s.json is valid AWS-signed evidence for %s (instance %s), so nothing was written", box, box, instance)) { |
| + t.Fatal("publish-record verify", line) |
| + } |
| + |
| + // The trusted signing host: its CLI, the key in a root 0600 file. |
| + sd := filepath.Join(d, "signer") |
| + check(os.MkdirAll(sd, 0700)) |
| + write(sd+"/carlos", activationCarlosImage, 0755) |
| + write(sd+"/release.key", []byte(base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600) |
| + signerCLI := func(want bool, args ...string) (string, string) { |
| + t.Helper() |
| + return run(want, sd+"/carlos", args...) |
| + } |
| + inspectWith := func(bin string) activationInspected { |
| + t.Helper() |
| + out, _ := run(true, bin, "system", "claims", "inspect", "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg) |
| + var o activationInspected |
| + check(json.Unmarshal([]byte(out), &o)) |
| + return o |
| + } |
| + refresh := func() activationInspected { t.Helper(); return inspectWith(sd + "/carlos") } |
| + // The control objects a phase may write; the agent writes status beside |
| + // them throughout. |
| + controlSnapshot := func() map[string]string { |
| t.Helper() |
| - cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", args...) |
| - cmd.Env = environment |
| - output, e := cmd.CombinedOutput() |
| - if (e == nil) != want { |
| - t.Fatal(args, string(output), e) |
| + all := activationStoreSnapshot(t, store) |
| + for k := range all { |
| + keep := k == hostactors.Key || k == hostfloor.Key || k == hostfloor.AuthKey |
| + for _, prefix := range []string{"control/host-actor-invitations/", "control/host-console-invitations/", "control/system/router/releases/"} { |
| + keep = keep || strings.HasPrefix(k, prefix) |
| + } |
| + if !keep { |
| + delete(all, k) |
| + } |
| } |
| - return string(output) |
| + return all |
| } |
| - absent := func(path string) { |
| + runArgs := func(out string) []string { |
| + args := []string{"system", "claims", "run", "--plan", sd + "/plan.json", "--signing-key", sd + "/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1"} |
| + if out != "" { |
| + args = append(args, "--out", out) |
| + } |
| + return args |
| + } |
| + // apply_plan (and phase_out, with out): the dry run, then the apply. |
| + tryPlan := func(p claimactivation.Plan, out string) (activationPhaseResult, error) { |
| t.Helper() |
| - if _, e := os.Lstat(path); !os.IsNotExist(e) { |
| - t.Fatal("present", path, e) |
| + raw, _ := json.Marshal(p) |
| + write(sd+"/plan.json", raw, 0600) |
| + before := controlSnapshot() |
| + if stdout, stderr, e := try(sd+"/carlos", append(runArgs(out), "--dry-run")...); e != nil { |
| + return activationPhaseResult{}, fmt.Errorf("%s dry run: %v: %s %s", p.Phase, e, stdout, stderr) |
| + } |
| + if !reflect.DeepEqual(before, controlSnapshot()) { |
| + t.Fatal("dry run wrote", p.Phase) |
| + } |
| + if out != "" { |
| + absent(out) |
| + } |
| + stdout, stderr, e := try(sd+"/carlos", runArgs(out)...) |
| + if e != nil { |
| + return activationPhaseResult{}, fmt.Errorf("%s: %v: %s %s", p.Phase, e, stdout, stderr) |
| } |
| + var r activationPhaseResult |
| + check(json.Unmarshal([]byte(stdout), &r)) |
| + t.Log("phase", p.Phase, "revision", r.Revision, "writes", len(r.Writes)) |
| + return r, nil |
| + } |
| + applyPlan := func(p claimactivation.Plan, out string) activationPhaseResult { |
| + t.Helper() |
| + r, e := tryPlan(p, out) |
| + check(e) |
| + return r |
| + } |
| + phase := func(name string, p claimactivation.Plan) activationPhaseResult { |
| + t.Helper() |
| + p.Phase, p.Bucket, p.ExpectedRevision = name, bucket, refresh().Roster.Revision |
| + return applyPlan(p, "") |
| } |
| - write("/opt/carlos/carlos", activationCarlosImage, 0755) |
| - write("/opt/carlos/carlos.prev", activationCarlosImage, 0755) |
| - write(d+"/release.key", []byte(base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600) |
| - // The console release, as carlos ship and carlos promote leave it with |
| - // pointer digests on: a signed pointer naming the build and its manifest |
| - // digest. |
| + // Step 5.1: roster and enrollment. |
| + applyPlan(claimactivation.Plan{Phase: "initialize", Bucket: bucket}, "") |
| + phase("configure", claimactivation.Plan{Regions: map[string]hostactors.Region{"ie": {Account: "640012953448", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"cohort1", "console"}}}}) |
| + boxInput := claimactivation.BoxInput{Reference: box, Namespace: "platform", Label: box, Region: "ie", Pool: "cohort1", AWSRegion: "eu-west-1", InstanceID: instance, Operator: "root-runner"} |
| + phase("invite-box", claimactivation.Plan{Box: &boxInput}) |
| + phase("enroll-box", claimactivation.Plan{Box: &boxInput}) |
| + enrolled, ok := refresh().Roster.Boxes[box] |
| + if !ok || enrolled.Pool != "cohort1" || enrolled.Cloud.InstanceID != instance || enrolled.State != "active" { |
| + t.Fatal("enrolled box", enrolled) |
| + } |
| + token := make([]byte, 16) |
| + _, err = rand.Read(token) |
| + check(err) |
| + consoleIn := hostactors.Console{InstanceID: consoleID, Enrollment: hex.EncodeToString(token), DeploymentBucket: bucket, Namespaces: []string{"platform"}, Compute: &hostactors.ComputeBinding{Box: box, Enrollment: enrolled.Enrollment}} |
| + phase("invite-console", claimactivation.Plan{Console: &consoleIn}) |
| + phase("enroll-console", claimactivation.Plan{Console: &consoleIn}) |
| + if o := refresh(); o.Roster.Consoles[consoleID].Compute == nil || len(o.Roster.Boxes) != 1 { |
| + t.Fatal("inspect", o.Roster) |
| + } |
| + // The box's staged inputs (/root/claim-trial on the trial's boxes). |
| + bd := filepath.Join(d, "box") |
| + check(os.MkdirAll(bd, 0700)) |
| + enrollmentRaw, _ := json.Marshal(hostfloor.Enrollment{Box: box, Enrollment: enrolled.Enrollment, InstanceID: enrolled.Cloud.InstanceID}) |
| + write(bd+"/"+box+"-enrollment.json", enrollmentRaw, 0600) |
| + |
| + // Step 6.1: the armed policy, through the real binary's sign-policy. |
| + floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| + floorRaw, _ := json.Marshal(floor) |
| + signed := applyPlan(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor}, sd+"/policy.json") |
| + policyRaw, err := os.ReadFile(sd + "/policy.json") |
| + check(err) |
| + if len(signed.Writes) != 0 || signed.FloorSHA256 != hostfloor.Digest(floorRaw) || signed.OutputSHA256 != hostfloor.Digest(policyRaw) { |
| + t.Fatal("sign-policy", signed.Result) |
| + } |
| + signerCLI(false, runArgs(sd+"/policy.json")...) // --out is never replaced |
| + var policy hostfloor.Policy |
| + check(json.Unmarshal(policyRaw, &policy)) |
| + if st, e := os.Stat(sd + "/policy.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" { |
| + t.Fatal("policy file", st, e) |
| + } |
| + // The identity. The console release, as carlos ship and carlos promote |
| + // leave it with pointer digests on (step 5.0a). |
| check(release.WriteDigestMarker(ctx, store, signer, "bab", "root-runner")) |
| check(release.Ship(ctx, store, "bab", release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}}, map[string][]byte{"linux-arm64": activationConsoleImage})) |
| check(signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234")) |
| - |
| - // The release identity, through the publish-identity phase over the |
| - // production S3 store (create-only PUT) against the loopback bucket. The |
| - // router hash comes from a signed checksum list; the runner's sums key is |
| - // this fixture's, because the vendor key cannot sign a fixture. The real |
| - // binary's own refusal of that list is checked below. |
| + // publish-identity over the production S3 store (create-only PUT) |
| + // against the loopback bucket. The router hash comes from a signed |
| + // checksum list; the runner's sums key is this fixture's, because the |
| + // vendor key cannot sign a fixture. The real binary's own refusal of that |
| + // list is checked below. |
| s3, err := release.NewS3StoreIn(ctx, bucket, "eu-west-1") |
| check(err) |
| sumsPub, sumsKey, _ := ed25519.GenerateKey(nil) |
| identityRunner := claimactivation.Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: bucket}, Signer: signer, SumsKey: base64.StdEncoding.EncodeToString(sumsPub)} |
| sums := hostfloor.Digest(activationCarlosImage) + " carlos-linux-arm64\n" + hostfloor.Digest([]byte("amd64 deb")) + " carlos_1.0.0_amd64.deb\n" + hostfloor.Digest([]byte("arm64 deb")) + " carlos_1.0.0_arm64.deb\n" |
| - o, err = directory.Read(ctx) |
| - check(err) |
| - identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums, |
| + identityPlan := claimactivation.Plan{Phase: "publish-identity", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Identity: &claimactivation.IdentityInput{ReleaseTag: "v1.0.0", MovesProtocol: 1, Sums: sums, |
| SumsSignature: base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(sums))), |
| Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}} |
| preview, err := identityRunner.Run(ctx, identityPlan, true) |
| @@ -216,82 +484,89 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| } |
| published, err := identityRunner.Run(ctx, identityPlan, false) |
| check(err) |
| - identityOut, err := reserveActivationOutput(d+"/identity.json", false) |
| + identityOut, err := reserveActivationOutput(sd+"/identity.json", false) |
| check(err) |
| check(identityOut.finish(published.Output)) |
| identityOut.close() |
| + identityRaw, err := os.ReadFile(sd + "/identity.json") |
| + check(err) |
| planRaw, _ := json.Marshal(identityPlan) |
| - write(d+"/identity-plan.json", planRaw, 0600) |
| - carlos(false, "system", "claims", "run", "--plan", d+"/identity-plan.json", "--signing-key", d+"/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1", "--out", d+"/identity-vendor.json") |
| - absent(d + "/identity-vendor.json") |
| + write(sd+"/plan.json", planRaw, 0600) |
| + if _, stderr := signerCLI(false, runArgs(sd+"/identity-vendor.json")...); !strings.Contains(stderr, sd+"/identity-vendor.json was kept. run it again with a new --out path") { |
| + t.Fatal("vendor-key refusal", stderr) |
| + } |
| + if st, e := os.Stat(sd + "/identity-vendor.json"); e != nil || st.Size() != 0 { |
| + t.Fatal("the refused run's reservation", st, e) |
| + } |
| + // Staged on the box, byte for byte (box_put). |
| + write(bd+"/policy.json", policyRaw, 0600) |
| + write(bd+"/identity.json", identityRaw, 0600) |
| - // The armed policy, through the real binary's sign-policy phase, to --out. |
| - floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| - floorRaw, _ := json.Marshal(floor) |
| - o, err = directory.Read(ctx) |
| - check(err) |
| - planRaw, _ = json.Marshal(claimactivation.Plan{Phase: "sign-policy", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Floor: &floor}) |
| - write(d+"/sign-policy.json", planRaw, 0600) |
| - sign := []string{"system", "claims", "run", "--plan", d + "/sign-policy.json", "--signing-key", d + "/release.key", "--public-key", pubArg, "--aws-region", "eu-west-1", "--out", d + "/policy.json"} |
| - before := activationStoreSnapshot(t, store) |
| - carlos(true, append(append([]string{}, sign...), "--dry-run")...) |
| - absent(d + "/policy.json") |
| - t.Log("sign-policy", carlos(true, sign...)) |
| - carlos(false, sign...) // --out is never replaced |
| - if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| - t.Fatal("sign-policy wrote to the bucket") |
| - } |
| - policyRaw, err := os.ReadFile(d + "/policy.json") |
| - check(err) |
| - var policy hostfloor.Policy |
| - check(json.Unmarshal(policyRaw, &policy)) |
| - if st, e := os.Stat(d + "/policy.json"); e != nil || st.Mode().Perm() != 0600 || !bytes.Equal(policy.Floor, floorRaw) || policy.Mode != "armed" { |
| - t.Fatal("policy file", st, e) |
| + // Step 6.2 as the trial ran it: guard-arm refuses the updated box, whose |
| + // carlos.prev is the release before, and writes nothing. |
| + armLeo := []string{"system", "guard-arm", "--policy", bd + "/policy.json", "--identity", bd + "/identity.json", "--enrollment", bd + "/" + box + "-enrollment.json"} |
| + _, guardBefore := os.Lstat(systemGuardDir(opt)) |
| + if _, stderr := carlos(false, armLeo...); !strings.Contains(stderr, "hostfloor: authenticated floor required") { |
| + t.Fatal("the trial's stop did not reproduce", stderr) |
| + } |
| + absent("/etc/carlos/host-actor.json") |
| + absent("/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf") |
| + if _, e := os.Lstat(systemGuardDir(opt)); os.IsNotExist(e) != os.IsNotExist(guardBefore) { |
| + t.Fatal("the refused guard-arm changed the guard directory", e) |
| + } |
| + t.Log("step 6.2 stop reproduced: guard-arm refuses carlos.prev v0.9.0") |
| + // install-channel without --policy is the trial's loop: no floor yet. |
| + if _, stderr := carlos(false, "system", "install-channel"); !strings.HasSuffix("\n"+strings.TrimSpace(stderr), "\nno host floor is published, so this box could not arm its guard and nothing was installed") { |
| + t.Fatal("install-channel without --policy", stderr) |
| + } |
| + // R1: install-channel --policy on the box. |
| + if out, _ := carlos(true, "system", "install-channel", "--policy", bd+"/policy.json"); strings.TrimSpace(out) != "replaced the rollback copy carlos.prev with v1.0.0 (v1.0.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| + t.Fatal("install-channel --policy", out) |
| + } |
| + cur, _ = os.ReadFile(systemBinaryPath(opt)) |
| + prev, _ = os.ReadFile(systemPrevPath(opt)) |
| + if !bytes.Equal(cur, activationCarlosImage) || !bytes.Equal(prev, activationCarlosImage) { |
| + t.Fatal("install-channel --policy did not leave v1.0.0 in both slots") |
| + } |
| + if held, e := os.ReadFile(channelIdentityPath(opt)); e != nil || !bytes.Equal(held, identityRaw) { |
| + t.Fatal("the channel identity is not the published one", e) |
| + } |
| + if out, _ := carlos(true, "system", "install-channel", "--policy", bd+"/policy.json"); strings.TrimSpace(out) != "v1.0.0 (v1.0.0) from the stable channel is already installed" { |
| + t.Fatal("install-channel --policy again", out) |
| + } |
| + // Readers (P3's box line): both slots are the trial release. |
| + if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v1.0.0" { |
| + t.Fatal("readers after install-channel", c, p) |
| + } |
| + // Step 6.2: arm the hosting box (leo's form). |
| + armed := time.Now() |
| + carlos(true, armLeo...) |
| + if raw, e := os.ReadFile("/etc/carlos/host-actor.json"); e != nil || !bytes.Equal(raw, enrollmentRaw) { |
| + t.Fatal("host-actor.json", e) |
| + } |
| + if raw, e := os.ReadFile("/etc/systemd/system/carlos-edge.service.d/90-host-floor.conf"); e != nil || !strings.Contains(string(raw), "guard-exec edge") { |
| + t.Fatal("edge drop-in", e) |
| } |
| + t.Log("step 6.2 passed: guard-arm armed the updated box") |
| - // The protected console copies, from a carlos-owned versions tree as on |
| - // the dev console box, through the real binary's install-console. |
| + // Step 6.3: the console box. The console release's build, staged in a |
| + // carlos-owned versions tree as on the dev console box. |
| + uid, gid := activationServiceIDs(t) |
| + check(os.Chmod(d, 0755)) |
| source := d + "/versions/abc1234/console" |
| write(source, activationConsoleImage, 0755) |
| write(d+"/versions/other/console", append([]byte("not the release "), activationConsoleImage[:64]...), 0755) |
| for _, p := range []string{d + "/versions", d + "/versions/abc1234", source} { |
| check(os.Chown(p, uid, gid)) |
| } |
| - install := func(src string) []string { |
| - return []string{"system", "claims", "install-console", "--identity", d + "/identity.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg} |
| - } |
| - carlos(false, install(d+"/versions/other/console")...) |
| - carlos(false, install("/opt/carlos/carlos")...) |
| - absent("/opt/carlos/system/console") |
| - carlos(true, append(install(source), "--dry-run")...) |
| - absent("/opt/carlos/system/console") |
| - t.Log("install-console", carlos(true, install(source)...)) |
| - for _, p := range []string{"/opt/carlos/system/console/console", "/opt/carlos/system/console/console.prev"} { |
| - body, e := os.ReadFile(p) |
| - st, se := os.Stat(p) |
| - if e != nil || se != nil || !bytes.Equal(body, activationConsoleImage) || st.Mode().Perm() != 0755 || st.Sys().(*syscall.Stat_t).Uid != 0 || st.Sys().(*syscall.Stat_t).Gid != 0 { |
| - t.Fatal("protected console", p, e, se) |
| - } |
| - } |
| - if out := carlos(true, install(source)...); !strings.Contains(out, `"already_installed": true`) { |
| - t.Fatal("rerun was not a no-op", out) |
| - } |
| - local := hostfloor.Enrollment{Box: "sophie", Enrollment: c.Compute.Enrollment, InstanceID: instance} |
| - localRaw, _ := json.Marshal(local) |
| - write(d+"/enrollment.json", localRaw, 0600) |
| - check(os.MkdirAll(d+"/data", 0755)) |
| - check(os.Chown(d+"/data", uid, gid)) |
| - check(os.Chmod(d, 0755)) |
| - write("/etc/carlos/host.env", []byte(strings.Join(environment[1:], "\n")+"\n"), 0600) |
| - carlos(true, "system", "guard-arm", "--policy", d+"/policy.json", "--identity", d+"/identity.json", "--enrollment", d+"/enrollment.json", "--console", "/opt/carlos/system/console/console") |
| - dbPath := d + "/data/console.db" |
| + // The box-only console.service and its database. |
| + dbPath := "/data/console.db" |
| st, err := console.Open(dbPath) |
| check(err) |
| now := time.Now().UTC() |
| check(st.CreateAccount(123, "bab", "fixture", "operator@example.test", now)) |
| _, err = st.CreateAppIn(123, "trial", "operator@example.test", now) |
| check(err) |
| - // OpenGated's schema migration is also used on the fixture before serving. |
| check(st.Close()) |
| st, err = console.OpenGated(dbPath) |
| check(err) |
| @@ -307,8 +582,8 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| check(e) |
| return token |
| } |
| - token := tokenFor("operator@example.test") |
| - other := tokenFor("member@example.test") |
| + operatorToken := tokenFor("operator@example.test") |
| + memberToken := tokenFor("member@example.test") |
| check(st.SetOperator("operator@example.test", true)) |
| check(st.Close()) |
| for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { |
| @@ -320,103 +595,160 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| check(err) |
| addr := ln.Addr().String() |
| check(ln.Close()) |
| - unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/system/console/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=" + d + "/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n" |
| + write("/etc/carlos/console-fixture.env", []byte("CARLOS_RELEASE_KEY="+base64.StdEncoding.EncodeToString(key.Seed())+"\n"), 0600) |
| + unit := "[Service]\nType=notify\nUser=carlos\nGroup=carlos\nEnvironmentFile=/etc/carlos/host.env\nEnvironmentFile=/etc/carlos/console-fixture.env\nEnvironment=CONSOLE_ADDR=" + addr + " CONSOLE_ORIGIN=http://" + addr + " CONSOLE_KEYMAIL=http://127.0.0.1:1 CONSOLE_DB=" + dbPath + "\nExecStart=/opt/carlos/system/console/console serve\nRestart=no\nTimeoutStartSec=30s\nNoNewPrivileges=yes\nProtectSystem=strict\nReadWritePaths=/data\nPrivateTmp=yes\nIPAddressDeny=any\nIPAddressAllow=127.0.0.1 169.254.169.254\n" |
| write("/etc/systemd/system/console.service", []byte(unit), 0644) |
| - config := func(enabled bool) { |
| - t.Helper() |
| - o, e := directory.Read(ctx) |
| - check(e) |
| - wire, _, e := store.Get(ctx, hostactors.Key) |
| + ctl("daemon-reload") |
| + // P9: no console mail name anywhere the console's environment comes from. |
| + for _, p := range []string{"/etc/carlos/host.env", "/etc/carlos/console-fixture.env", "/etc/systemd/system/console.service"} { |
| + raw, e := os.ReadFile(p) |
| check(e) |
| - write(d+"/roster.json", wire, 0600) |
| - args := []string{"console-config", "--roster", d + "/roster.json", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--bucket", bucket, "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub)} |
| - if enabled { |
| - args = append(args, "--enable-console-runtime") |
| + for _, name := range []string{"CARLOS_CONSOLE_MAIL_FROM", "CARLOS_MAIL_REGION", "CARLOS_MAIL_PLATFORM_SUFFIX", "CARLOS_MAIL_DMARC_RUA", "CARLOS_MAIL_SNS_TOPIC_ARN", "CARLOS_MAIL_OPERATORS", "CARLOS_MAIL_LEGACY_SWEEP"} { |
| + if bytes.Contains(raw, []byte(name)) { |
| + t.Fatal("P9: SET", name, p) |
| + } |
| } |
| - var output bytes.Buffer |
| - check(runSystemClaims(append(append([]string{}, args...), "--dry-run"), &output)) |
| - check(runSystemClaims(args, &output)) |
| } |
| - config(false) |
| - ctl := func(args ...string) { |
| + // box_inspect: the box reads the roster with its own credentials. |
| + boxInspect := func() uint64 { |
| t.Helper() |
| - out, e := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() |
| - if e != nil { |
| - t.Fatal(args, string(out), e) |
| + o := inspectWith(systemBinaryPath(opt)) |
| + write(bd+"/roster.json", o.SignedRoster, 0600) |
| + return o.Roster.Revision |
| + } |
| + rev := boxInspect() |
| + install := func(src string) []string { |
| + return []string{"system", "claims", "install-console", "--identity", bd + "/identity.json", "--source", src, "--bucket", bucket, "--aws-region", "eu-west-1", "--public-key", pubArg} |
| + } |
| + carlos(false, install(d+"/versions/other/console")...) |
| + carlos(false, install(systemBinaryPath(opt))...) |
| + absent("/opt/carlos/system/console") |
| + carlos(true, append(install(source), "--dry-run")...) |
| + absent("/opt/carlos/system/console") |
| + out, _ = carlos(true, install(source)...) |
| + t.Log("install-console", out) |
| + for _, p := range []string{"/opt/carlos/system/console/console", "/opt/carlos/system/console/console.prev"} { |
| + body, e := os.ReadFile(p) |
| + st, se := os.Stat(p) |
| + if e != nil || se != nil || !bytes.Equal(body, activationConsoleImage) || st.Mode().Perm() != 0755 || st.Sys().(*syscall.Stat_t).Uid != 0 || st.Sys().(*syscall.Stat_t).Gid != 0 { |
| + t.Fatal("protected console", p, e, se) |
| + } |
| + } |
| + if out, _ := carlos(true, install(source)...); !strings.Contains(out, `"already_installed": true`) { |
| + t.Fatal("rerun was not a no-op", out) |
| + } |
| + // guard-arm on the console box (sophie's form). |
| + carlos(true, append(append([]string{}, armLeo...), "--console", "/opt/carlos/system/console/console")...) |
| + dropIn, err := os.ReadFile("/etc/systemd/system/console.service.d/90-host-floor.conf") |
| + check(err) |
| + if string(dropIn) != hostfloor.ConsoleGuardDropIn("/opt/carlos/system/console/console") { |
| + t.Fatal("guard drop-in", string(dropIn)) |
| + } |
| + consoleConfig := func(enable bool) { |
| + t.Helper() |
| + args := []string{"system", "claims", "console-config", "--roster", bd + "/roster.json", "--expect-revision", fmt.Sprint(rev), "--bucket", bucket, "--console-id", consoleID, "--public-key", pubArg} |
| + if enable { |
| + args = append(args, "--enable-console-runtime") |
| } |
| + carlos(true, append(append([]string{}, args...), "--dry-run")...) |
| + carlos(true, args...) |
| + } |
| + consoleConfig(false) |
| + ctl("restart", "console.service") |
| + if state := ctl("is-active", "console.service"); state != "active" { |
| + t.Fatal("console", state) |
| } |
| - ctl("start", "console.service") |
| - report := func(enabled bool, previous string) hostactors.Report { |
| + consoleReported := func(enabled bool, previous string) hostactors.Report { |
| t.Helper() |
| - deadline := time.Now().Add(20 * time.Second) |
| + deadline := time.Now().Add(30 * time.Second) |
| for time.Now().Before(deadline) { |
| - raw, _, e := store.Get(ctx, "status/consoles/dev-console.json") |
| + raw, _, e := store.Get(ctx, "status/consoles/"+consoleID+".json") |
| var r hostactors.Report |
| - if e == nil && json.Unmarshal(raw, &r) == nil && r.HostClaimEnabled == enabled && r.ProcessID != previous { |
| + if e == nil && json.Unmarshal(raw, &r) == nil && r.HostClaimEnabled == enabled && r.ProcessID != "" && r.ProcessID != previous { |
| return r |
| } |
| - time.Sleep(100 * time.Millisecond) |
| + time.Sleep(200 * time.Millisecond) |
| } |
| - t.Fatal("console did not report", enabled) |
| + t.Fatal("console did not report", enabled, journal("console.service", 60)) |
| return hostactors.Report{} |
| } |
| - first := report(false, "") |
| - // The console runs under the guard drop-in as the service user, from |
| - // the protected copy's bytes. |
| - pidOut, err := exec.Command("systemctl", "show", "-p", "MainPID", "--value", "console.service").Output() |
| - check(err) |
| - pid := strings.TrimSpace(string(pidOut)) |
| - statusRaw, err := os.ReadFile("/proc/" + pid + "/status") |
| + first := consoleReported(false, "") |
| + // The console runs under the guard drop-in as the service user, from the |
| + // protected copy's bytes. |
| + pidOut := ctl("show", "-p", "MainPID", "--value", "console.service") |
| + statusRaw, err := os.ReadFile("/proc/" + pidOut + "/status") |
| check(err) |
| if !strings.Contains(string(statusRaw), fmt.Sprintf("\nUid:\t%d\t%d\t", uid, uid)) { |
| t.Fatal("console is not the service user", string(statusRaw)) |
| } |
| - exe, err := os.ReadFile("/proc/" + pid + "/exe") |
| + exe, err := os.ReadFile("/proc/" + pidOut + "/exe") |
| check(err) |
| if hostfloor.Digest(exe) != hostfloor.Digest(activationConsoleImage) { |
| t.Fatal("console process image differs from the protected copy") |
| } |
| - dropIn, err := os.ReadFile("/etc/systemd/system/console.service.d/90-host-floor.conf") |
| - check(err) |
| - if string(dropIn) != hostfloor.ConsoleGuardDropIn("/opt/carlos/system/console/console") { |
| - t.Fatal("guard drop-in", string(dropIn)) |
| + // The agent's own box report, once it has seen the armed guard: the |
| + // evidence the console report and the floor publication read. Nothing |
| + // here writes it. |
| + boxReported := func() { |
| + t.Helper() |
| + deadline := time.Now().Add(9 * time.Minute) |
| + for { |
| + raw, _, e := store.Get(ctx, "status/boxes/"+box+".json") |
| + var b status.Box |
| + if e == nil && json.Unmarshal(raw, &b) == nil && b.Metrics != nil && b.Metrics.FloorGuardReady && b.Metrics.FloorSHA256 == hostfloor.Digest(floorRaw) && b.Metrics.Enrollment == enrolled.Enrollment && b.GeneratedAt.After(armed) { |
| + t.Log("agent box report", b.GeneratedAt, b.Metrics.Release, b.Metrics.BinarySHA256) |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("the agent did not report the armed guard: %s\n%s", raw, journal("carlos-edge.service", 80)) |
| + } |
| + time.Sleep(5 * time.Second) |
| + } |
| } |
| - // Box reports are fixture telemetry. The console process entry is produced |
| - // by the real root observer below, never copied from the console report. |
| - put := func(k string, v any) { |
| + boxReported() |
| + // console-report: dry run, then the report. The agent's next box report |
| + // replaces the console process entry, so a report that loses that race |
| + // is run again (the runbook's rule). |
| + consoleReport := func() { |
| t.Helper() |
| - raw, _ := json.Marshal(v) |
| - _, tag, _ := store.Get(ctx, k) |
| - _, e := store.Put(ctx, k, raw, tag) |
| - check(e) |
| + var last error |
| + for attempt := 1; attempt <= 3; attempt++ { |
| + rev = boxInspect() |
| + args := []string{"system", "claims", "console-report", "--bucket", bucket, "--aws-region", "eu-west-1", "--expect-revision", fmt.Sprint(rev), "--console-id", consoleID, "--public-key", pubArg} |
| + if stdout, stderr, e := try(systemBinaryPath(opt), append(append([]string{}, args...), "--dry-run")...); e != nil { |
| + last = fmt.Errorf("dry run: %v: %s %s", e, stdout, stderr) |
| + } else if stdout, stderr, e := try(systemBinaryPath(opt), args...); e != nil { |
| + last = fmt.Errorf("%v: %s %s", e, stdout, stderr) |
| + } else { |
| + t.Log("console-report", stdout) |
| + return |
| + } |
| + t.Log("console-report attempt", attempt, last) |
| + time.Sleep(3 * time.Second) |
| + } |
| + t.Fatal("console-report", last) |
| } |
| - for ref, b := range next.Boxes { |
| - put("status/boxes/"+ref+".json", status.Box{GeneratedAt: time.Now().UTC(), Metrics: &status.BoxMetrics{Enrollment: b.Enrollment, InstanceID: b.Cloud.InstanceID, Release: "v1.0.0", ReleaseTag: "v1.0.0", Platform: "linux-arm64", BinarySHA256: hostfloor.Digest(activationCarlosImage), MovesProtocol: 1, BootID: first.BootID, FloorSHA256: hostfloor.Digest(floorRaw), FloorGuardReady: true}}) |
| + consoleReport() |
| + t.Log("step 6 passed") |
| + |
| + // Step 7.1: readers. Every slot is the trial release. |
| + if c, p := readers(); c != "carlos v1.0.0" || p != "carlos v1.0.0" { |
| + t.Fatal("readers", c, p) |
| } |
| - o, err = directory.Read(ctx) |
| - check(err) |
| - if _, err = runner.Run(ctx, claimactivation.Plan{Phase: "publish-floor", Bucket: bucket, ExpectedRevision: o.Roster.Revision, Floor: &floor}, true); err == nil { |
| - t.Fatal("published without independent process observation") |
| + // Step 7.2: floor and home, immediately after the console's report. |
| + floorPlan := func() claimactivation.Plan { |
| + return claimactivation.Plan{Phase: "publish-floor", Bucket: bucket, ExpectedRevision: refresh().Roster.Revision, Floor: &floor} |
| } |
| - publish := func() { |
| - t.Helper() |
| - o, e := directory.Read(ctx) |
| - check(e) |
| - before := activationStoreSnapshot(t, store) |
| - p, e := publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, true) |
| - check(e) |
| - if !reflect.DeepEqual(before, activationStoreSnapshot(t, store)) { |
| - t.Fatal("report dry run wrote") |
| - } |
| - cmd := exec.CommandContext(ctx, "/opt/carlos/carlos", "system", "claims", "console-report", "--bucket", bucket, "--aws-region", "eu-west-1", "--expect-revision", fmt.Sprint(o.Roster.Revision), "--console-id", c.InstanceID, "--public-key", base64.StdEncoding.EncodeToString(pub)) |
| - cmd.Env = environment |
| - if output, e := cmd.CombinedOutput(); e != nil { |
| - t.Fatal("report CLI", string(output), e) |
| + for attempt := 1; ; attempt++ { |
| + if _, e := tryPlan(floorPlan(), ""); e == nil { |
| + break |
| + } else if attempt == 3 { |
| + t.Fatal("publish-floor", e) |
| + } else { |
| + t.Log("publish-floor attempt", attempt, e) |
| } |
| - t.Log("authenticated process", p.ProcessID, p.BinarySHA256) |
| + consoleReport() |
| } |
| - publish() |
| - phase(claimactivation.Plan{Phase: "publish-floor", Floor: &floor}) |
| // The floor publish-floor wrote is the armed policy's, byte for byte. |
| for k, want := range map[string][]byte{hostfloor.Key: policy.Floor, hostfloor.AuthKey: policy.Authorization} { |
| got, _, e := store.Get(ctx, k) |
| @@ -424,38 +756,99 @@ func TestRootActivationConsoleRuntimePayload(t *testing.T) { |
| t.Fatal("published floor differs from the armed policy", k, e) |
| } |
| } |
| - phase(claimactivation.Plan{Phase: "home", HomeRegion: "ie"}) |
| - binding := hostactors.CensusKeyBinding{Bucket: bucket, Box: "leo", Enrollment: next.Boxes["leo"].Enrollment} |
| - proof, err := installActivationEvidenceKey(d+"/leo-evidence.key", binding, false) |
| - check(err) |
| - phase(claimactivation.Plan{Phase: "bind-key", Binding: &proof.Binding, Proof: proof.Proof}) |
| - config(true) |
| + phase("home", claimactivation.Plan{HomeRegion: "ie"}) |
| + // Step 7.3: the box's evidence key, then bind-key on the signer. |
| + rev = boxInspect() |
| + evidence := []string{"system", "claims", "evidence-key", "--roster", bd + "/roster.json", "--expect-revision", fmt.Sprint(rev), "--bucket", bucket, "--box", box, "--enrollment", enrolled.Enrollment, "--public-key", pubArg} |
| + carlos(true, append(append([]string{}, evidence...), "--dry-run")...) |
| + absent("/etc/carlos/host-evidence.key") |
| + proofOut, _ := carlos(true, evidence...) |
| + write(bd+"/key-proof.json", []byte(proofOut), 0600) |
| + var keyProof struct { |
| + Proof activationKeyProof `json:"key_proof"` |
| + } |
| + check(json.Unmarshal([]byte(proofOut), &keyProof)) |
| + if fi, e := os.Lstat("/etc/carlos/host-evidence.key"); e != nil || fi.Mode().Perm() != 0600 || fi.Sys().(*syscall.Stat_t).Uid != 0 || fi.Sys().(*syscall.Stat_t).Nlink != 1 { |
| + t.Fatal("host-evidence.key", fi, e) |
| + } |
| + phase("bind-key", claimactivation.Plan{Binding: &keyProof.Proof.Binding, Proof: keyProof.Proof.Proof}) |
| + if o := refresh(); o.Roster.HomeRegion != "ie" || o.Roster.Publication != "published" || o.Roster.RequestLimitNS != 120000000000 { |
| + t.Fatal("roster after step 7.3", o.Roster.HomeRegion, o.Roster.Publication, o.Roster.RequestLimitNS) |
| + } |
| + // Step 7.4: the console runtime. |
| + rev = boxInspect() |
| + consoleConfig(true) |
| ctl("restart", "console.service") |
| - second := report(true, first.ProcessID) |
| - publish() |
| + if state := ctl("is-active", "console.service"); state != "active" { |
| + t.Fatal("console", state) |
| + } |
| + second := consoleReported(true, first.ProcessID) |
| + consoleReport() |
| + t.Log("step 7 passed") |
| + |
| + // The console runtime's own refusals, as before. |
| raw, err := os.ReadFile("/etc/carlos/host.env") |
| check(err) |
| if bytes.Contains(raw, []byte("CARLOS_HOST_CLAIM=")) { |
| t.Fatal("enabled host execution") |
| } |
| - o, err = directory.Read(ctx) |
| + o, err := (hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}).Read(ctx) |
| check(err) |
| forged := second |
| forged.ProcessID = first.ProcessID |
| - if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil { |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, consoleID, forged); err == nil { |
| t.Fatal("accepted old process") |
| } |
| forged = second |
| forged.BinarySHA256 = strings.Repeat("a", 64) |
| - if _, err = activationConsoleProcess(ctx, pub, bucket, o, c.InstanceID, forged); err == nil { |
| + if _, err = activationConsoleProcess(ctx, pub, bucket, o, consoleID, forged); err == nil { |
| t.Fatal("accepted false process digest") |
| } |
| - activationResetFixture(t, store, "http://"+addr, token, other) |
| + activationResetFixture(t, store, "http://"+addr, operatorToken, memberToken, box, enrolled.Enrollment) |
| + // Observation for the report: the agent's next box report is written |
| + // whole (status.WriteBox), so whether the console process entry |
| + // console-report added survives it is recorded here, not asserted. |
| + reportedAt := time.Now() |
| + for deadline := time.Now().Add(7 * time.Minute); time.Now().Before(deadline); time.Sleep(5 * time.Second) { |
| + raw, _, e := store.Get(ctx, "status/boxes/"+box+".json") |
| + var b status.Box |
| + if e == nil && json.Unmarshal(raw, &b) == nil && b.Metrics != nil && b.GeneratedAt.After(reportedAt) { |
| + t.Log("OBSERVATION: the agent's next box report at", b.GeneratedAt, "carries", len(b.Metrics.ConsoleProcesses), "console process entries") |
| + break |
| + } |
| + } |
| ctl("stop", "console.service") |
| - if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, c.InstanceID, o.Roster.Revision, false); err == nil { |
| + if _, err = publishActivationConsoleReport(ctx, store, pub, bucket, consoleID, o.Roster.Revision, false); err == nil { |
| t.Fatal("reported stopped console") |
| } |
| - t.Log("PASS real guard-arm, guarded console, claims-off/on reports, floor/home/key sequence, authenticated reset and refusals") |
| + t.Log("PASS steps 6 and 7 on a box made by bootstrap and the real update: the 6.2 stop reproduced, install-channel --policy, guard-arm in both forms, the guarded console, floor, home, key and console runtime, with the agent's own box reports") |
| +} |
| + |
| +// activationServiceIDs is the service user bootstrap created. |
| +func activationServiceIDs(t *testing.T) (int, int) { |
| + t.Helper() |
| + u, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var uid, gid int |
| + if _, err := fmt.Sscan(u.Uid, &uid); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fmt.Sscan(u.Gid, &gid); err != nil { |
| + t.Fatal(err) |
| + } |
| + return uid, gid |
| +} |
| + |
| +// activationDirNames is the set of names in dir; a missing dir is empty. |
| +func activationDirNames(dir string) map[string]bool { |
| + names := map[string]bool{} |
| + ents, _ := os.ReadDir(dir) |
| + for _, e := range ents { |
| + names[e.Name()] = true |
| + } |
| + return names |
| } |
| func activationStoreSnapshot(t *testing.T, s release.Store) map[string]string { |
| @@ -467,6 +860,9 @@ func activationStoreSnapshot(t *testing.T, s release.Store) map[string]string { |
| result := map[string]string{} |
| for _, k := range keys { |
| raw, tag, e := s.Get(t.Context(), k) |
| + if errors.Is(e, os.ErrNotExist) { |
| + continue // removed between the list and the read |
| + } |
| if e != nil { |
| t.Fatal(e) |
| } |
| @@ -569,7 +965,10 @@ func activationS3Fixture(t *testing.T, s release.Store) string { |
| return server.URL |
| } |
| -func activationResetFixture(t *testing.T, s release.Store, origin, token, other string) { |
| +// activationResetFixture is the authenticated reset of an exhausted wake |
| +// retry through the guarded console. Its claim state is fixture state; box |
| +// and enrollment are the enrolled box's. |
| +func activationResetFixture(t *testing.T, s release.Store, origin, token, other, box, enrollment string) { |
| t.Helper() |
| ctx := t.Context() |
| check := func(e error) { |
| @@ -585,10 +984,10 @@ func activationResetFixture(t *testing.T, s release.Store, origin, token, other |
| check(err) |
| o, err = hostclaim.Update(ctx, s, o, hostclaim.BeginCreate(hostclaim.NewHolder{Family: "instance", Key: "apps/bab/trial/instances/" + host + ".json", Account: "bab", App: "trial", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: now}, record, "create", "dev-console", now)) |
| check(err) |
| - o, err = hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("create", "leo")) |
| + o, err = hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("create", box)) |
| check(err) |
| for i := 0; i < hostclaim.WakeRetryLimit; i++ { |
| - a := hostclaim.WakeRetryAttempt{ID: fmt.Sprint(i), Box: "leo", Enrollment: strings.Repeat("2", 32), BootID: "fixture", ControllerID: "fixture", InvocationID: "fixture", LaunchInputs: "fixture"} |
| + a := hostclaim.WakeRetryAttempt{ID: fmt.Sprint(i), Box: box, Enrollment: enrollment, BootID: "fixture", ControllerID: "fixture", InvocationID: "fixture", LaunchInputs: "fixture"} |
| o, err = hostclaim.Update(ctx, s, o, hostclaim.ReserveWakeRetryAttempt(hostclaim.WakeRetryReservation{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now})) |
| check(err) |
| o, err = hostclaim.Update(ctx, s, o, hostclaim.FailWakeRetryAttempt(hostclaim.WakeRetryResult{HolderIncarnation: o.Claim.Holder.Incarnation, EpisodeID: "exhausted", Attempt: a, Now: now})) |
diff --git a/internal/host/installchannel.go b/internal/host/installchannel.go| index 6e7dbf2e1..4e0aa52d4 100644 |
| --- a/internal/host/installchannel.go |
| +++ b/internal/host/installchannel.go |
| @@ -1,14 +1,17 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "crypto/ed25519" |
| + "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "os" |
| "path/filepath" |
| "runtime" |
| + "strings" |
| "github.com/carlosframework/platform/internal/cliflag" |
| "github.com/carlosframework/platform/internal/hostfloor" |
| @@ -37,9 +40,23 @@ import ( |
| // another release this way or through an update, is refused: its releases |
| // come through `carlos system update`. Run again on a box it has already |
| // installed, it changes nothing. |
| +// |
| +// --policy FILE (claim trial, prev-rehearsal ruling R1) is for arming a box |
| +// before the host floor is published. FILE is exactly what `carlos system |
| +// claims run` sign-policy writes. Its floor is verified as guard-arm |
| +// verifies it (the root-signed moves-protocol authorization, for this |
| +// deployment's bucket, under CARLOS_RELEASE_PUBKEY) and stands in for the |
| +// published floor while there is none; a published floor, or either half |
| +// of one, must be the policy's own bytes. Every other refusal stands. It |
| +// also admits the one updated box A2-2 would otherwise leave unarmable: |
| +// one whose carlos already is the channel release's signed artifact. Its |
| +// carlos.prev, the release before, is replaced by that artifact |
| +// (refreshPrev), because guard-arm requires both slots to be the |
| +// identity's release. Without --policy nothing here changes. |
| + |
| +const installChannelUsage = "usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet.\n" |
| -const installChannelUsage = "usage: carlos system install-channel\n\n" + |
| - "Install the release this box's channel names, as root, on a box that has none yet.\n" |
| +const installChannelPolicyFlag = "the armed policy carlos system claims run sign-policy wrote, used as the host floor until one is published" |
| const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary" |
| @@ -57,6 +74,8 @@ type channelInstall struct { |
| bucket string |
| fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error |
| own func(path string) error |
| + // policy is --policy's file, byte for byte; nil without the flag. |
| + policy []byte |
| } |
| // slotKind is what one protected slot holds (slotState). |
| @@ -72,11 +91,14 @@ const ( |
| type channelInstalled struct { |
| Version, Tag, Channel string |
| Already bool |
| + // Replaced: --policy replaced an updated box's carlos.prev (refreshPrev). |
| + Replaced bool |
| } |
| // RunSystemInstallChannel is `carlos system install-channel`. |
| func RunSystemInstallChannel(args []string) { |
| fs := flag.NewFlagSet("system install-channel", flag.ExitOnError) |
| + policyPath := fs.String("policy", "", installChannelPolicyFlag) |
| fs.Usage = cliflag.Usage(fs, installChannelUsage) |
| _ = fs.Parse(args) |
| if fs.NArg() != 0 { |
| @@ -92,6 +114,38 @@ func RunSystemInstallChannel(args []string) { |
| fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid)) |
| os.Exit(1) |
| } |
| + var policy []byte |
| + if *policyPath != "" { |
| + raw, err := readChannelPolicy(*policyPath) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + policy = raw |
| + } |
| + in := newChannelInstall(channel, policy) |
| + got, err := in.run(context.Background()) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + fmt.Println(installedLine(got)) |
| +} |
| + |
| +// channelPolicyLimit caps --policy's file: sign-policy writes about 500 |
| +// bytes. |
| +const channelPolicyLimit = 64 << 10 |
| + |
| +// errChannelPolicy is --policy's one refusal of the policy itself, said |
| +// alike before and after its signature is checked. |
| +var errChannelPolicy = errors.New("the --policy file is not an armed policy signed for this deployment, so nothing was installed") |
| + |
| +// newChannelInstall is the production run. The --policy path refreshes |
| +// carlos.prev as an update does, so its applier reads the root |
| +// participation census as the update's does (systemapply.go): on a census |
| +// box it takes the root moves floor branch, where a floor step that fails |
| +// fails (fix round 1, I2). Without --policy the applier is as it was. |
| +func newChannelInstall(channel string, policy []byte) channelInstall { |
| in := channelInstall{ |
| a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(), |
| dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH}, |
| @@ -99,17 +153,51 @@ func RunSystemInstallChannel(args []string) { |
| fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| }, |
| - own: ownByService, |
| + own: ownByService, |
| + policy: policy, |
| } |
| - got, err := in.run(context.Background()) |
| + if policy != nil { |
| + in.a.rootMoves = rootMovesFloorIdentity |
| + } |
| + return in |
| +} |
| + |
| +// readChannelPolicy reads --policy's file and refuses, before anything else |
| +// is read, a file that is not an armed policy at all. It is opened without |
| +// following a symlink or waiting on a FIFO, and must be a regular file of |
| +// at most channelPolicyLimit bytes (openChannelPolicy). Its signature is |
| +// checked once the release key is known (channelPolicy). |
| +func readChannelPolicy(path string) ([]byte, error) { |
| + raw, err := openChannelPolicy(path, channelPolicyLimit) |
| if err != nil { |
| - fmt.Fprintln(os.Stderr, err) |
| - os.Exit(1) |
| + return nil, fmt.Errorf("the policy %s could not be read, so nothing was installed: %w", path, err) |
| } |
| - fmt.Println(installedLine(got)) |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + return nil, errChannelPolicy |
| + } |
| + return raw, nil |
| +} |
| + |
| +// channelPolicy is guard-arm's check of --policy (guardArmInputs, first |
| +// half): an armed policy whose floor carries the root-signed |
| +// moves-protocol authorization for this deployment's bucket. |
| +func channelPolicy(raw []byte, pub ed25519.PublicKey, bucket string) (hostfloor.Policy, hostfloor.Verified, error) { |
| + var p hostfloor.Policy |
| + if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor |
| + } |
| + f, err := p.Verify(pub, bucket) |
| + if err != nil { |
| + return hostfloor.Policy{}, hostfloor.Verified{}, err |
| + } |
| + return p, f, nil |
| } |
| func installedLine(got channelInstalled) string { |
| + if got.Replaced { |
| + return fmt.Sprintf("replaced the rollback copy carlos.prev with %s (%s) from the %s channel, the release carlos already runs. the release carlos.prev held before is no longer on this box", got.Version, got.Tag, got.Channel) |
| + } |
| if got.Already { |
| return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel) |
| } |
| @@ -147,6 +235,15 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err) |
| } |
| got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel} |
| + var policy *hostfloor.Policy |
| + var policyFloor hostfloor.Verified |
| + if in.policy != nil { |
| + pol, f, err := channelPolicy(in.policy, pub, in.bucket) |
| + if err != nil { |
| + return got, errChannelPolicy |
| + } |
| + policy, policyFloor = &pol, f |
| + } |
| art, ok := p.Current.Artifacts[a.platform] |
| if !ok { |
| return got, fmt.Errorf("the %s channel names %s with no artifact for %s, so nothing was installed", a.channel, p.Current.Version, a.platform) |
| @@ -165,10 +262,23 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| if applied != nil && applied.Version != p.Current.Version { |
| return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| } |
| - wire, err := in.checkFloor(ctx, p, art, pub) |
| + wire, err := in.checkFloor(ctx, p, art, pub, policy, policyFloor) |
| if err != nil { |
| return got, err |
| } |
| + // With --policy, a box whose update has not finished (an open journal, |
| + // or an update-begun marker beside the guard) is refused, as guard-arm |
| + // refuses it: recovery owns carlos.prev until then (fix round 1, I1). |
| + if policy != nil { |
| + if err := guardArmIdle(systemGuardDir(a.optDir), systemRootDir(a.optDir)); err != nil { |
| + // The reason is the journal or marker guard-arm names, without |
| + // guard-arm's floor prefix: it is an unfinished update. |
| + if errors.Is(err, hostfloor.ErrFloor) { |
| + err = errors.New(strings.TrimPrefix(err.Error(), hostfloor.ErrFloor.Error()+": ")) |
| + } |
| + return got, fmt.Errorf("an update on this box has not finished, so nothing was installed: %w", err) |
| + } |
| + } |
| already := systemCacheHolds(systemBinaryPath(a.optDir), art.SHA256) && systemCacheHolds(systemPrevPath(a.optDir), art.SHA256) && |
| systemCacheHolds(channelIdentityPath(a.optDir), hostfloor.Digest(wire)) |
| if applied != nil { |
| @@ -176,9 +286,29 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| // either this install, complete, or a box an update has since |
| // managed: its releases come through carlos system update. |
| if already { |
| + // With --policy, "already installed" also needs carlos.prev's |
| + // version record to name this release, and its judgement to have |
| + // run. A record a cut-off run left missing or stale is completed |
| + // first (which judges); otherwise the judgement runs again, as it |
| + // is idempotent, so one a run left unfinished is finished. Either |
| + // failing fails the run (fix round 2). |
| + if policy != nil { |
| + var err error |
| + if !prevRecorded(a.optDir, p.Current.Version) { |
| + err = in.completePrev(ctx, p.Current.Version, false) |
| + } else { |
| + err = channelPrevJudge(a, ctx) |
| + } |
| + if err != nil { |
| + return got, err |
| + } |
| + } |
| got.Already = true |
| return got, nil |
| } |
| + if policy != nil { |
| + return in.refreshPrev(ctx, p, art, wire, got) |
| + } |
| return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| } |
| // With no applied.json, a slot that holds anything but the signed |
| @@ -223,6 +353,13 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| } |
| } |
| + // With --policy, carlos.prev's version record is part of the install |
| + // (completePrev); without it the install is exactly as it was. |
| + if policy != nil { |
| + if err := in.completePrev(ctx, p.Current.Version, false); err != nil { |
| + return got, err |
| + } |
| + } |
| if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| return got, err |
| } |
| @@ -244,23 +381,33 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error) |
| // identity, and returns the identity once the release passes the floor |
| // exactly as guard-arm will check it. No floor, or one that cannot be read, |
| // refuses: a joining box could not arm its guard without one, and a read |
| -// that failed says nothing about where the floor is. |
| -func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, error) { |
| +// that failed says nothing about where the floor is. With --policy |
| +// (policy non-nil) the policy's verified floor is the floor instead |
| +// (policyFloorPublished). |
| +func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey, policy *hostfloor.Policy, policyFloor hostfloor.Verified) ([]byte, error) { |
| s := in.a.store |
| - raw, _, err := s.Get(ctx, hostfloor.Key) |
| - if errors.Is(err, os.ErrNotExist) { |
| - return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| - } |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| - } |
| - auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| - } |
| - floor, err := hostfloor.Verify(pub, in.bucket, raw, auth) |
| - if err != nil { |
| - return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + var floor hostfloor.Verified |
| + if policy != nil { |
| + if err := policyFloorPublished(ctx, s, *policy); err != nil { |
| + return nil, err |
| + } |
| + floor = policyFloor |
| + } else { |
| + raw, _, err := s.Get(ctx, hostfloor.Key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + floor, err = hostfloor.Verify(pub, in.bucket, raw, auth) |
| + if err != nil { |
| + return nil, fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| + } |
| } |
| key, err := release.IdentityKey(p.Current.ReleaseTag) |
| if err != nil { |
| @@ -276,6 +423,140 @@ func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointe |
| return wire, nil |
| } |
| +// policyFloorPublished checks --policy against what the bucket holds: no |
| +// floor published, or a published pair (or the half of one that a torn |
| +// publication leaves; hostfloor.Publish writes the authorization first) |
| +// that is the policy's floor and authorization byte for byte. Anything |
| +// else, a read that fails included, refuses. |
| +func policyFloorPublished(ctx context.Context, s release.Store, policy hostfloor.Policy) error { |
| + for _, half := range []struct { |
| + key string |
| + want []byte |
| + }{{hostfloor.AuthKey, policy.Authorization}, {hostfloor.Key, policy.Floor}} { |
| + got, _, err := s.Get(ctx, half.key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| + } |
| + if !bytes.Equal(got, half.want) { |
| + return errors.New("the published host floor is not the floor in the --policy file, so nothing was installed") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// channelPrevVersionWrite is writePrevVersion, a seam for the tests that |
| +// the record's write failing fails the --policy install. |
| +var channelPrevVersionWrite = writePrevVersion |
| + |
| +// prevRecorded reports whether carlos.prev's version record names version. |
| +func prevRecorded(optDir, version string) bool { |
| + v, ok := readLine(prevVersionPath(optDir)) |
| + return ok && v == version |
| +} |
| + |
| +// completePrev is --policy's refresh of carlos.prev's records, the steps |
| +// an update's notePrev takes but with every one mandatory, armed floor or |
| +// not (prev-rehearsal fix round 1): under the prev lock, the verdicts and |
| +// the version record of what carlos.prev held are removed, carlos is |
| +// copied over it when copyCurrent is set, and the version record is |
| +// written and read back. Any failure fails the run, which then keeps no |
| +// identity and reports nothing done; a retry completes it. The floor's |
| +// judgement follows as an update makes it (notePrev without a replace): |
| +// a judgement that cannot be made leaves no verdict, which refuses. |
| +func (in channelInstall) completePrev(ctx context.Context, version string, copyCurrent bool) error { |
| + a := in.a |
| + err := withPrevLock(a.optDir, func() error { |
| + for _, path := range []string{prevAllowedPath(a.optDir), seedsPrevAllowedPath(a.optDir), prevVersionPath(a.optDir)} { |
| + if err := removeDurable(path); err != nil { |
| + return err |
| + } |
| + } |
| + if copyCurrent { |
| + if err := copyFileDurable(systemBinaryPath(a.optDir), systemPrevPath(a.optDir), 0o755); err != nil { |
| + return err |
| + } |
| + } |
| + return channelPrevVersionWrite(a.optDir, version) |
| + }) |
| + if err == nil && !prevRecorded(a.optDir, version) { |
| + err = fmt.Errorf("%s does not name %s", prevVersionPath(a.optDir), version) |
| + } |
| + if err != nil { |
| + return fmt.Errorf("the record of the release in carlos.prev could not be written, so the install is not complete. run it again: %w", err) |
| + } |
| + return channelPrevJudge(a, ctx) |
| +} |
| + |
| +// channelPrevJudge is the floor's judgement of carlos.prev, as an update |
| +// makes it (notePrev without a replace); a seam for the tests of a |
| +// judgement that fails or is cut off. |
| +var channelPrevJudge = func(a *systemApplier, ctx context.Context) error { return a.notePrev(ctx, false, "") } |
| + |
| +// refreshPrev is --policy's one admission of an updated box (applied.json |
| +// names the channel release, and the slots and the identity are not yet |
| +// install-channel's): carlos must already hold the channel release's |
| +// signed artifact, hashed from a descriptor opened with O_NOFOLLOW |
| +// (slotState). Its carlos.prev, the release the update replaced, is then |
| +// refreshed from carlos the way an update refreshes it (notePrev: the |
| +// sidecar and verdicts of the old carlos.prev removed first, the copy, the |
| +// version sidecar, the floor's judgement), every step mandatory here |
| +// (completePrev), and only then is the identity kept beside |
| +// them. Nothing is fetched, and applied.json and seen.json are the |
| +// update's and stay as they are. The caller has already refused an armed |
| +// guard, another release and a release below the policy's floor. |
| +// |
| +// Why A2-2 allows it: A2-2 puts the identity's artifact in both slots so |
| +// guard-arm's two-slot rule can pass, and on a fresh box carlos.prev is |
| +// then a copy of carlos. Here carlos already is that artifact, so this |
| +// reaches A2-2's end state exactly. What is lost is the rollback to the |
| +// release before, which is below the floor whenever the floor is the |
| +// release being armed, and which the armed guard would refuse to run. |
| +func (in channelInstall) refreshPrev(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, wire []byte, got channelInstalled) (channelInstalled, error) { |
| + a := in.a |
| + current, prev := systemBinaryPath(a.optDir), systemPrevPath(a.optDir) |
| + state, err := slotState(current, art.SHA256) |
| + if err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } |
| + if state != slotHolds { |
| + return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", current) |
| + } |
| + held, err := slotState(prev, art.SHA256) |
| + if err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } |
| + if held == slotForeign { |
| + // The release before is replaced; a symlink or anything but a |
| + // regular file is not what an update leaves, and is refused. |
| + if fi, err := os.Lstat(prev); err != nil { |
| + return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| + } else if !fi.Mode().IsRegular() { |
| + return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", prev) |
| + } |
| + } |
| + // Always, even over a carlos.prev a cut-off run already copied, so the |
| + // version record is this release's. The record is mandatory here. |
| + if err := in.completePrev(ctx, p.Current.Version, true); err != nil { |
| + return got, err |
| + } |
| + // The replaced line names a release that was there; a retry that found |
| + // carlos.prev already this release, or none, says only what is installed. |
| + replaced := held == slotForeign |
| + for _, slot := range []string{prev, current} { |
| + if state, err := slotState(slot, art.SHA256); err != nil || state != slotHolds { |
| + return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| + } |
| + } |
| + if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| + return got, err |
| + } |
| + got.Replaced = replaced |
| + return got, nil |
| +} |
| + |
| // 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 |
diff --git a/internal/host/installchannel_linux.go b/internal/host/installchannel_linux.go| index cabf7fef9..a4d468b6d 100644 |
| --- a/internal/host/installchannel_linux.go |
| +++ b/internal/host/installchannel_linux.go |
| @@ -6,6 +6,7 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "errors" |
| + "fmt" |
| "io" |
| "os" |
| "path/filepath" |
| @@ -37,6 +38,37 @@ func ownDir(path string, uid, gid int) error { |
| return unix.Fchown(fd, uid, gid) |
| } |
| +// openChannelPolicy reads --policy's file: opened with O_NOFOLLOW (a |
| +// symlink is refused, never followed) and O_NONBLOCK (a FIFO never blocks |
| +// the open), fstat'd to be a regular file of at most limit bytes, and only |
| +// then read, from the descriptor, up to limit. |
| +func openChannelPolicy(path string, limit int64) ([]byte, error) { |
| + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + f := os.NewFile(uintptr(fd), path) |
| + defer f.Close() |
| + st, err := f.Stat() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !st.Mode().IsRegular() { |
| + return nil, errors.New("not a regular file") |
| + } |
| + if st.Size() > limit { |
| + return nil, fmt.Errorf("larger than %d bytes", limit) |
| + } |
| + raw, err := io.ReadAll(io.LimitReader(f, limit+1)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if int64(len(raw)) > limit { |
| + return nil, fmt.Errorf("larger than %d bytes", limit) |
| + } |
| + return raw, nil |
| +} |
| + |
| // openat2 is unix.Openat2, a seam for the test that a kernel without it |
| // refuses the removal. There is no path-based fallback. |
| var openat2 = unix.Openat2 |
diff --git a/internal/host/installchannel_other.go b/internal/host/installchannel_other.go| index 4ddf4eaa7..46797b801 100644 |
| --- a/internal/host/installchannel_other.go |
| +++ b/internal/host/installchannel_other.go |
| @@ -15,3 +15,5 @@ func ownDir(path string, uid, gid int) error { return errOwnNotLinux } |
| func removeCacheEntry(dataDir, version string) error { return errOwnNotLinux } |
| func slotState(path, want string) (slotKind, error) { return slotAbsent, errOwnNotLinux } |
| + |
| +func openChannelPolicy(path string, limit int64) ([]byte, error) { return nil, errOwnNotLinux } |
diff --git a/internal/host/installchannel_policy_linux_test.go b/internal/host/installchannel_policy_linux_test.go| new file mode 100644 |
| index 000000000..a81b329e2 |
| --- /dev/null |
| +++ b/internal/host/installchannel_policy_linux_test.go |
| @@ -0,0 +1,46 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| +) |
| + |
| +// M3, Linux: a symlink, a FIFO, a directory and an oversized --policy file |
| +// are refused before anything is read; the FIFO's open does not wait for a |
| +// writer. |
| +func TestInstallChannelPolicyOpenRefusesWhatIsNotAPolicyFile(t *testing.T) { |
| + dir := t.TempDir() |
| + // M3: a symlink, a FIFO, a directory and an oversized file are refused |
| + // before anything is read; the FIFO's open does not wait for a writer. |
| + raw0 := signedPolicyBytes(t) |
| + real := filepath.Join(dir, "real.json") |
| + if err := os.WriteFile(real, raw0, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(dir, "link.json") |
| + if err := os.Symlink(real, link); err != nil { |
| + t.Fatal(err) |
| + } |
| + fifo := filepath.Join(dir, "fifo.json") |
| + if err := syscall.Mkfifo(fifo, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + big := filepath.Join(dir, "big.json") |
| + if err := os.WriteFile(big, append(raw0, bytes.Repeat([]byte(" "), channelPolicyLimit)...), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{link, fifo, dir, big} { |
| + if _, err := readChannelPolicy(path); err == nil || !strings.HasPrefix(err.Error(), "the policy "+path+" could not be read, so nothing was installed: ") { |
| + t.Fatalf("%s: %v", path, err) |
| + } |
| + } |
| + if got, err := readChannelPolicy(real); err != nil || !bytes.Equal(got, raw0) { |
| + t.Fatalf("the real file: %v", err) |
| + } |
| +} |
diff --git a/internal/host/installchannel_policy_rootrunner_linux_test.go b/internal/host/installchannel_policy_rootrunner_linux_test.go| new file mode 100644 |
| index 000000000..719b7cd4b |
| --- /dev/null |
| +++ b/internal/host/installchannel_policy_rootrunner_linux_test.go |
| @@ -0,0 +1,83 @@ |
| +//go:build linux && rootrunner |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Fix round 1, I2, as real root: a box with a root participation census |
| +// (leo's kind) and an unarmed guard, updated from v1.3.0 to v1.4.0 by the |
| +// real apply on the census branch, then armed for guard-arm by |
| +// install-channel --policy on that same branch. The release before is |
| +// replaced, carlos.prev's record names the release, the identity is kept, |
| +// and the root moves floor's judgement ran over the new carlos.prev. |
| +func TestRootInstallChannelPolicyCensusBox(t *testing.T) { |
| + scratch := generationRootRunnerDir(t) |
| + t.Setenv("TMPDIR", scratch) |
| + f := &channelFixture{applyFixture: newApplyFixture(t, "stable", "v1.3.0")} |
| + for _, dir := range []string{f.a.optDir, f.a.dataDir, f.a.etcDir} { |
| + for path := dir; path != scratch && path != "/" && path != filepath.Dir(scratch); path = filepath.Dir(path) { |
| + if err := os.Chmod(path, 0755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(f.pub)) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "bucket") |
| + id := rootFloorIdentity{Deployment: "bucket", Box: "box", Enrollment: "en"} |
| + f.a.rootMoves = func() (rootFloorIdentity, bool, error) { return id, true, nil } |
| + old := []byte("#!/bin/sh\necho carlos v1.3.0\n") |
| + rootFloorSignRunnerBytes(t, f.a.optDir, systemBinaryPath(f.a.optDir), "v1.3.0", old, f.priv) |
| + f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| + applyChannelUpdate(t, f, "v1.4.0", f.body, true) |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, old) { |
| + t.Fatalf("the census update left %q and %q", cur, prev) |
| + } |
| + f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| + f.fetches++ |
| + return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| + }, own: func(string) error { return nil }} |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(context.Background()) |
| + if err != nil || !got.Replaced { |
| + t.Fatalf("install-channel --policy on a census box: %+v %v", got, err) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || !prevRecorded(f.a.optDir, "v1.4.0") || f.fetches != 0 { |
| + t.Fatalf("slots %q %q", cur, prev) |
| + } |
| + key, _ := release.IdentityKey("v1.4.0") |
| + published, _, err := f.store.Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if held, err := os.ReadFile(channelIdentityPath(f.a.optDir)); err != nil || !bytes.Equal(held, published) { |
| + t.Fatal("identity", err) |
| + } |
| + floors, err := openRootMovesFloors(f.a.optDir, id, false) |
| + if err != nil { |
| + t.Fatal("the census box's root floor state", err) |
| + } |
| + defer floors.close() |
| + raw, err := floors.public.Read("prev.json") |
| + if err != nil { |
| + t.Fatal("the root moves floor did not judge the new carlos.prev", err) |
| + } |
| + var verdict rootFloorVerdict |
| + if err := json.Unmarshal(raw, &verdict); err != nil || verdict.Identity != id || verdict.PrevAbsent || verdict.Prev.Version != "v1.4.0" || verdict.Prev.Digest != hostfloor.Digest(f.body) { |
| + t.Fatalf("the judgement is not of the replaced carlos.prev (v1.4.0): %s %v", raw, err) |
| + } |
| + if again, err := f.in.run(context.Background()); err != nil || !again.Already { |
| + t.Fatalf("rerun %+v %v", again, err) |
| + } |
| + t.Log("PASS install-channel --policy on a census box: root moves branch, mandatory record, identity kept") |
| +} |
diff --git a/internal/host/installchannel_policy_test.go b/internal/host/installchannel_policy_test.go| new file mode 100644 |
| index 000000000..3cad35a6f |
| --- /dev/null |
| +++ b/internal/host/installchannel_policy_test.go |
| @@ -0,0 +1,724 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostfloor" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// install-channel --policy (claim trial prev-rehearsal, ruling R1). The box |
| +// these tests start from is made by the platform's own update path, never |
| +// by hand: v1.3.0 on disk, the channel promoted to v1.4.0, the agent's |
| +// request and cache written as systemLeg writes them, then the real apply |
| +// (systemApplier.Run) moving v1.3.0 to carlos.prev. That is the trial's |
| +// leo-climbing: applied.json names the channel release, carlos is its |
| +// artifact, carlos.prev the release before, and no channel identity. |
| + |
| +// updatedChannelBox returns that box. old and body are the two releases' |
| +// bytes. |
| +func updatedChannelBox(t *testing.T) (f *channelFixture, old []byte) { |
| + t.Helper() |
| + f = &channelFixture{applyFixture: newApplyFixture(t, "stable", "v1.3.0")} |
| + old, err := os.ReadFile(systemBinaryPath(f.a.optDir)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.body = []byte("#!/bin/sh\necho carlos v1.4.0\n") |
| + applyChannelUpdate(t, f, "v1.4.0", f.body, true) |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, old) { |
| + t.Fatalf("the update left %q and %q", cur, prev) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("an update wrote a channel identity: %v", err) |
| + } |
| + f.in = channelInstall{a: f.a, bucket: "bucket", fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| + f.fetches++ |
| + return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| + }, own: func(path string) error { |
| + f.owned = append(f.owned, path) |
| + return nil |
| + }} |
| + return f, old |
| +} |
| + |
| +// applyChannelUpdate moves f's box to version through the platform's update |
| +// path: the promote (and, with identity, the release identity), the agent's |
| +// fetch and request (systemLeg), then the real apply. |
| +func applyChannelUpdate(t *testing.T, f *channelFixture, version string, body []byte, identity bool) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if identity { |
| + f.publish(t, version, body) |
| + } else { |
| + rel := release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{ |
| + "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hostfloor.Digest(body)}}} |
| + if _, err := release.PromoteSystem(ctx, f.store, release.RootSigner(f.priv), "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + p, _, err := release.GetSystemPointer(ctx, f.store, f.pub, release.SystemComponentRouter, "stable") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + leg := &systemLeg{dataDir: f.a.dataDir, fetchTo: func(ctx context.Context, key, dst, sha string) error { |
| + return release.FetchToFile(ctx, f.store, key, dst, systemArtifactLimit, sha) |
| + }} |
| + pending := &systemPending{version: version, promotedAt: p.PromotedAt, artifact: p.Current.Artifacts["linux-arm64"]} |
| + if err := leg.fetch(ctx, pending); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := leg.writeRequest(pending, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The candidate ships the instance layout's mount, so the layout guard |
| + // does not open the registry (a post-layout release, as every one the |
| + // trial installs is). |
| + plan, units := `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service"],"inert":["carlos-restore-verify.timer"]}`, "carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-update.service carlos-system-recover.service carlos-instance-control.service data-instances.mount" |
| + for _, path := range []string{filepath.Join(f.a.stagingDir(), "carlos"), systemPrevPath(f.a.optDir), systemBinaryPath(f.a.optDir)} { |
| + f.answer["plan:"+path], f.answer["units:"+path] = plan, units |
| + // Every binary answers `version` from its own bytes. |
| + delete(f.answer, "version:"+path) |
| + } |
| + if err := f.a.Run(ctx, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || r.Version != version { |
| + t.Fatalf("the update to %s did not apply: %+v", version, r) |
| + } |
| + if cur, _ := os.ReadFile(systemBinaryPath(f.a.optDir)); !bytes.Equal(cur, body) { |
| + t.Fatalf("the update left %q", cur) |
| + } |
| +} |
| + |
| +// signedPolicy is what claims run sign-policy writes for floor. |
| +func signedPolicy(t *testing.T, key ed25519.PrivateKey, bucket string, floor hostfloor.Floor) []byte { |
| + t.Helper() |
| + p, err := hostfloor.ArmedPolicy(release.RootSigner(key), bucket, floor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := json.Marshal(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return raw |
| +} |
| + |
| +// boxFiles is every file install-channel or the update keeps in the root |
| +// directory and the two slots, by name, so a refusal can be shown to have |
| +// changed nothing. |
| +func boxFiles(t *testing.T, f *channelFixture) map[string]string { |
| + t.Helper() |
| + got := map[string]string{} |
| + for _, dir := range []string{f.a.optDir, systemRootDir(f.a.optDir)} { |
| + ents, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range ents { |
| + path := filepath.Join(dir, e.Name()) |
| + if fi, err := os.Lstat(path); err == nil && fi.Mode().IsRegular() { |
| + b, _ := os.ReadFile(path) |
| + got[path] = string(b) |
| + } else if err == nil { |
| + got[path] = fi.Mode().String() |
| + } |
| + } |
| + } |
| + return got |
| +} |
| + |
| +func TestInstallChannelPolicyArmsAnUpdatedBoxAtTheChannelHead(t *testing.T) { |
| + f, old := updatedChannelBox(t) |
| + ctx := context.Background() |
| + // Without --policy the box is refused exactly as before, and not |
| + // changed: with no floor published, for that (the trial's loop), and |
| + // with one, as an updated box (the sibling |
| + // TestInstallChannelRefusesAnUpdatedBoxAtTheChannelHead). |
| + before := boxFiles(t, f) |
| + if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" { |
| + t.Fatalf("without --policy: %v", err) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + applied, _ := os.ReadFile(f.a.file("applied.json")) |
| + seen, _ := os.ReadFile(f.a.file("seen.json")) |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !got.Replaced || got.Already || got.Version != "v1.4.0" || got.Tag != "v1.4.0" { |
| + t.Fatalf("installed %+v", got) |
| + } |
| + if line := installedLine(got); line != "replaced the rollback copy carlos.prev with v1.4.0 (v1.4.0) from the stable channel, the release carlos already runs. the release carlos.prev held before is no longer on this box" { |
| + t.Fatalf("line %q", line) |
| + } |
| + cur, prev := f.slots(t) |
| + if !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || bytes.Equal(prev, old) { |
| + t.Fatalf("slots hold %q and %q", cur, prev) |
| + } |
| + // Nothing was fetched: carlos already is the artifact. |
| + if f.fetches != 0 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| + // The update's records stand; the version sidecar names the release |
| + // carlos.prev now holds. |
| + if a, _ := os.ReadFile(f.a.file("applied.json")); !bytes.Equal(a, applied) { |
| + t.Fatalf("applied.json changed: %s", a) |
| + } |
| + if s, _ := os.ReadFile(f.a.file("seen.json")); !bytes.Equal(s, seen) { |
| + t.Fatalf("seen.json changed: %s", s) |
| + } |
| + if v, ok := readLine(prevVersionPath(f.a.optDir)); !ok || v != "v1.4.0" { |
| + t.Fatalf("carlos.prev's version sidecar is %q %v", v, ok) |
| + } |
| + // The identity kept beside the slots is the published one, and |
| + // guard-arm's two checks pass: its inputs, and both slots. |
| + key, _ := release.IdentityKey("v1.4.0") |
| + published, _, err := f.store.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + held, err := os.ReadFile(channelIdentityPath(f.a.optDir)) |
| + if err != nil || !bytes.Equal(held, published) { |
| + t.Fatalf("identity %v", err) |
| + } |
| + _, floor, err := guardArmInputs(f.in.policy, held, f.pub, "bucket", "v1.4.0", "linux-arm64", hostfloor.Digest(f.body)) |
| + if err != nil { |
| + t.Fatalf("guard-arm's inputs: %v", err) |
| + } |
| + for _, slot := range []string{systemBinaryPath(f.a.optDir), systemPrevPath(f.a.optDir)} { |
| + b, _ := os.ReadFile(slot) |
| + if err := hostfloor.CheckImage(f.pub, floor, held, "v1.4.0", "linux-arm64", hostfloor.Digest(b)); err != nil { |
| + t.Fatalf("guard-arm would refuse %s: %v", slot, err) |
| + } |
| + } |
| + // Run again: nothing to do. |
| + again, err := f.in.run(ctx) |
| + if err != nil || !again.Already || again.Replaced || f.fetches != 0 { |
| + t.Fatalf("second run %+v %v", again, err) |
| + } |
| + // The floor, once published from the same plan, is the policy's own |
| + // bytes, so the run still passes; the same with half of it published, |
| + // as a publication cut off after its first write leaves it. |
| + var policy hostfloor.Policy |
| + if err := json.Unmarshal(f.in.policy, &policy); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.CreateExact(ctx, f.store, hostfloor.AuthKey, policy.Authorization); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("half published: %+v %v", got, err) |
| + } |
| + if err := hostfloor.Publish(ctx, f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("published: %+v %v", got, err) |
| + } |
| +} |
| + |
| +// Each refusal of --policy on the updated box, and on a fresh one, leaves |
| +// the box as it was and fetches nothing. |
| +func TestInstallChannelPolicyRefusals(t *testing.T) { |
| + _, otherKey, _ := ed25519.GenerateKey(nil) |
| + for _, c := range []struct { |
| + name string |
| + setup func(t *testing.T, f *channelFixture) |
| + want string |
| + }{ |
| + {"signed by another key", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, otherKey, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"for another bucket", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "another-bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"re-indented", func(t *testing.T, f *channelFixture) { |
| + var b bytes.Buffer |
| + if err := json.Indent(&b, signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}), "", " "); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.in.policy = b.Bytes() |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"unpublished mode", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = []byte(`{"schema":1,"deployment_bucket":"bucket","mode":"unpublished"}`) |
| + }, "the --policy file is not an armed policy signed for this deployment, so nothing was installed"}, |
| + {"an armed guard", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), f.in.policy, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "this box's host lifecycle guard is armed, so carlos system update installs its releases"}, |
| + {"another release", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.publish(t, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n")) |
| + }, "this box already runs v1.4.0, so carlos system update installs its releases"}, |
| + {"below the floor", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v2.0.0"}) |
| + }, "v1.4.0 (v1.4.0) is below the host floor v2.0.0 or is not the release its identity names, so nothing was installed"}, |
| + {"another floor published", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := hostfloor.Publish(context.Background(), f.store, release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| + {"another floor half published", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + _, auth, err := hostfloor.Authorize(release.RootSigner(f.priv), "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.3.0"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.CreateExact(context.Background(), f.store, hostfloor.AuthKey, auth); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "the published host floor is not the floor in the --policy file, so nothing was installed"}, |
| + {"an unreadable floor", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + open := f.a.openStore |
| + f.a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| + s, k, err := open(ctx) |
| + return floorUnreadable{s}, k, err |
| + } |
| + }, "the host floor could not be read, so nothing was installed: door restarting"}, |
| + {"a foreign carlos", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1.4.0 rebuilt\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "%opt%/carlos holds a build this command did not install, so nothing was installed"}, |
| + {"a symlinked carlos.prev", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + elsewhere := filepath.Join(t.TempDir(), "carlos.prev") |
| + if err := os.Rename(systemPrevPath(f.a.optDir), elsewhere); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(elsewhere, systemPrevPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "%opt%/carlos.prev holds a build this command did not install, so nothing was installed"}, |
| + {"an unreadable install record", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(f.a.file("applied.json"), []byte("{"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "this box's install record could not be read, so nothing was installed: unexpected end of JSON input"}, |
| + {"a carlos.prev that is a directory", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "%opt%/carlos.prev holds a build this command did not install, so nothing was installed"}, |
| + {"an unreadable guard", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), "policy.json"), []byte("{"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "this box's host lifecycle guard could not be read, so nothing was installed: hostfloor: authenticated floor required"}, |
| + {"an open update journal", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(f.a.file("journal.json"), []byte("{}"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "an update on this box has not finished, so nothing was installed: %opt%/system/router/journal.json is open; let the update finish or recover first"}, |
| + {"an update-begun marker", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), updateBegunFile), []byte("{}"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, "an update on this box has not finished, so nothing was installed: %opt%/system/guard/" + updateBegunFile + " is present; let the update finish or recover first"}, |
| + {"a census that cannot be read", func(t *testing.T, f *channelFixture) { |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.a.rootMoves = func() (rootFloorIdentity, bool, error) { |
| + return rootFloorIdentity{}, true, errors.New("census unreadable") |
| + } |
| + }, "the stable channel could not be read, so nothing was installed: census unreadable"}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + f, _ := updatedChannelBox(t) |
| + c.setup(t, f) |
| + before := boxFiles(t, f) |
| + _, err := f.in.run(context.Background()) |
| + want := strings.ReplaceAll(c.want, "%opt%", f.a.optDir) |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err %v, want %q", err, want) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("a refused run changed the box: %v", diffFiles(before, after)) |
| + } |
| + if f.fetches != 0 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a refused run kept an identity: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// On a fresh box --policy stands in for the floor that is not published |
| +// yet, and the install is the one without it: both slots, .prev first. |
| +func TestInstallChannelPolicyInstallsAFreshBoxBeforeTheFloor(t *testing.T) { |
| + f := newChannelFixture(t, "") |
| + ctx := context.Background() |
| + if _, err := f.in.run(ctx); err == nil || err.Error() != "no host floor is published, so this box could not arm its guard and nothing was installed" { |
| + t.Fatalf("without --policy: %v", err) |
| + } |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(ctx) |
| + if err != nil || got.Already || got.Replaced { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + if line := installedLine(got); line != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" { |
| + t.Fatalf("line %q", line) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) || f.fetches != 1 { |
| + t.Fatalf("slots %q %q after %d fetches", cur, prev, f.fetches) |
| + } |
| + // With --policy carlos.prev's version record is part of the install. |
| + if !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatal("the fresh install kept no version record for carlos.prev") |
| + } |
| + if got, err := f.in.run(ctx); err != nil || !got.Already { |
| + t.Fatalf("again %+v %v", got, err) |
| + } |
| + // A hand-rolled slot is still refused with --policy. |
| + g := newChannelFixture(t, "") |
| + g.in.policy = signedPolicy(t, g.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(systemPrevPath(g.a.optDir), []byte("#!/bin/sh\necho hand rolled\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := g.in.run(ctx); err == nil || err.Error() != systemPrevPath(g.a.optDir)+" holds a build this command did not install, so nothing was installed" || g.fetches != 0 { |
| + t.Fatalf("hand-rolled: %v", err) |
| + } |
| +} |
| + |
| +// failPrevRecord makes completePrev's write of carlos.prev's version record |
| +// fail until the returned function restores it. |
| +func failPrevRecord(t *testing.T) func() { |
| + t.Helper() |
| + real := channelPrevVersionWrite |
| + channelPrevVersionWrite = func(string, string) error { return errors.New("disk full") } |
| + restore := func() { channelPrevVersionWrite = real } |
| + t.Cleanup(restore) |
| + return restore |
| +} |
| + |
| +const prevRecordRefusal = "the record of the release in carlos.prev could not be written, so the install is not complete. run it again: disk full" |
| + |
| +// Fix round 1: the version record of carlos.prev is mandatory in the |
| +// --policy refresh, armed floor or not. A run whose record cannot be |
| +// written fails, keeps no identity and reports nothing done; the retry |
| +// completes it. |
| +func TestInstallChannelPolicyFailsWhenThePrevRecordCannotBeWritten(t *testing.T) { |
| + f, _ := updatedChannelBox(t) |
| + ctx := context.Background() |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + restore := failPrevRecord(t) |
| + got, err := f.in.run(ctx) |
| + if err == nil || err.Error() != prevRecordRefusal || got.Replaced || got.Already { |
| + t.Fatalf("a failed record: %+v %v", got, err) |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a failed record kept the identity: %v", err) |
| + } |
| + if _, ok := readLine(prevVersionPath(f.a.optDir)); ok { |
| + t.Fatal("a failed record left a version record") |
| + } |
| + // Still failing: never "already installed". |
| + if got, err := f.in.run(ctx); err == nil || err.Error() != prevRecordRefusal || got.Already { |
| + t.Fatalf("the second failed record: %+v %v", got, err) |
| + } |
| + restore() |
| + // The failed run had already copied carlos.prev, so the retry replaces |
| + // nothing and its line says only what is installed (M4). |
| + got, err = f.in.run(ctx) |
| + if err != nil || got.Replaced || got.Already || installedLine(got) != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" { |
| + t.Fatalf("the retry: %+v %v", got, err) |
| + } |
| + if !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatal("the retry did not record carlos.prev") |
| + } |
| + if _, err := os.Stat(channelIdentityPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if again, err := f.in.run(ctx); err != nil || !again.Already { |
| + t.Fatalf("after the retry: %+v %v", again, err) |
| + } |
| +} |
| + |
| +// Fix round 1, the interruption with a matching identity already present: |
| +// install A, update to B, update back to A, and the --policy refresh cut |
| +// off after carlos.prev is copied and before its record is written. Both |
| +// slots and the identity then match A, which the shortcut alone would call |
| +// "already installed"; the retry completes the record instead. |
| +func TestInstallChannelPolicyCompletesACutOffRefreshOverAMatchingIdentity(t *testing.T) { |
| + f := newChannelFixture(t, "") |
| + ctx := context.Background() |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if got, err := f.in.run(ctx); err != nil || got.Already || got.Replaced { |
| + t.Fatalf("install A: %+v %v", got, err) |
| + } |
| + identityA, err := os.ReadFile(channelIdentityPath(f.a.optDir)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b := []byte("#!/bin/sh\necho carlos v1.5.0\n") |
| + applyChannelUpdate(t, f, "v1.5.0", b, true) |
| + applyChannelUpdate(t, f, "v1.4.0", f.body, false) |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, b) || !prevRecorded(f.a.optDir, "v1.5.0") { |
| + t.Fatalf("after the updates: %q %q", cur, prev) |
| + } |
| + // The cut-off refresh: carlos.prev copied, its record not written. |
| + failPrevRecord(t) |
| + if got, err := f.in.run(ctx); err == nil || err.Error() != prevRecordRefusal || got.Already || got.Replaced { |
| + t.Fatalf("the cut-off refresh: %+v %v", got, err) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) { |
| + t.Fatal("the cut-off refresh did not copy carlos.prev") |
| + } |
| + if held, _ := os.ReadFile(channelIdentityPath(f.a.optDir)); !bytes.Equal(held, identityA) { |
| + t.Fatal("the identity is not A's") |
| + } |
| + if _, ok := readLine(prevVersionPath(f.a.optDir)); ok { |
| + t.Fatal("the cut-off refresh left a version record") |
| + } |
| + // Still failing: the shortcut does not answer over it. |
| + if got, err := f.in.run(ctx); err == nil || err.Error() != prevRecordRefusal || got.Already { |
| + t.Fatalf("over incomplete metadata: %+v %v", got, err) |
| + } |
| + channelPrevVersionWrite = writePrevVersion |
| + got, err := f.in.run(ctx) |
| + if err != nil || !got.Already { |
| + t.Fatalf("the retry: %+v %v", got, err) |
| + } |
| + if !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatal("the retry did not complete carlos.prev's record") |
| + } |
| + if f.fetches != 1 { |
| + t.Fatalf("%d fetches", f.fetches) |
| + } |
| +} |
| + |
| +// --policy's file is refused before anything else is read when it cannot |
| +// be read or is not an armed policy at all. |
| +func TestInstallChannelPolicyFileIsReadUpFront(t *testing.T) { |
| + dir := t.TempDir() |
| + missing := filepath.Join(dir, "policy.json") |
| + if _, err := readChannelPolicy(missing); err == nil || !strings.HasPrefix(err.Error(), "the policy "+missing+" could not be read, so nothing was installed: ") { |
| + t.Fatalf("missing: %v", err) |
| + } |
| + for _, body := range []string{"", "{", `{"mode":"unpublished"}`, `{"mode":"enforced"}`} { |
| + if err := os.WriteFile(missing, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := readChannelPolicy(missing); err == nil || err.Error() != "the --policy file is not an armed policy signed for this deployment, so nothing was installed" { |
| + t.Fatalf("%q: %v", body, err) |
| + } |
| + } |
| + _, key, _ := ed25519.GenerateKey(nil) |
| + raw := signedPolicy(t, key, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if err := os.WriteFile(missing, raw, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := readChannelPolicy(missing); err != nil || !bytes.Equal(got, raw) { |
| + t.Fatalf("a signed policy: %v", err) |
| + } |
| +} |
| + |
| +// signedPolicyBytes is a well-formed armed policy under a throwaway key. |
| +func signedPolicyBytes(t *testing.T) []byte { |
| + t.Helper() |
| + _, key, _ := ed25519.GenerateKey(nil) |
| + return signedPolicy(t, key, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| +} |
| + |
| +// An updated box with no carlos.prev at all gets the release in it and its |
| +// record; nothing was replaced, so the line says what is installed. |
| +func TestInstallChannelPolicyFillsAnAbsentPrev(t *testing.T) { |
| + f, _ := updatedChannelBox(t) |
| + if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + got, err := f.in.run(context.Background()) |
| + if err != nil || got.Replaced || got.Already { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + if line := installedLine(got); line != "installed v1.4.0 (v1.4.0) from the stable channel as carlos and carlos.prev" { |
| + t.Fatalf("line %q", line) |
| + } |
| + if _, prev := f.slots(t); !bytes.Equal(prev, f.body) || !prevRecorded(f.a.optDir, "v1.4.0") || f.fetches != 0 { |
| + t.Fatalf("carlos.prev %q", prev) |
| + } |
| +} |
| + |
| +// I2: on a box with a root participation census, the --policy path takes |
| +// the update's root moves floor branch. Here, not root, the branch cannot |
| +// keep the release's identity in root's guarded state, so the run refuses |
| +// with the box unchanged, where the census-less branch would go on. The |
| +// production constructor sets the census source only with --policy. |
| +func TestInstallChannelPolicyTakesTheCensusBranch(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("the root branch succeeds as root; TestRootInstallChannelPolicyCensusBox covers it") |
| + } |
| + f, _ := updatedChannelBox(t) |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + f.a.rootMoves = func() (rootFloorIdentity, bool, error) { |
| + return rootFloorIdentity{Deployment: "bucket", Box: "box", Enrollment: "en"}, true, nil |
| + } |
| + before := boxFiles(t, f) |
| + if _, err := f.in.run(context.Background()); err == nil || !strings.HasPrefix(err.Error(), "the stable channel could not be read, so nothing was installed: ") { |
| + t.Fatalf("a census box off root: %v", err) |
| + } |
| + if after := boxFiles(t, f); !equalFiles(before, after) { |
| + t.Fatalf("changed: %v", diffFiles(before, after)) |
| + } |
| + if newChannelInstall("stable", nil).a.rootMoves != nil { |
| + t.Fatal("the no-policy applier reads the census") |
| + } |
| + if newChannelInstall("stable", []byte("{}")).a.rootMoves == nil { |
| + t.Fatal("the --policy applier does not read the census") |
| + } |
| +} |
| + |
| +// abaReturn is the fix round 2 box: installed at A (v1.4.0) with --policy, |
| +// updated to B and back to A, so A's channel identity is still held, with |
| +// a moves floor armed at A on the box so that judging carlos.prev writes a |
| +// verdict. |
| +func abaReturn(t *testing.T) *channelFixture { |
| + t.Helper() |
| + f := newChannelFixture(t, "") |
| + f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"}) |
| + if got, err := f.in.run(context.Background()); err != nil || got.Already || got.Replaced { |
| + t.Fatalf("install A: %+v %v", got, err) |
| + } |
| + applyChannelUpdate(t, f, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"), true) |
| + applyChannelUpdate(t, f, "v1.4.0", f.body, false) |
| + armLocalFloor(t, f.a.dataDir, "v1.4.0") |
| + return f |
| +} |
| + |
| +// failJudge makes the floor's judgement of carlos.prev fail (as a cut-off |
| +// or failing judgement) until restored; calls counts every judgement. |
| +func failJudge(t *testing.T, calls *int) func() { |
| + t.Helper() |
| + real := channelPrevJudge |
| + channelPrevJudge = func(a *systemApplier, ctx context.Context) error { |
| + *calls++ |
| + return errors.New("judgement cut off") |
| + } |
| + restore := func() { |
| + channelPrevJudge = func(a *systemApplier, ctx context.Context) error { |
| + *calls++ |
| + return real(a, ctx) |
| + } |
| + } |
| + t.Cleanup(func() { channelPrevJudge = real }) |
| + return restore |
| +} |
| + |
| +// Fix round 2: the refresh cut off after carlos.prev's record is written |
| +// and before its judgement finishes, over a matching identity. Binaries, |
| +// identity and record then all match A; the retry still judges, and only |
| +// then answers "already installed". |
| +func TestInstallChannelPolicyFinishesAJudgementCutOffAfterTheRecord(t *testing.T) { |
| + f := abaReturn(t) |
| + ctx := context.Background() |
| + calls := 0 |
| + restore := failJudge(t, &calls) |
| + if got, err := f.in.run(ctx); err == nil || err.Error() != "judgement cut off" || got.Already || got.Replaced { |
| + t.Fatalf("the cut-off refresh: %+v %v", got, err) |
| + } |
| + if !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatal("the cut-off refresh did not write the record first") |
| + } |
| + if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a verdict before the judgement: %v", err) |
| + } |
| + if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) { |
| + t.Fatal("slots") |
| + } |
| + restore() |
| + got, err := f.in.run(ctx) |
| + if err != nil || !got.Already || calls != 2 { |
| + t.Fatalf("the retry: %+v %v after %d judgements", got, err, calls) |
| + } |
| + if v, ok := readLine(prevAllowedPath(f.a.optDir)); !ok || v != "v1.4.0" { |
| + t.Fatalf("the retry did not finish the judgement: %q %v", v, ok) |
| + } |
| + // Every --policy "already installed" judges again. |
| + if got, err := f.in.run(ctx); err != nil || !got.Already || calls != 3 { |
| + t.Fatalf("a rerun: %+v %v after %d judgements", got, err, calls) |
| + } |
| +} |
| + |
| +// Fix round 2: a judgement that keeps failing is reported every time, over |
| +// a record and an identity that match; never "already installed". |
| +func TestInstallChannelPolicyReportsAFailingJudgement(t *testing.T) { |
| + f := abaReturn(t) |
| + ctx := context.Background() |
| + calls := 0 |
| + failJudge(t, &calls) |
| + for i := 0; i < 3; i++ { |
| + if got, err := f.in.run(ctx); err == nil || err.Error() != "judgement cut off" || got.Already { |
| + t.Fatalf("run %d: %+v %v", i, got, err) |
| + } |
| + } |
| + if calls != 3 || !prevRecorded(f.a.optDir, "v1.4.0") { |
| + t.Fatalf("%d judgements", calls) |
| + } |
| + if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !os.IsNotExist(err) { |
| + t.Fatalf("a verdict from a failed judgement: %v", err) |
| + } |
| +} |
| + |
| +// floorUnreadable fails every read of the host floor's two objects. |
| +type floorUnreadable struct{ release.Store } |
| + |
| +func (s floorUnreadable) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == hostfloor.Key || key == hostfloor.AuthKey { |
| + return nil, "", errors.New("door restarting") |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func equalFiles(a, b map[string]string) bool { return len(diffFiles(a, b)) == 0 } |
| + |
| +func diffFiles(a, b map[string]string) []string { |
| + var d []string |
| + for k, v := range a { |
| + if w, ok := b[k]; !ok || w != v { |
| + d = append(d, k) |
| + } |
| + } |
| + for k := range b { |
| + if _, ok := a[k]; !ok { |
| + d = append(d, k) |
| + } |
| + } |
| + return d |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 889ac9320..ad02d55b6 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -858,6 +858,32 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-step6-artifacts-fix2.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| + // Census overlay claim-prev-rehearsal (install-channel --policy: arm an |
| + // updater-installed box, controller ruling R1), loaded last, under |
| + // census-ruling-claim-prev-rehearsal (platform-1e, accepted as proposed; |
| + // docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md). It |
| + // adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-prev-rehearsal-fix1 (fix round 1: mandatory |
| + // carlos.prev record, unfinished-update refusal, the census branch for |
| + // --policy, the hardened policy read), loaded last, under |
| + // census-ruling-claim-prev-rehearsal-fix1 (platform-1e, accepted as |
| + // proposed; docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md, |
| + // "Fix round 1"). It adds no accepted-legacy row, so the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Census overlay claim-prev-rehearsal-fix2 (fix round 2: carlos.prev's |
| + // judgement always finished on --policy's already-installed path), |
| + // loaded last. Envelope only. Under census-ruling-claim-prev-rehearsal-fix2 |
| + // (platform-1e, accepted as proposed; |
| + // docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md, |
| + // "Fix round 2"). The seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| 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-claim-prev-rehearsal-fix1.tsv b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv| new file mode 100644 |
| index 000000000..2eb55b452 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv |
| @@ -0,0 +1,8 @@ |
| +# Census overlay claim-prev-rehearsal-fix1 (fix round 1: Astra's metadata finding and platform-00's I1, I2, M3, M5, as ruled by the controller), loaded LAST, after claim-prev-rehearsal. RULED by platform-1e 2026-10-18 as census-ruling-claim-prev-rehearsal-fix1: every row ACCEPTED as proposed (channelInstall.run basis corrected at F,J4, refreshPrev re-pinned, openChannelPolicy new, readChannelPolicy removed, internal/host envelope re-pinned; the seal stands, no 9B8 entry); docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md, section "Fix round 1". |
| +# install-channel --policy only; without --policy nothing changes. carlos.prev's version record is mandatory in the --policy path (completePrev: under the prev lock the old verdicts and record are removed, carlos copied when refreshing, the record written and read back, every failure fatal, then the floor's judgement by notePrev without a replace), and the --policy "already installed" answer requires that record. An open update journal or update-begun marker refuses (guardArmIdle). The --policy applier reads the root participation census (rootMovesFloorIdentity), so a census box takes the update's root moves floor branch. The policy file is opened with O_NOFOLLOW and O_NONBLOCK, fstat'd regular and capped at 64 KiB (openChannelPolicy); readChannelPolicy keeps no read site of its own (@remove). |
| +# channelInstall.run's digest and class are unchanged; only its basis is corrected (M5). The seal stands: no accepted-legacy row. The 9B8 inventory is unchanged. |
| +@coverage internal/host ebc571e3abd07501ffcd2992ad2f2269c242b0dd5c792314662c6988a6264e34 |
| +@remove internal/host/installchannel.go readChannelPolicy |
| +internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ruling-claim-prev-rehearsal-fix1;basis=carlos system install-channel (root): at a fresh box's start-up, and with --policy also at an updated box's attended root shell before the host floor is published; its one read site opens root's apply lock; the install goes through verifyPointer, hostfloor.CheckImage (against the published floor, or with --policy the verified policy floor) and stageCandidate into both protected slots, or with --policy on an updated box at the channel head through refreshPrev; it refuses a box whose guard is armed or that already runs another release, and with --policy a box whose update has not finished file:OpenFile@196 f0cdb6dd28e83da282c3b0d7bc2012dcd80db181a8cecbf1a2db6bcc41d1d5e3 |
| +internal/host/installchannel.go channelInstall.refreshPrev authority F,J4 rev3=census-ruling-claim-prev-rehearsal-fix1;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=under the apply lock, after the armed-guard, unfinished-update, other-release and floor refusals: carlos must hash, through slotState's O_NOFOLLOW descriptor, to the channel release's signed artifact sha256; its one read site Lstats carlos.prev to refuse a symlink or anything but a regular file; completePrev then removes the old verdicts and version record, copies carlos over carlos.prev and writes and reads back the record, each step mandatory; it then reaches notePrev and through it the floor's judgement (judgePrevWith and judgeSeedsPrev, or with a root participation census the root moves floor judge), which can open the registry as root when a legacy local floor exists; both slots are re-hashed before the identity is kept; nothing is fetched or executed and applied.json and seen.json are not written file:Lstat@907 1426d3230768f721a7daf2eadd42590d69125ade16c81d6ff70fdb89543b2abe |
| +internal/host/installchannel_linux.go openChannelPolicy authority F,J4 rev3=census-ruling-claim-prev-rehearsal-fix1;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=reads the operator-staged --policy file once, before the apply lock or any store read: opened with O_NOFOLLOW (a symlink is refused) and O_NONBLOCK (a FIFO does not block), fstat'd to be a regular file of at most 64 KiB, then read from the descriptor up to that cap; the bytes are used only after channelPolicy verifies the root-signed moves-protocol authorization for this deployment's bucket under CARLOS_RELEASE_PUBKEY, and a published floor must equal them byte for byte file:Open@79,file:ReadAll@485,file:Stat@263 eb5725bd24aa51f5d4f93b01a065351a278bb82cbf013a106b2f1fba36987f94 |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv| new file mode 100644 |
| index 000000000..d7f21aed4 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv |
| @@ -0,0 +1,3 @@ |
| +# Census overlay claim-prev-rehearsal-fix2 (fix round 2: Astra's re-review and platform-00's minors, as ruled by the controller), loaded LAST, after claim-prev-rehearsal-fix1. RULED by platform-1e 2026-10-18 as census-ruling-claim-prev-rehearsal-fix2: ACCEPTED as proposed (envelope-only re-pin of internal/host; the seal unchanged, no 9B8 entry); docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md, section "Fix round 2". |
| +# Envelope only. In the install-channel --policy path, "already installed" always runs carlos.prev's judgement again (channelPrevJudge, notePrev without a replace) and fails on its error; completePrev ends with the same judgement; the unfinished-update refusal's reason drops guard-arm's floor prefix; installChannelUsage is one literal of the same value (RHB-C19, re-approved). No root-read unit's digest or sites change: channelInstall.run, channelInstall.refreshPrev and openChannelPolicy stand at fix1's rows. The seal stands; the 9B8 inventory is unchanged. |
| +@coverage internal/host 33968e7c537bbb72b15c1c9641515921341507d38d4150589f27370e919ba36c |
diff --git a/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv| new file mode 100644 |
| index 000000000..89d151d4d |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal.tsv |
| @@ -0,0 +1,7 @@ |
| +# Census overlay claim-prev-rehearsal (claim trial: arm an updater-installed box; controller ruling R1, plus platform-00's binding additions), loaded LAST, after claim-step6-artifacts-fix2. RULED by platform-1e 2026-10-18 as census-ruling-claim-prev-rehearsal: every row ACCEPTED as proposed (readChannelPolicy and channelInstall.refreshPrev new, internal/host envelope re-pinned; 2b's rows unchanged, no 9B8 entry, the seal stands); docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md. |
| +# carlos system install-channel --policy FILE (internal/host installchannel.go). Without --policy nothing changes: channelInstall.run keeps its digest and class (F,J4, census-ruling-region-ha-2b), and slotState, var openat2 and removeBeneath are unchanged and stay 2b's rows. install and checkFloor have no read site of their own (envelope only): with --policy the signed armed policy's verified floor stands in for the published one, and a published floor, or either half of one, must be the policy's bytes. |
| +# Two new root-read units, both reached only through install-channel --policy, run as uid 0 at the box's root shell: readChannelPolicy reads the operator-staged policy file and refuses it before anything else unless it is an armed policy (its signature is verified once the release key is known); channelInstall.refreshPrev, the one admission of an updated box at the channel head, Lstats carlos.prev to refuse a symlink or a non-regular file before the updater's own notePrev refreshes it from carlos. |
| +# The seal stands: no accepted-legacy row. No box agent, update, recovery or edge path reaches either new unit. The 9B8 inventory is unchanged: install-channel writes only local files. |
| +@coverage internal/host 422bed5ca44ec32c8eab923ad6b96c7a39c50829c4a3346f004fb2383b3dad9e |
| +internal/host/installchannel.go readChannelPolicy authority F,J4 rev3=census-ruling-claim-prev-rehearsal;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=reads the operator-staged --policy file once, before the apply lock or any store read; refuses an unreadable file or one that is not an armed policy; the bytes are used only after channelPolicy verifies the root-signed moves-protocol authorization for this deployment's bucket under CARLOS_RELEASE_PUBKEY, and a published floor must equal them byte for byte file:ReadFile@67 631724a2f53e641e7b5d4d42c927e8687472000f2ad506fa9ee64f0bb146e77f |
| +internal/host/installchannel.go channelInstall.refreshPrev authority F,J4 rev3=census-ruling-claim-prev-rehearsal;identity=uid 0 at the box's attended root shell (carlos system install-channel --policy);basis=under the apply lock, after the armed-guard, other-release and floor refusals: carlos must hash, through slotState's O_NOFOLLOW descriptor, to the channel release's signed artifact sha256; its one read site Lstats carlos.prev to refuse a symlink or anything but a regular file; carlos.prev is then refreshed from carlos by the updater's notePrev and both slots are re-hashed before the identity is kept; nothing is fetched or executed and applied.json and seen.json are not written file:Lstat@907 d0848383f68009331536d067c1cade578186514edb351645a7a3ad2e250a7bf5 |