box-publish-record
Squashed from box-publish-record into main.
11 files changed,
+1325
−1
docs/site/cli.md+10 −0docs/superpowers/notes/census-proposal-box-publish-record.md+162 −0internal/host/boxpublish.go+335 −0internal/host/boxpublish_test.go+737 −0internal/provision/box_publish_record_census_test.go+18 −0internal/provision/plan14_root_reads_test.go+8 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-box-publish-record.tsv+7 −0system.go+5 −0system_roles.go+8 −0system_roles_test.go+34 −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.067s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.606s
ok github.com/carlosframework/platform/cmd/moveprobe 93.862s
ok github.com/carlosframework/platform/cmd/zddprobe 0.762s
ok github.com/carlosframework/platform/internal/activator 252.019s
ok github.com/carlosframework/platform/internal/agentsock 0.034s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.042s
ok github.com/carlosframework/platform/internal/appconfig 2.794s
ok github.com/carlosframework/platform/internal/apperr 0.138s
ok github.com/carlosframework/platform/internal/applog 0.303s
ok github.com/carlosframework/platform/internal/awsinv 0.017s
ok github.com/carlosframework/platform/internal/boxchoice 0.009s
ok github.com/carlosframework/platform/internal/boxid 1.116s
? 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.013s
ok github.com/carlosframework/platform/internal/caps 0.022s
ok github.com/carlosframework/platform/internal/certwarm 0.004s
ok github.com/carlosframework/platform/internal/cgroup 0.789s
ok github.com/carlosframework/platform/internal/changelog 0.426s
ok github.com/carlosframework/platform/internal/claimactivation 0.058s
ok github.com/carlosframework/platform/internal/clamd 0.057s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 410.696s
? 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.172s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.035s
ok github.com/carlosframework/platform/internal/dbverify 0.007s
ok github.com/carlosframework/platform/internal/dnsrec 0.014s
ok github.com/carlosframework/platform/internal/docsite 0.016s
ok github.com/carlosframework/platform/internal/domains 2.625s
ok github.com/carlosframework/platform/internal/doorkey 0.043s
ok github.com/carlosframework/platform/internal/economics 0.016s
ok github.com/carlosframework/platform/internal/edge 46.445s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.015s
ok github.com/carlosframework/platform/internal/fleetapi 0.258s
ok github.com/carlosframework/platform/internal/gate 1.728s
ok github.com/carlosframework/platform/internal/genselect 0.059s
ok github.com/carlosframework/platform/internal/geo 0.043s
ok github.com/carlosframework/platform/internal/geoip 0.070s
ok github.com/carlosframework/platform/internal/historyreadback 0.270s
ok github.com/carlosframework/platform/internal/host 189.588s
ok github.com/carlosframework/platform/internal/hostactors 7.381s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.008s
ok github.com/carlosframework/platform/internal/hostbox 3.335s
ok github.com/carlosframework/platform/internal/hostclaim 1.017s
ok github.com/carlosframework/platform/internal/hostenv 0.009s
ok github.com/carlosframework/platform/internal/hostevidence 0.028s
ok github.com/carlosframework/platform/internal/hostexec 7.389s
ok github.com/carlosframework/platform/internal/hostfence 0.066s
ok github.com/carlosframework/platform/internal/hostfiles 11.256s
ok github.com/carlosframework/platform/internal/hostfloor 0.014s
ok github.com/carlosframework/platform/internal/hostguard 1.231s
ok github.com/carlosframework/platform/internal/hostops 2.448s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.003s
ok github.com/carlosframework/platform/internal/hostquarantine 0.005s
ok github.com/carlosframework/platform/internal/hostrelocate 1.011s
ok github.com/carlosframework/platform/internal/hostresolve 0.260s
ok github.com/carlosframework/platform/internal/hostwrite 0.003s
ok github.com/carlosframework/platform/internal/infraview 0.004s
ok github.com/carlosframework/platform/internal/instancectl 5.308s
ok github.com/carlosframework/platform/internal/instanceexec 2.934s
ok github.com/carlosframework/platform/internal/instancefs 0.008s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.012s
ok github.com/carlosframework/platform/internal/layout 2.040s
ok github.com/carlosframework/platform/internal/lbhealth 0.002s
ok github.com/carlosframework/platform/internal/lease 9.893s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.007s
ok github.com/carlosframework/platform/internal/ledger 0.034s
ok github.com/carlosframework/platform/internal/mail 0.432s
ok github.com/carlosframework/platform/internal/maintenance 0.101s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.783s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.187s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 24.278s
ok github.com/carlosframework/platform/internal/moves/archive 1.468s
ok github.com/carlosframework/platform/internal/objstore 0.433s
ok github.com/carlosframework/platform/internal/ops 0.215s
ok github.com/carlosframework/platform/internal/park 0.425s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 45.992s
ok github.com/carlosframework/platform/internal/publicrelease 0.011s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 6.458s
ok github.com/carlosframework/platform/internal/relay 7.284s
ok github.com/carlosframework/platform/internal/release 24.959s
ok github.com/carlosframework/platform/internal/rollout 4.408s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.009s
ok github.com/carlosframework/platform/internal/rootparticipation 0.061s
ok github.com/carlosframework/platform/internal/rootstate 0.031s
ok github.com/carlosframework/platform/internal/runs 0.622s
ok github.com/carlosframework/platform/internal/s3compat 0.012s
ok github.com/carlosframework/platform/internal/safefile 0.005s
ok github.com/carlosframework/platform/internal/schedule 3.179s
ok github.com/carlosframework/platform/internal/seeds 0.028s
ok github.com/carlosframework/platform/internal/sim/hostlife 19.673s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.277s
ok github.com/carlosframework/platform/internal/status 0.086s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.287s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.008s
ok github.com/carlosframework/platform/internal/termfence 0.005s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.008s
ok github.com/carlosframework/platform/internal/updatewords 0.006s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.878s
=== 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: 49283234 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.021s
=== RUN TestT4StorageProbeMinio
2026/10/18 03:38:42 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DF828DE18A7A43, 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.021s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md| index f5156ce8a..81aff3dd5 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2264,6 +2264,16 @@ carlos system group role ie default |
| Ops account owners and `CARLOS_SYSTEM_OPERATORS`. |
| +#### A box's identity record |
| + |
| +carlos system box publish-record publishes the box's identity record to the deployment bucket, at control/boxes/<box>.json, where the host claim's enrollment reads it. The record is the box's EC2 instance identity document and AWS's signature over it, so AWS vouches for the box, not the box itself or you. The agent publishes it by itself only in a region with HA on. Anywhere else, run this once on the box as the agent user, with the box's host.env loaded. This is the only supported way to run it: |
| + |
| +```sh |
| +systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record |
| +``` |
| + |
| +It refuses to run as root, on a fleet box, and on a box with no S3 deployment bucket. It only ever creates the record, and only an S3 bucket can refuse to overwrite one. If a record is already there, it checks it the way enrollment does: AWS's signature, the account, the region, the instance and the box name. A valid record is kept as it is, even an older one written before the instance was stopped and started, and nothing is written. Any other record is refused, with what is wrong, and left alone. A valid record means only that the evidence is in place. Enrollment still makes its own checks before it accepts the box. |
| + |
| #### Boxes a region's group launches |
| In a region with HA on, the boxes its Auto Scaling group launches set themselves up with no hand steps. A box's start-up runs two commands as root before it starts the agent. |
diff --git a/docs/superpowers/notes/census-proposal-box-publish-record.md b/docs/superpowers/notes/census-proposal-box-publish-record.md| new file mode 100644 |
| index 000000000..beac2e573 |
| --- /dev/null |
| +++ b/docs/superpowers/notes/census-proposal-box-publish-record.md |
| @@ -0,0 +1,162 @@ |
| +# Census: carlos system box publish-record |
| + |
| +Status: RULED as census-ruling-box-publish-record (platform-1e, R1-R4), on |
| +ea68e8a6. This note records the ruling and the two fix rounds after it: |
| +3165a3bf applied the ruling, and the second round replaced exact evidence |
| +matching with enrollment's own verifier. platform-1e rules as the host |
| +claim's owner. |
| + |
| +Branch `box-publish-record`, cut from main at b1118925 (region HA 2b). |
| + |
| +## The ruling |
| + |
| +- **R1.** The verb refuses euid 0 before any store, IMDS or host read. Root |
| + cannot reach `publishBoxRecord` through it. The overlay header records |
| + "root unreachable: verb refuses euid 0 (R1)" rather than a unit row. |
| +- **R2.** The one supported way to run it: |
| + `systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record`. |
| +- **R3.** No region HA gate. Run after the loop has published, the verb is a |
| + no-op: it keeps the loop's record and writes nothing. |
| +- **R4.** `boxRecordStore.Put` is `nonrecord`, on condition that |
| + `boxRecordStore` is the only store the verb hands `publishBoxRecord`. The |
| + store-guard test asserts zero S3 requests for a delete, a list, an |
| + overwrite and any other key. |
| + |
| +## What changed |
| + |
| +`carlos system box publish-record` makes one `publishBoxRecord` attempt on a |
| +box, by hand, without `boxRecordLoop`'s `CARLOS_REGION_HA` gate. The dev claim |
| +trial needs records at `control/boxes/<box>.json` for leo-climbing |
| +(carlos-dev-ie-b) and sophie-knitting (carlos-dev-ie). Region HA stays off on |
| +dev. |
| + |
| +- `internal/host/boxpublish.go` (new): `RunSystemBoxPublishRecord`, |
| + `boxPublishRun`, `heldEvidence`, `boxRecordStore` and the verb's lines. |
| +- `system_roles.go`: `runSystemBoxVerb` hands `box publish-record` to the |
| + host verb before `roleArgs`. |
| +- `system.go`: `systemUsage` gains the synopsis and description lines. |
| +- `internal/host/boxrecord.go` is not edited. `publishBoxRecord`, |
| + `boxRecordLoop` and its gate are byte for byte main's. |
| + |
| +The verb's checks, in order: |
| + |
| +1. The effective uid is not 0 (R1). |
| +2. The box is not a fleet box (`shouldSkipPublisherElection`). |
| +3. `CARLOS_DEPLOYMENT_BUCKET` is set. A deployment directory is refused: its |
| + check-then-rename cannot refuse an overwrite, and the verb never opens one. |
| +4. The box's label is `metricsBoxLabel`, the loop's, and it is a valid box |
| + reference. This is the first host read (`/etc/machine-id`, or IMDS for a |
| + group box). |
| +5. The store is opened with `openDeployStoreEnv` and must be a |
| + `release.S3Store`. |
| +6. The record under the key is read through `boxRecordStore`, then this |
| + box's record is read from IMDS once. |
| +7. If a record is there, enrollment's own verifier decides |
| + (`heldEvidence`). It calls `boxid.Verifier.Enroll` with the account from |
| + this box's current IMDS identity and `boxid.EmbeddedCerts`. The `Expect` |
| + holds the box label, the current AWS region, and the current instance id |
| + as `TofuInstanceID`. Enroll checks: |
| + - a strict version 1 envelope naming this box, with no other fields and |
| + nothing after it; |
| + - AWS's RSA-SHA256 signature under the certificate for the document's |
| + region; |
| + - the account, the region and the instance. |
| + |
| + There is no freshness check. An older record whose `pendingTime` or |
| + `instanceType` has since changed, or whose `written_at` is old, is kept. |
| + Its bytes are never touched, because the record cannot be replaced. |
| + Anything Enroll refuses is refused, with the reason named, and nothing is |
| + written. Keeping a record means only that valid evidence is in place. The |
| + roster and operator checks are still enrollment's. |
| +8. If nothing is there, `publishBoxRecord` runs through `boxRecordStore` with |
| + the record already read. Its outcome is reported by what reached the |
| + bucket. A failure before any create says nothing was written. A failure |
| + after a create reached the bucket says the write may have happened. A |
| + create answered 412 whose stored bytes are exactly this run's (a retried |
| + create) is reported as published. A record that arrived after step 6 is |
| + held to the same verifier. |
| + |
| +## Root-read census (TestPlan14RootReadCensus) |
| + |
| +Overlay `internal/provision/testdata/plan14-root-reads-box-publish-record.tsv`, |
| +loaded after `plan14-root-reads-region-ha-2b.tsv`. |
| + |
| +The scan finds no read site in any new or changed unit, so no root-read unit |
| +is added or changed. The only discrepancies were the two source envelopes. |
| + |
| +| Unit | Disposition | Reason | |
| +|---|---|---| |
| +| `@coverage .` | re-pinned to `d123dce7…5402` | `system.go` (usage lines) and `system_roles.go` (dispatch) changed. Neither adds a read. | |
| +| `@coverage internal/host` | re-pinned to `b1fb4b84…7ab3` | `boxpublish.go` is new. Its units have no read site of their own, and root is refused before any read (R1). | |
| +| `internal/boxid/certs.go:EmbeddedCerts` | unchanged, accepted-legacy (J4, root-input) | The verb now calls it for the certificates enrollment verifies with. Its reads are the binary's own embedded files, its digest is unchanged, and root never reaches it through the verb (R1). | |
| +| `internal/boxid` envelope | unchanged (`0c6243af…3794`) | `Verifier.Enroll` has no read site. internal/host already imported internal/boxid (`claimproduction.go` calls `EmbeddedCerts`), so the verb's import graph gains no package. Only `crypto/x509` is added, from the standard library. | |
| +| legacy seal | unchanged (`fc584e11…1b5d`) | The overlay adds no `@legacy` row. | |
| + |
| +## Record-write census (TestPlan9B8RecordWriteCensus) |
| + |
| +The inventory is re-pinned by `internal/provision/box_publish_record_census_test.go` |
| +(`plan14BoxPublishRecordWriteCensusInventory`, which the test uses). It |
| +appends one entry to `plan14ActivationToolsWriteCensusInventory`. |
| + |
| +| Unit | Kinds | Class | Reason | |
| +|---|---|---|---| |
| +| `internal/host/boxpublish.go:boxRecordStore.Put` | `def:Put,sel:Put` | `nonrecord` (R4) | It writes `control/boxes/<box>.json`, a box identity record, never an instance or sidecar record. It touches no record-key constructor or record path literal. It admits only a create-only Put on its one key. | |
| + |
| +Digest `866978d6c6786a351bbeaf6f9e5382df0b3fd08cbb47aed0c01738f7c5120d7a`. |
| + |
| +Before this branch, the one writer of `control/boxes/` was `boxid.Publish`, |
| +reached only from `publishBoxRecord` in `boxRecordLoop`. The verb is a second |
| +caller of the same chain, not a second writer: `boxid.Publish` (`nonrecord`, |
| +`sel:Put`) is unchanged. `boxRecordStore` wraps the store the chain writes |
| +through, and refuses before the bucket any Put on another key, any Put with an |
| +etag, and every `Delete`, `List` and `ListDirs`. `Get` is admitted on the one |
| +key only. |
| + |
| +## Tests behind the claims |
| + |
| +`internal/host/boxpublish_test.go` runs the real `S3Store` (through |
| +`openDeployStoreEnv`) against `leasetest`'s S3 server. The server sits behind |
| +a fake of the dev bucket's policy, which denies a PUT under `control/boxes/` |
| +without `If-None-Match: *` and any DELETE there. |
| + |
| +- Fresh box: the record is written at the right key with the right fields, |
| + by one create-only PUT. |
| +- After the loop has published (R3): kept, no PUT, bytes unchanged. |
| +- An older record, correctly signed for the same account, region and |
| + instance but with a different `pendingTime` and `instanceType`: kept as |
| + it is, no PUT, bytes unchanged. |
| +- Anything enrollment's verifier refuses is refused, named, with no PUT and |
| + bytes unchanged. The signing certificates are generated the way boxid's |
| + own tests generate them. The cases: |
| + - a different account; |
| + - a different region (one that has a certificate); |
| + - another instance; |
| + - a bad signature (another key); |
| + - a missing signature; |
| + - a document altered after signing; |
| + - a different box; |
| + - version 2; |
| + - an unknown field; |
| + - trailing data; |
| + - not JSON; |
| + - a document that is not JSON. |
| +- A record arriving between the verb's read and `publishBoxRecord`'s is held |
| + to the same verifier: a valid older one is kept, a forged one refused. |
| +- The embedded production certificates cover eu-west-1. |
| +- A different record created just before the PUT: the bucket's 412 stops it |
| + and nothing is overwritten. |
| +- A create committed but answered 500: the SDK retries, gets 412, and the |
| + verb reports published. |
| +- A create committed but answered 400: the verb says the write may have |
| + happened, and a second run reports it in place. |
| +- Root (R1), a fleet box, and no S3 bucket (a directory set, or a non-S3 |
| + store) are each refused before the machine id, the store or IMDS is |
| + touched, with zero bucket requests. The directory is never written. |
| +- A group box publishes under its group reference, the loop's label. |
| +- `boxRecordStore` (R4): zero S3 requests for a delete, a list, an |
| + overwrite, another box's key and any other key. |
| +- `boxRecordLoop` still returns at once with region HA off, unparseable, or |
| + on a fleet box, and sends nothing. |
| + |
| +`system_roles_test.go` runs `carlos system box publish-record` from |
| +`runSystem` in a child process, and checks the refusal and the exit status. |
diff --git a/internal/host/boxpublish.go b/internal/host/boxpublish.go| new file mode 100644 |
| index 000000000..962ab8dcb |
| --- /dev/null |
| +++ b/internal/host/boxpublish.go |
| @@ -0,0 +1,335 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/cliflag" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// carlos system box publish-record: one publishBoxRecord attempt, by hand, |
| +// without the CARLOS_REGION_HA gate boxRecordLoop has (census ruling |
| +// box-publish-record, R1-R4). |
| +// |
| +// The host claim's enrollment needs a box's signed identity record at |
| +// control/boxes/<box>.json, and the agent writes it only when region HA is |
| +// on. This verb lets an operator publish it once, with the box's own AWS |
| +// credentials and IMDS: AWS signs the identity document, not the box and |
| +// not the operator. |
| +// |
| +// It runs as the agent user and refuses root before any store, IMDS or |
| +// host read (R1); the one supported way to run it is systemd-run with |
| +// User=carlos and host.env as its EnvironmentFile (R2). It refuses on a |
| +// fleet box, as the loop skips one, and anywhere without an S3 deployment |
| +// bucket: only S3's If-None-Match makes the write create-only, and a |
| +// deployment directory's check-then-rename does not. |
| +// |
| +// It reads the record already under the box's key first, and keeps one |
| +// that enrollment's own verifier accepts as evidence for this box |
| +// (heldEvidence: boxid.Verifier.Enroll, with the account, AWS region and |
| +// instance id taken from this box's current IMDS identity). Its bytes are |
| +// never touched, however old: an instance's identity document changes |
| +// with its lifecycle (pendingTime, instanceType) while the instance stays |
| +// the same, and the record cannot be replaced. Anything the verifier |
| +// refuses is refused and left alone. Keeping a record says only that |
| +// valid evidence is in place; enrollment still makes its roster and |
| +// operator checks. With nothing there, it calls publishBoxRecord through |
| +// boxRecordStore, which can read and create this box's one record key and |
| +// nothing else (R4), so it can never delete or overwrite: the dev bucket's |
| +// box_records policy (BoxRecordsAreCreateOnly, BoxRecordsAreNeverDeleted) |
| +// denies both anyway. |
| + |
| +const boxPublishRecordCommand = "systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record" |
| + |
| +const boxPublishRecordUsage = "usage: carlos system box publish-record\n\n" + |
| + "Publish this box's AWS-signed identity record to the deployment bucket, once. It never replaces or deletes a record.\n" + |
| + "Run it on the box as the agent user, with host.env loaded:\n\n" + |
| + " " + boxPublishRecordCommand + "\n" |
| + |
| +// errBoxRecordStore is boxRecordStore refusing anything but reading and |
| +// creating this box's own record. publishBoxRecord never asks for more, so |
| +// a user only sees it if that changes. |
| +var errBoxRecordStore = errors.New("publish-record only reads and creates this box's own record") |
| + |
| +// boxRecordStore is the only store publish-record gives publishBoxRecord |
| +// (R4): Get on this box's record key, and Put on it create-only (ifMatch |
| +// "", which S3Store sends as If-None-Match: *). Everything else is |
| +// refused before it reaches the bucket. attempted is whether a create |
| +// reached the bucket, and wrote whether one was answered as done. |
| +type boxRecordStore struct { |
| + inner release.Store |
| + key string |
| + attempted bool |
| + wrote bool |
| +} |
| + |
| +func (s *boxRecordStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key != s.key { |
| + return nil, "", errBoxRecordStore |
| + } |
| + return s.inner.Get(ctx, key) |
| +} |
| + |
| +func (s *boxRecordStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key != s.key || ifMatch != "" { |
| + return "", errBoxRecordStore |
| + } |
| + s.attempted = true |
| + etag, err := s.inner.Put(ctx, key, body, "") |
| + if err == nil { |
| + s.wrote = true |
| + } |
| + return etag, err |
| +} |
| + |
| +func (s *boxRecordStore) List(context.Context, string) ([]string, error) { |
| + return nil, errBoxRecordStore |
| +} |
| + |
| +func (s *boxRecordStore) ListDirs(context.Context, string) ([]string, error) { |
| + return nil, errBoxRecordStore |
| +} |
| + |
| +func (s *boxRecordStore) Delete(context.Context, string) error { return errBoxRecordStore } |
| + |
| +// The lines publish-record prints. Every one is in the copy review deck. |
| +func boxRecordPublishedLine(box, instance string) string { |
| + return fmt.Sprintf("published the identity record for %s (instance %s) to %s", box, instance, boxid.Key(box)) |
| +} |
| + |
| +func boxRecordInPlaceLine(box, instance string) string { |
| + return fmt.Sprintf("the record at %s is valid AWS-signed evidence for %s (instance %s), so nothing was written. enrollment still makes its own checks before it accepts the box", boxid.Key(box), box, instance) |
| +} |
| + |
| +const ( |
| + boxRecordRootRefusal = "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos." |
| + boxRecordFleetRefusal = "this is a fleet box. fleet boxes publish no identity record, so nothing was written" |
| + boxRecordNoBucket = "this box has no deployment bucket (CARLOS_DEPLOYMENT_BUCKET is not set). the record is written only to an S3 bucket, which refuses to overwrite it, so nothing was written" |
| + boxRecordNoNameRefusal = "this box's name could not be read, so nothing was written" |
| +) |
| + |
| +// errOwnIdentityIncomplete is the IMDS identity document naming no account, |
| +// region or instance: there is nothing to check a stored record against, |
| +// and nothing worth publishing. |
| +var errOwnIdentityIncomplete = errors.New("the identity document AWS served this box names no account, region or instance") |
| + |
| +func boxRecordBadName(box string) error { |
| + return fmt.Errorf("this box's name %q is not a valid box reference, so nothing was written", box) |
| +} |
| + |
| +func boxRecordStoreFailure(err error) error { |
| + return fmt.Errorf("the deployment bucket could not be opened: %v. nothing was written", err) |
| +} |
| + |
| +func boxRecordRefusal(err error) error { |
| + return fmt.Errorf("refused: %v. nothing was changed", err) |
| +} |
| + |
| +func boxRecordMismatch(box, what string) error { |
| + return fmt.Errorf("refused: the record already under %s is not valid evidence for this box: %s. nothing was changed", box, what) |
| +} |
| + |
| +// boxRecordRetryable is a failure before any write reached the bucket. |
| +func boxRecordRetryable(box string, err error) error { |
| + return fmt.Errorf("the identity record for %s was not published: %v. nothing was written, so it is safe to run again", box, err) |
| +} |
| + |
| +// boxRecordUncertain is a failure once a create reached the bucket: S3 may |
| +// have committed it. A second run only ever creates, and reports what it |
| +// finds. |
| +func boxRecordUncertain(box string, err error) error { |
| + return fmt.Errorf("the identity record for %s may or may not have been written: %v. it is safe to run again, which only ever creates the record and says what is there", box, err) |
| +} |
| + |
| +// awsIdentity is the part of an identity document enrollment checks. |
| +type awsIdentity struct { |
| + Account string `json:"accountId"` |
| + Region string `json:"region"` |
| + Instance string `json:"instanceId"` |
| +} |
| + |
| +// identityOf reads rec's identity document. It does not check the |
| +// signature. |
| +func identityOf(rec boxid.Record) (awsIdentity, bool) { |
| + doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument) |
| + if err != nil { |
| + return awsIdentity{}, false |
| + } |
| + var id awsIdentity |
| + if json.Unmarshal(doc, &id) != nil { |
| + return awsIdentity{}, false |
| + } |
| + return id, true |
| +} |
| + |
| +// heldEvidence says why raw, the record already under the box's key, is |
| +// not evidence for this box, or "" when it is. The verdict is |
| +// boxid.Verifier.Enroll's, with the account, AWS region and instance id |
| +// (TofuInstanceID) taken from own, this box's current IMDS identity, and |
| +// certs as enrollment's: a strict v1 envelope naming this box, no other |
| +// fields and nothing after it, and AWS's signature under the certificate |
| +// for the document's region. There is no freshness check: the document's |
| +// mutable fields (pendingTime, instanceType) and written_at may differ |
| +// from today's. The rest only names the refusal, in Enroll's own order. |
| +func heldEvidence(raw []byte, box string, own awsIdentity, certs map[string]*x509.Certificate) string { |
| + v := boxid.Verifier{Account: own.Account, Certs: certs} |
| + _, err := v.Enroll(raw, boxid.Expect{Box: box, AWSRegion: own.Region, TofuInstanceID: own.Instance}) |
| + if err == nil { |
| + return "" |
| + } |
| + const unreadable = "it cannot be read as an identity record" |
| + dec := json.NewDecoder(bytes.NewReader(raw)) |
| + dec.DisallowUnknownFields() |
| + var held boxid.Record |
| + if dec.Decode(&held) != nil { |
| + return unreadable |
| + } |
| + if _, terr := dec.Token(); !errors.Is(terr, io.EOF) { |
| + return unreadable |
| + } |
| + switch { |
| + case held.V != 1: |
| + return fmt.Sprintf("its version is %d, not 1", held.V) |
| + case held.Box != box: |
| + return fmt.Sprintf("it is for box %q", held.Box) |
| + } |
| + id, ok := identityOf(held) |
| + switch { |
| + case !ok: |
| + return unreadable |
| + case id.Instance != own.Instance: |
| + return fmt.Sprintf("it is signed for instance %s, and this box is %s", id.Instance, own.Instance) |
| + case id.Account != own.Account: |
| + return fmt.Sprintf("it is signed for account %s, and this box is in account %s", id.Account, own.Account) |
| + case id.Region != own.Region: |
| + return fmt.Sprintf("it is signed in region %s, and this box is in %s", id.Region, own.Region) |
| + } |
| + reason := strings.TrimPrefix(err.Error(), boxid.ErrUnverified.Error()+": ") |
| + return fmt.Sprintf("it does not verify as AWS-signed evidence: %s", reason) |
| +} |
| + |
| +// boxPublishRun is one run of the verb, with its seams: the environment, |
| +// the effective uid (R1), the machine id (a host read, made only after the |
| +// uid check), the deployment store, the IMDS read and the AWS signing |
| +// certificates enrollment verifies with. |
| +type boxPublishRun struct { |
| + getenv func(string) string |
| + geteuid func() int |
| + machineID func() string |
| + open func(context.Context, func(string) string) (release.Store, error) |
| + read func(box string) (boxid.Record, error) |
| + certs func() (map[string]*x509.Certificate, error) |
| +} |
| + |
| +// publish is the verb. It returns the line to print, or the refusal. |
| +func (r boxPublishRun) publish(ctx context.Context) (string, error) { |
| + if r.geteuid() == 0 { |
| + return "", errors.New(boxRecordRootRefusal) |
| + } |
| + if shouldSkipPublisherElection(r.getenv) { |
| + return "", errors.New(boxRecordFleetRefusal) |
| + } |
| + if r.getenv("CARLOS_DEPLOYMENT_BUCKET") == "" { |
| + return "", errors.New(boxRecordNoBucket) |
| + } |
| + box := metricsBoxLabel(r.getenv, r.machineID()) |
| + if box == "" { |
| + return "", errors.New(boxRecordNoNameRefusal) |
| + } |
| + if !boxid.ValidBox(box) { |
| + return "", boxRecordBadName(box) |
| + } |
| + opened, err := r.open(ctx, r.getenv) |
| + if err != nil { |
| + return "", boxRecordStoreFailure(err) |
| + } |
| + if _, ok := opened.(*release.S3Store); !ok { |
| + return "", errors.New(boxRecordNoBucket) |
| + } |
| + store := &boxRecordStore{inner: opened, key: boxid.Key(box)} |
| + |
| + existing, _, gerr := store.Get(ctx, store.key) |
| + if gerr != nil && !errors.Is(gerr, os.ErrNotExist) { |
| + return "", boxRecordRetryable(box, gerr) |
| + } |
| + mine, err := r.read(box) |
| + if err != nil { |
| + return "", boxRecordRetryable(box, err) |
| + } |
| + own, ok := identityOf(mine) |
| + if !ok || own.Account == "" || own.Region == "" || own.Instance == "" { |
| + return "", boxRecordRetryable(box, errOwnIdentityIncomplete) |
| + } |
| + // keep decides a record that is already there: kept as it is when the |
| + // verifier accepts it, refused otherwise. |
| + keep := func(raw []byte) (string, error) { |
| + certs, err := r.certs() |
| + if err != nil { |
| + return "", boxRecordRetryable(box, err) |
| + } |
| + if what := heldEvidence(raw, box, own, certs); what != "" { |
| + return "", boxRecordMismatch(box, what) |
| + } |
| + return boxRecordInPlaceLine(box, own.Instance), nil |
| + } |
| + if gerr == nil { |
| + return keep(existing) |
| + } |
| + |
| + done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return mine, nil }) |
| + switch { |
| + case err != nil && done: |
| + return "", boxRecordRefusal(err) |
| + case err != nil && store.attempted: |
| + return "", boxRecordUncertain(box, err) |
| + case err != nil: |
| + return "", boxRecordRetryable(box, err) |
| + case store.attempted: |
| + // Either this create landed, or a retry of it was answered 412 |
| + // and boxid.Publish found exactly these bytes, written_at |
| + // included: this run's record either way. |
| + return boxRecordPublishedLine(box, own.Instance), nil |
| + } |
| + // No create was attempted: a record arrived after the read above and |
| + // publishBoxRecord took it as this instance's. It is held to the same |
| + // verifier as one that was there from the start. |
| + raw, _, err := store.Get(ctx, store.key) |
| + if err != nil { |
| + return "", boxRecordRetryable(box, err) |
| + } |
| + return keep(raw) |
| +} |
| + |
| +// RunSystemBoxPublishRecord is `carlos system box publish-record`. |
| +func RunSystemBoxPublishRecord(args []string) { |
| + fs := flag.NewFlagSet("system box publish-record", flag.ExitOnError) |
| + fs.Usage = cliflag.Usage(fs, boxPublishRecordUsage) |
| + _ = fs.Parse(args) |
| + if fs.NArg() != 0 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + client := &http.Client{Timeout: 2 * time.Second} |
| + run := boxPublishRun{getenv: os.Getenv, geteuid: os.Geteuid, machineID: readMachineID, open: openDeployStoreEnv, |
| + read: func(box string) (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }, |
| + certs: boxid.EmbeddedCerts} |
| + line, err := run.publish(context.Background()) |
| + if err != nil { |
| + fmt.Fprintln(os.Stderr, err) |
| + os.Exit(1) |
| + } |
| + fmt.Println(line) |
| +} |
diff --git a/internal/host/boxpublish_test.go b/internal/host/boxpublish_test.go| new file mode 100644 |
| index 000000000..ec6c80402 |
| --- /dev/null |
| +++ b/internal/host/boxpublish_test.go |
| @@ -0,0 +1,737 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "crypto/x509/pkix" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "math/big" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// carlos system box publish-record, through the production wiring: the |
| +// real S3Store (openDeployStoreEnv over CARLOS_DEPLOYMENT_BUCKET and |
| +// CARLOS_S3_ENDPOINT) against leasetest's S3 server, which enforces |
| +// If-None-Match: * itself, behind the dev bucket's box_records policy; |
| +// the real IMDS reader over fakeIMDS (boxrecord_test.go); and enrollment's |
| +// own verifier over documents a test AWS signs. |
| + |
| +const ( |
| + publishMachineID = "0123456789abcdef0123456789abcdef" |
| + publishBucket = "deploy" |
| + publishAgentUID = 999 |
| + publishAccount = "111122223333" |
| + publishRegion = "eu-west-1" |
| + publishInstance = "i-0abc" |
| +) |
| + |
| +// testAWS stands in for AWS's instance-identity signer the way boxid's |
| +// own tests do (boxid/internal/signtest, which this package cannot |
| +// import): one RSA key, a self-signed certificate for it, and PKCS #1 |
| +// v1.5 SHA-256 signatures over identity documents. |
| +type testAWS struct { |
| + key *rsa.PrivateKey |
| + cert *x509.Certificate |
| +} |
| + |
| +func newTestAWS(t testing.TB) *testAWS { |
| + t.Helper() |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"}, |
| + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} |
| + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cert, err := x509.ParseCertificate(der) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &testAWS{key: key, cert: cert} |
| +} |
| + |
| +// identityDoc is an identity document as IMDS serves one, with the two |
| +// fields that change over an instance's life. |
| +func identityDoc(account, region, instance, pendingTime, instanceType string) string { |
| + return "{\n \"accountId\" : \"" + account + "\",\n \"instanceId\" : \"" + instance + "\",\n \"instanceType\" : \"" + instanceType + |
| + "\",\n \"pendingTime\" : \"" + pendingTime + "\",\n \"region\" : \"" + region + "\"\n}\n" |
| +} |
| + |
| +func (a *testAWS) sign(t testing.TB, doc string) string { |
| + t.Helper() |
| + sum := sha256.Sum256([]byte(doc)) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(sig) |
| +} |
| + |
| +// certs certifies eu-west-1, this box's region, and eu-west-2, so a record |
| +// signed in another supported region verifies and is still refused. |
| +func (a *testAWS) certs() (map[string]*x509.Certificate, error) { |
| + return map[string]*x509.Certificate{"eu-west-1": a.cert, "eu-west-2": a.cert}, nil |
| +} |
| + |
| +func (a *testAWS) record(t testing.TB, box, doc, writtenAt string) boxid.Record { |
| + return boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString([]byte(doc)), |
| + IdentitySignature: a.sign(t, doc), WrittenAt: writtenAt} |
| +} |
| + |
| +// s3Request is one request the fake S3 saw. |
| +type s3Request struct{ Method, Key, IfNoneMatch, IfMatch string } |
| + |
| +// boxRecordsBucket is the fake deployment bucket: leasetest's versioned S3 |
| +// server, behind the dev bucket's deny-only box_records policy. Under |
| +// control/boxes/ a PUT without If-None-Match: * is AccessDenied |
| +// (BoxRecordsAreCreateOnly) and so is any DELETE |
| +// (BoxRecordsAreNeverDeleted). Every request is recorded. aws and doc |
| +// are the box's AWS and the identity document IMDS serves it now. |
| +// |
| +// Hooks, each used once: afterGet runs after the first GET has been |
| +// answered (a writer arriving between the verb's read and |
| +// publishBoxRecord's); beforePut runs before the first PUT reaches the |
| +// server (a writer arriving just before the create). commitThenFail lists |
| +// statuses for successive PUTs: each such PUT is applied and then answered |
| +// with that status instead, as when S3 commits a write whose answer is |
| +// lost. |
| +type boxRecordsBucket struct { |
| + model *leasetest.VersionedBucket |
| + mu sync.Mutex |
| + seen []s3Request |
| + aws *testAWS |
| + doc string |
| + |
| + afterGet func() |
| + beforePut func() |
| + commitThenFail []int |
| +} |
| + |
| +func (b *boxRecordsBucket) requests() []s3Request { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return append([]s3Request(nil), b.seen...) |
| +} |
| + |
| +func (b *boxRecordsBucket) handler() http.Handler { |
| + next, _ := leasetest.NewS3Server(b.model) |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + _, key, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") |
| + b.mu.Lock() |
| + b.seen = append(b.seen, s3Request{r.Method, key, r.Header.Get("If-None-Match"), r.Header.Get("If-Match")}) |
| + var afterGet, beforePut func() |
| + fail := 0 |
| + switch r.Method { |
| + case http.MethodGet: |
| + afterGet, b.afterGet = b.afterGet, nil |
| + case http.MethodPut: |
| + beforePut, b.beforePut = b.beforePut, nil |
| + if len(b.commitThenFail) > 0 { |
| + fail, b.commitThenFail = b.commitThenFail[0], b.commitThenFail[1:] |
| + } |
| + } |
| + b.mu.Unlock() |
| + if strings.HasPrefix(key, boxid.Prefix) { |
| + if r.Method == http.MethodDelete || (r.Method == http.MethodPut && r.Header.Get("If-None-Match") != "*") { |
| + s3Error(w, http.StatusForbidden, "AccessDenied") |
| + return |
| + } |
| + } |
| + if beforePut != nil { |
| + beforePut() |
| + } |
| + if fail != 0 { |
| + next.ServeHTTP(httptest.NewRecorder(), r) |
| + s3Error(w, fail, http.StatusText(fail)) |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + if afterGet != nil { |
| + afterGet() |
| + } |
| + }) |
| +} |
| + |
| +func s3Error(w http.ResponseWriter, status int, code string) { |
| + w.Header().Set("Content-Type", "application/xml") |
| + w.WriteHeader(status) |
| + _, _ = w.Write([]byte("<Error><Code>" + strings.ReplaceAll(code, " ", "") + "</Code><Message>" + code + "</Message></Error>")) |
| +} |
| + |
| +// publishFixture points the process's AWS and CARLOS_* environment at a |
| +// fresh fake bucket, as host.env would on a box, and makes the box's AWS. |
| +func publishFixture(t *testing.T) *boxRecordsBucket { |
| + t.Helper() |
| + b := &boxRecordsBucket{model: leasetest.NewVersionedBucket(), aws: newTestAWS(t), |
| + doc: identityDoc(publishAccount, publishRegion, publishInstance, "2026-10-06T08:00:00Z", "t4g.small")} |
| + b.model.AddBucket(publishBucket) |
| + server := httptest.NewServer(b.handler()) |
| + t.Cleanup(server.Close) |
| + for k, v := range map[string]string{ |
| + "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", |
| + "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true", |
| + "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", |
| + "CARLOS_S3_ENDPOINT": server.URL, "CARLOS_DEPLOYMENT_BUCKET": publishBucket, |
| + "CARLOS_DEPLOYMENT_DIR": "", "CARLOS_FLEET": "", "CARLOS_FLEET_BOX": "", "CARLOS_BOX_REFERENCE": "", |
| + "CARLOS_REGION_HA": "", |
| + } { |
| + t.Setenv(k, v) |
| + } |
| + return b |
| +} |
| + |
| +// imdsRead is the real IMDS reader over fakeIMDS serving b's current |
| +// document and AWS's signature over it. |
| +func (b *boxRecordsBucket) imdsRead(t *testing.T, written time.Time) func(string) (boxid.Record, error) { |
| + client := imdsClient(&fakeIMDS{doc: b.doc, sig: b.aws.sign(t, b.doc)}) |
| + return func(box string) (boxid.Record, error) { |
| + return readIdentityRecord(client, "http://imds.test", box, written) |
| + } |
| +} |
| + |
| +// publishRun is the verb's production wiring with the test's seams: the |
| +// agent user's uid, the fixed machine id, the fake IMDS and the test |
| +// AWS's certificates. |
| +func (b *boxRecordsBucket) publishRun(t *testing.T) boxPublishRun { |
| + return boxPublishRun{getenv: os.Getenv, geteuid: func() int { return publishAgentUID }, |
| + machineID: func() string { return publishMachineID }, open: openDeployStoreEnv, read: b.imdsRead(t, recordNow), |
| + certs: b.aws.certs} |
| +} |
| + |
| +func (b *boxRecordsBucket) run(t *testing.T) (string, error) { |
| + t.Helper() |
| + return b.publishRun(t).publish(context.Background()) |
| +} |
| + |
| +// thisBoxRecord is the record this box would publish now, written at |
| +// writtenAt. |
| +func (b *boxRecordsBucket) thisBoxRecord(t *testing.T, writtenAt string) boxid.Record { |
| + return b.aws.record(t, status.Label(publishMachineID), b.doc, writtenAt) |
| +} |
| + |
| +// assertCreateOnly: no DELETE, no request off this box's key, and every |
| +// PUT create-only. Overwrites would also have been denied by the fake's |
| +// policy, but the verb must never even ask. |
| +func assertCreateOnly(t *testing.T, reqs []s3Request) (puts int) { |
| + t.Helper() |
| + key := boxid.Key(status.Label(publishMachineID)) |
| + for _, r := range reqs { |
| + if r.Key != key { |
| + t.Errorf("%s %s: publish-record touched another key", r.Method, r.Key) |
| + } |
| + switch r.Method { |
| + case http.MethodPut: |
| + puts++ |
| + if r.IfNoneMatch != "*" || r.IfMatch != "" { |
| + t.Errorf("PUT %s with If-None-Match %q If-Match %q; want create-only", r.Key, r.IfNoneMatch, r.IfMatch) |
| + } |
| + case http.MethodGet: |
| + default: |
| + t.Errorf("publish-record sent %s %s", r.Method, r.Key) |
| + } |
| + } |
| + return puts |
| +} |
| + |
| +func inPlaceLine(box string) string { |
| + return "the record at control/boxes/" + box + ".json is valid AWS-signed evidence for " + box + |
| + " (instance i-0abc), so nothing was written. enrollment still makes its own checks before it accepts the box" |
| +} |
| + |
| +func TestPublishRecordWritesAFreshBoxsRecord(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + raw, ok := b.model.Current(publishBucket, "control/boxes/"+box+".json") |
| + if !ok { |
| + t.Fatal("no record at control/boxes/<box>.json") |
| + } |
| + var rec map[string]any |
| + if err := json.Unmarshal(raw, &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := b.thisBoxRecord(t, "2026-10-06T09:00:00Z") |
| + if len(rec) != 5 || rec["v"] != float64(1) || rec["box"] != box || rec["identity_document"] != want.IdentityDocument || |
| + rec["written_at"] != "2026-10-06T09:00:00Z" { |
| + t.Fatalf("record = %v", rec) |
| + } |
| + // The record the verb wrote is evidence enrollment accepts. |
| + certs, _ := b.aws.certs() |
| + if _, err := (boxid.Verifier{Account: publishAccount, Certs: certs}).Enroll(raw, boxid.Expect{Box: box, AWSRegion: publishRegion}); err != nil { |
| + t.Fatalf("the published record does not enroll: %v", err) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 1 { |
| + t.Fatalf("%d PUTs, want one", puts) |
| + } |
| +} |
| + |
| +// R3: there is no region HA gate. Run after the loop has published (its |
| +// publishBoxRecord, store, label and IMDS read, an hour earlier), the |
| +// verb keeps that record and changes nothing. |
| +func TestPublishRecordAfterTheLoopIsANoOp(t *testing.T) { |
| + b := publishFixture(t) |
| + ctx := context.Background() |
| + store, err := openDeployStoreEnv(ctx, os.Getenv) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + box := metricsBoxLabel(os.Getenv, publishMachineID) |
| + loopRead := b.imdsRead(t, recordNow.Add(-time.Hour)) |
| + if done, err := publishBoxRecord(ctx, store, box, func() (boxid.Record, error) { return loopRead(box) }); !done || err != nil { |
| + t.Fatalf("the loop's publish: done=%v err=%v", done, err) |
| + } |
| + before, _ := b.model.Current(publishBucket, boxid.Key(box)) |
| + seenBefore := len(b.requests()) |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := inPlaceLine(box); line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { |
| + t.Fatalf("the loop's record was rewritten:\n%s\n%s", before, after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()[seenBefore:]); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| +} |
| + |
| +// Astra, round 2: an older record AWS signed for this same instance, in |
| +// this account and region, is evidence enrollment accepts, although its |
| +// document's mutable fields have changed since (a stop and start, a |
| +// resize). It is kept byte for byte, never replaced, and nothing is |
| +// written. |
| +func TestPublishRecordKeepsAnOlderValidRecord(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + older := identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro") |
| + if older == b.doc { |
| + t.Fatal("the two documents are the same") |
| + } |
| + before := mustJSON(t, b.aws.record(t, box, older, "2026-09-01T00:05:00Z")) |
| + b.model.Put(publishBucket, boxid.Key(box), before) |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := inPlaceLine(box); line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { |
| + t.Fatalf("the older record was replaced:\n%s\n%s", before, after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| +} |
| + |
| +// Anything enrollment's verifier refuses is refused, named, and left |
| +// alone. |
| +func TestPublishRecordRefusesWhatEnrollmentWouldRefuse(t *testing.T) { |
| + box := status.Label(publishMachineID) |
| + prefix := "refused: the record already under " + box + " is not valid evidence for this box: " |
| + type held struct { |
| + box, doc string |
| + forger bool // signed by another key |
| + edit func(*boxid.Record) |
| + raw func([]byte) []byte |
| + } |
| + cases := map[string]struct { |
| + h held |
| + want string |
| + }{ |
| + "different account": {held{doc: identityDoc("999999999999", publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for account 999999999999, and this box is in account 111122223333"}, |
| + "different region": {held{doc: identityDoc(publishAccount, "eu-west-2", publishInstance, "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed in region eu-west-2, and this box is in eu-west-1"}, |
| + "another instance": {held{doc: identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-09-01T00:00:00Z", "t4g.small")}, "it is signed for instance i-0zzz, and this box is i-0abc"}, |
| + "bad signature": {held{forger: true}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "missing signature": {held{edit: func(r *boxid.Record) { r.IdentitySignature = "" }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "altered document": {held{edit: func(r *boxid.Record) { |
| + r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte(strings.Replace(identityDocFor(r), "t4g.small", "m7g.large", 1))) |
| + }}, "it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error"}, |
| + "different box": {held{box: "bert-sawing"}, `it is for box "bert-sawing"`}, |
| + "version 2": {held{edit: func(r *boxid.Record) { r.V = 2 }}, "its version is 2, not 1"}, |
| + "unknown field": {held{raw: func(b []byte) []byte { return append(bytes.TrimSuffix(b, []byte("}")), []byte(`,"extra":1}`)...) }}, "it cannot be read as an identity record"}, |
| + "trailing data": {held{raw: func(b []byte) []byte { return append(b, []byte(`{}`)...) }}, "it cannot be read as an identity record"}, |
| + "not json": {held{raw: func([]byte) []byte { return []byte("not json") }}, "it cannot be read as an identity record"}, |
| + "document not json": {held{edit: func(r *boxid.Record) { r.IdentityDocument = base64.StdEncoding.EncodeToString([]byte("nope")) }}, "it cannot be read as an identity record"}, |
| + } |
| + for name, tc := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + b := publishFixture(t) |
| + signer, doc, hbox := b.aws, b.doc, box |
| + if tc.h.forger { |
| + signer = newTestAWS(t) |
| + } |
| + if tc.h.doc != "" { |
| + doc = tc.h.doc |
| + } |
| + if tc.h.box != "" { |
| + hbox = tc.h.box |
| + } |
| + rec := signer.record(t, hbox, doc, "2026-09-01T00:05:00Z") |
| + if tc.h.edit != nil { |
| + tc.h.edit(&rec) |
| + } |
| + before := mustJSON(t, rec) |
| + if tc.h.raw != nil { |
| + before = tc.h.raw(before) |
| + } |
| + b.model.Put(publishBucket, boxid.Key(box), before) |
| + line, err := b.run(t) |
| + if want := prefix + tc.want + ". nothing was changed"; err == nil || err.Error() != want { |
| + t.Fatalf("line=%q err=%v\nwant %q", line, err, want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(before) { |
| + t.Fatalf("the record was changed:\n%s\n%s", before, after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// identityDocFor is r's decoded identity document. |
| +func identityDocFor(r *boxid.Record) string { |
| + doc, _ := base64.StdEncoding.DecodeString(r.IdentityDocument) |
| + return string(doc) |
| +} |
| + |
| +// A record that arrives after the verb's read and before |
| +// publishBoxRecord's is held to the same verifier: publishBoxRecord alone |
| +// would take any record naming this instance. |
| +func TestPublishRecordHoldsALateRecordToTheSameVerifier(t *testing.T) { |
| + box := status.Label(publishMachineID) |
| + for name, tc := range map[string]struct { |
| + forged bool |
| + want string |
| + }{ |
| + "valid, older": {false, inPlaceLine(box)}, |
| + "same instance, forged signature": {true, "refused: the record already under " + box + " is not valid evidence for this box: it does not verify as AWS-signed evidence: signature: crypto/rsa: verification error. nothing was changed"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + b := publishFixture(t) |
| + signer := b.aws |
| + if tc.forged { |
| + signer = newTestAWS(t) |
| + } |
| + late := mustJSON(t, signer.record(t, box, identityDoc(publishAccount, publishRegion, publishInstance, "2026-09-01T00:00:00Z", "t4g.micro"), "2026-09-01T00:05:00Z")) |
| + b.afterGet = func() { b.model.Put(publishBucket, boxid.Key(box), late) } |
| + line, err := b.run(t) |
| + got := line |
| + if err != nil { |
| + got = err.Error() |
| + } |
| + if got != tc.want || (err != nil) != tc.forged { |
| + t.Fatalf("line=%q err=%v\nwant %q", line, err, tc.want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(late) { |
| + t.Fatalf("the late record was changed:\n%s", after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The conditional create is the bucket's: a record another instance |
| +// creates just before the verb's write is not overwritten, because the |
| +// fake answers the If-None-Match: * PUT with 412. |
| +func TestPublishRecordLosesARaceWithoutOverwriting(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + racer := mustJSON(t, b.aws.record(t, box, identityDoc(publishAccount, publishRegion, "i-0zzz", "2026-10-06T08:00:00Z", "t4g.small"), "2026-10-01T00:00:00Z")) |
| + b.beforePut = func() { b.model.Put(publishBucket, boxid.Key(box), racer) } |
| + want := "refused: another record already holds " + box + ": boxid: " + box + " already has a different record: store: compare-and-swap conflict. nothing was changed" |
| + if line, err := b.run(t); err == nil || err.Error() != want { |
| + t.Fatalf("line=%q err=%v\nwant %q", line, err, want) |
| + } |
| + if after, _ := b.model.Current(publishBucket, boxid.Key(box)); string(after) != string(racer) { |
| + t.Fatalf("the racing record was overwritten:\n%s", after) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 1 { |
| + t.Fatalf("%d PUTs, want the one conditional create", puts) |
| + } |
| +} |
| + |
| +// S3 commits the create but its answer is lost (500). The SDK retries, |
| +// the retry is answered 412, and boxid.Publish finds exactly this run's |
| +// bytes. That is this run's record: published, although the store never |
| +// saw a successful Put. |
| +func TestPublishRecordReportsARetriedCreateAsPublished(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + b.commitThenFail = []int{http.StatusInternalServerError} |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := "published the identity record for " + box + " (instance i-0abc) to control/boxes/" + box + ".json"; line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + got, _ := b.model.Current(publishBucket, boxid.Key(box)) |
| + var rec boxid.Record |
| + if json.Unmarshal(got, &rec) != nil || rec.WrittenAt != "2026-10-06T09:00:00Z" || rec.IdentityDocument != base64.StdEncoding.EncodeToString([]byte(b.doc)) { |
| + t.Fatalf("stored %s", got) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 2 { |
| + t.Fatalf("%d PUTs, want the create and its retry", puts) |
| + } |
| +} |
| + |
| +// Once a create has reached the bucket, a failure does not claim nothing |
| +// was written. Here S3 committed it and answered 400. |
| +func TestPublishRecordSaysAnUncertainWriteMayHaveHappened(t *testing.T) { |
| + b := publishFixture(t) |
| + box := status.Label(publishMachineID) |
| + b.commitThenFail = []int{http.StatusBadRequest} |
| + _, err := b.run(t) |
| + if err == nil || !strings.HasPrefix(err.Error(), "the identity record for "+box+" may or may not have been written: ") || |
| + !strings.HasSuffix(err.Error(), ". it is safe to run again, which only ever creates the record and says what is there") { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if _, ok := b.model.Current(publishBucket, boxid.Key(box)); !ok { |
| + t.Fatal("the fake did not commit the create") |
| + } |
| + // The second run finds valid evidence and says so. |
| + if line, err := b.run(t); err != nil || line != inPlaceLine(box) { |
| + t.Fatalf("second run: line=%q err=%v", line, err) |
| + } |
| +} |
| + |
| +// Failures before any create reached the bucket say nothing was written. |
| +func TestPublishRecordReportsFailuresBeforeAnyWriteAsRetryable(t *testing.T) { |
| + box := status.Label(publishMachineID) |
| + for name, tc := range map[string]struct { |
| + imdsDown bool |
| + doc string // the document IMDS serves, when not the usual one |
| + reason string |
| + }{ |
| + "IMDS down": {imdsDown: true, reason: "IMDS down"}, |
| + "own identity incomplete": {doc: "{\"accountId\" : \"111122223333\", \"region\" : \"eu-west-1\"}\n", reason: "the identity document AWS served this box names no account, region or instance"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + b := publishFixture(t) |
| + if tc.doc != "" { |
| + b.doc = tc.doc |
| + } |
| + run := b.publishRun(t) |
| + if tc.imdsDown { |
| + run.read = func(string) (boxid.Record, error) { return boxid.Record{}, errors.New("IMDS down") } |
| + } |
| + _, err := run.publish(context.Background()) |
| + want := "the identity record for " + box + " was not published: " + tc.reason + ". nothing was written, so it is safe to run again" |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err = %v\nwant %q", err, want) |
| + } |
| + if puts := assertCreateOnly(t, b.requests()); puts != 0 { |
| + t.Fatalf("%d PUTs, want none", puts) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// refusedEarly runs the verb with every later seam tripwired: it must |
| +// refuse before the machine id, the store, IMDS or the certificates are |
| +// touched. |
| +func refusedEarly(t *testing.T, b *boxRecordsBucket, run boxPublishRun, want string) { |
| + t.Helper() |
| + touched := []string{} |
| + run.machineID = func() string { touched = append(touched, "machine id"); return publishMachineID } |
| + run.open = func(context.Context, func(string) string) (release.Store, error) { |
| + touched = append(touched, "store") |
| + return nil, errors.New("opened") |
| + } |
| + run.read = func(string) (boxid.Record, error) { touched = append(touched, "IMDS"); return boxid.Record{}, nil } |
| + run.certs = func() (map[string]*x509.Certificate, error) { touched = append(touched, "certs"); return nil, nil } |
| + _, err := run.publish(context.Background()) |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("err = %v\nwant %q", err, want) |
| + } |
| + if len(touched) != 0 || len(b.requests()) != 0 { |
| + t.Fatalf("read %v and sent %d requests before refusing", touched, len(b.requests())) |
| + } |
| +} |
| + |
| +// Census ruling R1: root is refused before any store, IMDS or host read. |
| +func TestPublishRecordRefusesRoot(t *testing.T) { |
| + b := publishFixture(t) |
| + run := b.publishRun(t) |
| + run.geteuid = func() int { return 0 } |
| + refusedEarly(t, b, run, "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos.") |
| +} |
| + |
| +func TestPublishRecordRefusesAFleetBox(t *testing.T) { |
| + b := publishFixture(t) |
| + t.Setenv("CARLOS_FLEET", "acme") |
| + t.Setenv("CARLOS_FLEET_BOX", "kass-1") |
| + refusedEarly(t, b, b.publishRun(t), boxRecordFleetRefusal) |
| +} |
| + |
| +// A deployment directory cannot refuse an overwrite (DirStore.Put checks, |
| +// then renames), so only an S3 bucket will do. |
| +func TestPublishRecordRequiresAnS3Bucket(t *testing.T) { |
| + b := publishFixture(t) |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + refusedEarly(t, b, b.publishRun(t), boxRecordNoBucket) |
| + if entries, _ := os.ReadDir(dir); len(entries) != 0 { |
| + t.Fatalf("the deployment directory was written: %v", entries) |
| + } |
| + // Belt and braces: a store that is not S3 is refused even with a |
| + // bucket named. |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", publishBucket) |
| + run := b.publishRun(t) |
| + run.open = func(context.Context, func(string) string) (release.Store, error) { |
| + return release.NewDirStore(dir), nil |
| + } |
| + if _, err := run.publish(context.Background()); err == nil || err.Error() != boxRecordNoBucket { |
| + t.Fatalf("err = %v", err) |
| + } |
| + if entries, _ := os.ReadDir(dir); len(entries) != 0 { |
| + t.Fatalf("the deployment directory was written: %v", entries) |
| + } |
| +} |
| + |
| +// A box its region's group launched publishes under its group reference, |
| +// the label boxRecordLoop takes (metricsBoxLabel). |
| +func TestPublishRecordUsesTheGroupReference(t *testing.T) { |
| + b := publishFixture(t) |
| + t.Setenv("CARLOS_BOX_REFERENCE", "group") |
| + withGroupIdentity(t, publishAccount, publishInstance, nil) |
| + ref := hostactors.GroupBoxReference(publishAccount, publishInstance) |
| + if loop := metricsBoxLabel(os.Getenv, publishMachineID); loop != ref { |
| + t.Fatalf("the loop's label is %q, want the group reference %q", loop, ref) |
| + } |
| + line, err := b.run(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if want := "published the identity record for " + ref + " (instance i-0abc) to control/boxes/" + ref + ".json"; line != want { |
| + t.Fatalf("line = %q\nwant %q", line, want) |
| + } |
| + raw, ok := b.model.Current(publishBucket, boxid.Key(ref)) |
| + var rec boxid.Record |
| + if !ok || json.Unmarshal(raw, &rec) != nil || rec.Box != ref { |
| + t.Fatalf("record under the group reference: %s", raw) |
| + } |
| + if _, ok := b.model.Current(publishBucket, boxid.Key(status.Label(publishMachineID))); ok { |
| + t.Fatal("a group box published under its machine-id label") |
| + } |
| +} |
| + |
| +// The verb's certificates are enrollment's: boxid.EmbeddedCerts covers |
| +// the dev region, so a real box's record can verify. |
| +func TestPublishRecordProductionCertsCoverTheDevRegion(t *testing.T) { |
| + certs, err := boxid.EmbeddedCerts() |
| + if err != nil || certs[publishRegion] == nil { |
| + t.Fatalf("embedded certs: %v (eu-west-1 present: %v)", err, certs[publishRegion] != nil) |
| + } |
| +} |
| + |
| +// Census ruling R4: the store publish-record hands publishBoxRecord sends |
| +// the bucket nothing for a delete, a list, an overwrite or any other key. |
| +func TestBoxRecordStoreOnlyReadsAndCreatesItsOwnKey(t *testing.T) { |
| + b := publishFixture(t) |
| + ctx := context.Background() |
| + inner, err := openDeployStoreEnv(ctx, os.Getenv) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := inner.(*release.S3Store); !ok { |
| + t.Fatalf("store is %T, want the S3Store", inner) |
| + } |
| + s := &boxRecordStore{inner: inner, key: boxid.Key("annie-welding")} |
| + if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), `"etag"`); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("overwrite (replace-only Put): %v", err) |
| + } |
| + if _, err := s.Put(ctx, boxid.Key("bert-sawing"), []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("Put on another box's key: %v", err) |
| + } |
| + if _, err := s.Put(ctx, "control/serial", []byte("{}"), ""); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("Put on another key: %v", err) |
| + } |
| + if _, _, err := s.Get(ctx, boxid.Key("bert-sawing")); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("Get on another box's key: %v", err) |
| + } |
| + if err := s.Delete(ctx, boxid.Key("annie-welding")); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("Delete: %v", err) |
| + } |
| + if _, err := s.List(ctx, boxid.Prefix); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("List: %v", err) |
| + } |
| + if _, err := s.ListDirs(ctx, "control/"); !errors.Is(err, errBoxRecordStore) { |
| + t.Errorf("ListDirs: %v", err) |
| + } |
| + if reqs := b.requests(); len(reqs) != 0 || s.attempted || s.wrote { |
| + t.Fatalf("refused calls reached S3: %v (attempted=%v wrote=%v)", reqs, s.attempted, s.wrote) |
| + } |
| + if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); err != nil || !s.wrote { |
| + t.Fatalf("create: err=%v wrote=%v", err, s.wrote) |
| + } |
| + if _, err := s.Put(ctx, boxid.Key("annie-welding"), []byte("{}"), ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("a second create: %v, want the bucket's 412 as ErrCASConflict", err) |
| + } |
| + if reqs := b.requests(); len(reqs) != 2 || reqs[0].IfNoneMatch != "*" || reqs[1].IfNoneMatch != "*" { |
| + t.Fatalf("requests %v, want two create-only PUTs", reqs) |
| + } |
| +} |
| + |
| +// boxRecordLoop's gate is unchanged: with CARLOS_REGION_HA off, or on a |
| +// fleet box, it returns at once and never reaches the bucket. Past the |
| +// gate it would retry each minute, so a broken gate shows as a timeout. |
| +func TestBoxRecordLoopGateIsUnchanged(t *testing.T) { |
| + for name, env := range map[string]map[string]string{ |
| + "region HA off": {"CARLOS_REGION_HA": ""}, |
| + "region HA unparseable": {"CARLOS_REGION_HA": "yes"}, |
| + "fleet box, region HA on": {"CARLOS_REGION_HA": "1", "CARLOS_FLEET": "acme", "CARLOS_FLEET_BOX": "kass-1"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + b := publishFixture(t) |
| + for k, v := range env { |
| + t.Setenv(k, v) |
| + } |
| + done := make(chan struct{}) |
| + go func() { boxRecordLoop(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("boxRecordLoop did not return: its region HA gate let it run") |
| + } |
| + if n := len(b.requests()); n != 0 { |
| + t.Fatalf("boxRecordLoop sent %d requests with its gate shut", n) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/provision/box_publish_record_census_test.go b/internal/provision/box_publish_record_census_test.go| new file mode 100644 |
| index 000000000..107a68afe |
| --- /dev/null |
| +++ b/internal/provision/box_publish_record_census_test.go |
| @@ -0,0 +1,18 @@ |
| +package provision |
| + |
| +// plan14BoxPublishRecordWriteCensusInventory is the record-write inventory |
| +// with carlos system box publish-record's one write site, under |
| +// census-ruling-box-publish-record (platform-1e, R1-R4; |
| +// docs/superpowers/notes/census-proposal-box-publish-record.md). |
| +// |
| +// boxRecordStore.Put is the store publish-record hands publishBoxRecord. It |
| +// writes control/boxes/<box>.json, a box's identity record, never an |
| +// instance or sidecar record, and only create-only: a Put on any other key, |
| +// or with an etag, is refused before the store. Hence nonrecord, on R4's |
| +// condition that boxRecordStore is the only store the verb hands |
| +// publishBoxRecord. |
| +func plan14BoxPublishRecordWriteCensusInventory() []censusEntry { |
| + return append(plan14ActivationToolsWriteCensusInventory(), censusEntry{ |
| + "internal/host/boxpublish.go", "boxRecordStore.Put", "nonrecord", "def:Put,sel:Put", "866978d6c6786a351bbeaf6f9e5382df0b3fd08cbb47aed0c01738f7c5120d7a", |
| + }) |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index a1baddea9..397cfddf4 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -827,6 +827,14 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "fc584e113d0c6786bdeb3344354ae567c112c607e68f85056352545557a91b5d" |
| + // Census overlay box-publish-record (carlos system box publish-record), |
| + // loaded last, under census-ruling-box-publish-record (platform-1e, |
| + // R1-R4): root unreachable, the verb refuses euid 0 (R1). Envelope-only: |
| + // it re-pins the internal/host and root package envelopes, adds and |
| + // changes no root-read unit, and the seal stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-box-publish-record.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/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 6f71d60e4..f66dd1740 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := plan14ActivationToolsWriteCensusInventory() |
| + inventory := plan14BoxPublishRecordWriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv| new file mode 100644 |
| index 000000000..1b23d566f |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-box-publish-record.tsv |
| @@ -0,0 +1,7 @@ |
| +# Plan 14 census overlay box-publish-record (carlos system box publish-record, approved by Paul; owner platform-1e as the host claim's owner), loaded last, after region-ha-2b. RULED by platform-1e as census-ruling-box-publish-record (R1-R4); the ruling and its reasons are docs/superpowers/notes/census-proposal-box-publish-record.md. |
| +# root unreachable: verb refuses euid 0 (R1). boxPublishRun.publish's first check is the effective uid, before any store, IMDS or host read (the machine id included), so root never reaches publishBoxRecord through this verb. The one supported way to run it is systemd-run --wait --pipe -p EnvironmentFile=/etc/carlos/host.env -p User=carlos /opt/carlos/carlos system box publish-record (R2). It has no region HA gate, and run after the loop has published it keeps that record and writes nothing (R3). boxRecordStore is the only store it hands publishBoxRecord, and it sends the bucket nothing for a delete, a list, an overwrite or any other key (R4). |
| +# internal/host boxpublish.go: one publishBoxRecord attempt, without boxRecordLoop's CARLOS_REGION_HA gate. It refuses a fleet box (shouldSkipPublisherElection) and any box without an S3 deployment bucket (CARLOS_DEPLOYMENT_BUCKET unset, or a store that is not release.S3Store: a deployment directory's check-then-rename cannot refuse an overwrite). It reads the record already under control/boxes/<box>.json through boxRecordStore and keeps it, bytes untouched, only when enrollment's own verifier accepts it (heldEvidence: boxid.Verifier.Enroll with the account, AWS region and instance id, as TofuInstanceID, from this box's current IMDS identity, and boxid.EmbeddedCerts, the certificates enrollment uses; no freshness check, so an older document whose pendingTime or instanceType has since changed is kept); with nothing there it calls publishBoxRecord through boxRecordStore, which admits only Get and create-only Put (If-None-Match: *) on that key. Its reads are existing units, unchanged: metricsBoxLabel and readMachineID for the label, openDeployStoreEnv for the store, readIdentityRecord and imdsGetRaw (accepted-legacy) for IMDS, publishBoxRecord and boxid.Load for the record, and boxid.EmbeddedCerts (accepted-legacy, J4, root-input; its reads are the binary's embedded files, and internal/host already imported it through claimproduction.go, so the import graph gains no package) for the certificates. Verifier.Enroll has no read site. The scan finds no read site in the new units, so no root-read unit is added or changed. boxrecord.go is not edited, and boxRecordLoop's gate is pinned by TestBoxRecordLoopGateIsUnchanged. |
| +# Root package (system_roles.go, system.go): runSystemBoxVerb hands `box publish-record` to host.RunSystemBoxPublishRecord before roleArgs; systemUsage gains its synopsis and description lines. No root read is added. |
| +# Envelope-only: the internal/host and root package envelopes are re-pinned; the legacy seal stands at fc584e11. The record-write census (9B8) gains internal/host/boxpublish.go:boxRecordStore.Put, nonrecord under R4 (box_publish_record_census_test.go). |
| +@coverage . d123dce752a02f9db9b87f591d8efab5c37148caff628f596968b2df3b875402 |
| +@coverage internal/host b1fb4b8431cb790bd17f8d8016e5d9b34fa93527e1fc0c464130401780137ab3 |
diff --git a/system.go b/system.go| index 7e0f61187..1828c1f5a 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -141,6 +141,7 @@ func systemUsage() { |
| carlos system seeds-floor publish --min-release <release> |
| carlos system legacy-routes assign --name <name> --to <account>/<app> |
| carlos system box role <box> <default|legacy|platform> |
| + carlos system box publish-record |
| carlos system group role <region> <default|legacy> |
| carlos system host-env <write|seed> --deployment <name> --region <code> [flags] |
| @@ -165,6 +166,10 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| group role <region> <default|legacy> |
| the same for every box a region's group has enrolled, and for |
| every box it enrolls later |
| + box publish-record |
| + run on a box as the agent user, with systemd-run -p User=carlos: |
| + publish its AWS-signed identity record to the deployment |
| + bucket, once. It never replaces or deletes a record |
| A box a region's group launches (region HA): |
diff --git a/system_roles.go b/system_roles.go| index 487d52bc5..c6b7c6e6f 100644 |
| --- a/system_roles.go |
| +++ b/system_roles.go |
| @@ -8,6 +8,8 @@ import ( |
| "net/http" |
| "net/url" |
| "os" |
| + |
| + "github.com/carlosframework/platform/internal/host" |
| ) |
| // carlos system box role <box> <role> and carlos system group role |
| @@ -94,6 +96,12 @@ func roleArgs(rest []string) (name, role, console string) { |
| } |
| func runSystemBoxVerb(rest []string) { |
| + if len(rest) > 0 && rest[0] == "publish-record" { |
| + // On the box itself, not through the console (internal/host |
| + // boxpublish.go). |
| + host.RunSystemBoxPublishRecord(rest[1:]) |
| + return |
| + } |
| box, role, consoleFlag := roleArgs(rest) |
| sess := requireDeployAPISession(consoleFlag) |
| printUsing(stdout, sess) |
diff --git a/system_roles_test.go b/system_roles_test.go| index f8fc0383f..4ac17417d 100644 |
| --- a/system_roles_test.go |
| +++ b/system_roles_test.go |
| @@ -1,10 +1,14 @@ |
| package main |
| import ( |
| + "bytes" |
| "context" |
| "errors" |
| "net/http" |
| + "os" |
| + "os/exec" |
| "reflect" |
| + "strings" |
| "testing" |
| ) |
| @@ -17,6 +21,36 @@ func TestSystemVerbKnowsBoxAndGroup(t *testing.T) { |
| } |
| } |
| +// carlos system box publish-record reaches the box verb, not the console |
| +// role call: run in a child on a fleet box, it prints the fleet refusal |
| +// (or, as root, the root refusal) and exits 1 before any store, console |
| +// or IMDS is touched. |
| +func TestSystemBoxPublishRecordDispatch(t *testing.T) { |
| + if os.Getenv("CARLOS_TEST_PUBLISH_RECORD_CHILD") == "1" { |
| + runSystem([]string{"box", "publish-record"}) |
| + os.Exit(0) |
| + } |
| + cmd := exec.Command(os.Args[0], "-test.run=^TestSystemBoxPublishRecordDispatch$") |
| + scratch := t.TempDir() |
| + cmd.Dir = scratch |
| + cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + scratch, "CARLOS_TEST_PUBLISH_RECORD_CHILD=1", |
| + "CARLOS_FLEET=acme", "CARLOS_FLEET_BOX=kass-1"} |
| + var stdout, stderr bytes.Buffer |
| + cmd.Stdout, cmd.Stderr = &stdout, &stderr |
| + err := cmd.Run() |
| + var exit *exec.ExitError |
| + if !errors.As(err, &exit) || exit.ExitCode() != 1 { |
| + t.Fatalf("exit: %v; stdout %q stderr %q", err, stdout.String(), stderr.String()) |
| + } |
| + want := "this is a fleet box. fleet boxes publish no identity record, so nothing was written" |
| + if os.Geteuid() == 0 { // root is refused first (census ruling R1) |
| + want = "carlos system box publish-record runs as the agent user, not root. run it with systemd-run -p User=carlos." |
| + } |
| + if got := strings.TrimSpace(stderr.String()); got != want { |
| + t.Fatalf("stderr = %q", got) |
| + } |
| +} |
| + |
| // fakeRolePost answers one role call with status and body, recording what |
| // was asked. |
| type fakeRolePost struct { |