Minted platform apexes carry their app's gate; layout report stops calling them external
The domains pass mints <app>.<sqid>.<apps-domain> by copying the first ready route's socket, but copied no Gate. An app whose instances were all password-gated therefore served the first one's process at its apex to anyone, and `carlos gate set -host <apex>` could not reach it (no record behind a minted row; a deleted row is re-minted). Seen live on jelly-demo, where the apex served a gated demo's /dev/mail sign-in links unauthenticated. The minted route now takes the gate of the route it serves from (same Socket, Addr, or static Channel), falling back to the first gate any other route of the app carries, so a mixed gated/ungated app fails closed. The fallback is a real hash rather than a "closed" sentinel because the gate cookie key is derived from the hash alone; a public value would be a key anyone could mint cookies with. It is set at mint and converged on every pass with SetGate (Upsert can never clear a gate), so set, change and clear on the source all reach the apex, on the domains loop's cadence. The layout report classified such a row as "external" (an unflagged socket route) and raised the mixed-layout warning on boxes where every process was nested. A Platform row proxying a socket another row of its app serves now reports the new word "platform", which layoutMixed and the downgrade guard already ignore on old consoles. The root read census is updated with overlay plan14-root-reads-platform-apex-gate (proposed; the ruling is to be asked of the census owner on #280): domains.Reconcile is re-frozen at its seeded-instances disposition with the gate stamping and SetGate convergence appended, the new platformGate and servesSame are accepted legacy at their siblings' J1 root-route disposition, and the new mintedAliasOf is diagnostic H3 with the two layout reports that call it. Every read is of registry Route rows the agent itself wrote; the seal moves.
11 files changed,
+538
−8
docs/site/cli.md+6 −2internal/console/apistatuslayout.go+3 −1internal/console/apistatuslayout_test.go+79 −0internal/domains/reconcile.go+82 −0internal/domains/reconcile_test.go+144 −0internal/edge/gate_test.go+76 −0internal/host/agent_layout_test.go+66 −0internal/host/layoutleg.go+41 −0internal/provision/plan14_root_reads_test.go+18 −0internal/provision/testdata/plan14-root-reads-platform-apex-gate.tsv+16 −0internal/status/snapshot.go+7 −5
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (4 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (21 s)
=== gofmt (0 s)
gofmt -l .: clean
=== rest (172 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.781s
ok github.com/carlosframework/platform/cmd/moveprobe 12.009s
ok github.com/carlosframework/platform/cmd/zddprobe 0.785s
ok github.com/carlosframework/platform/internal/activator 58.703s
ok github.com/carlosframework/platform/internal/agentsock 0.044s
ok github.com/carlosframework/platform/internal/alerting 0.008s
ok github.com/carlosframework/platform/internal/analytics 0.026s
ok github.com/carlosframework/platform/internal/appconfig 3.046s
ok github.com/carlosframework/platform/internal/apperr 0.130s
ok github.com/carlosframework/platform/internal/applog 0.257s
ok github.com/carlosframework/platform/internal/awsinv 0.018s
ok github.com/carlosframework/platform/internal/boxchoice 0.008s
ok github.com/carlosframework/platform/internal/boxid 0.931s
? 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.009s
ok github.com/carlosframework/platform/internal/caps 0.030s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.847s
ok github.com/carlosframework/platform/internal/changelog 0.636s
ok github.com/carlosframework/platform/internal/claimactivation 1.334s
ok github.com/carlosframework/platform/internal/clamd 0.062s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? 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 7.674s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.031s
ok github.com/carlosframework/platform/internal/dbquery 0.014s
ok github.com/carlosframework/platform/internal/dbreads 2.118s
? github.com/carlosframework/platform/internal/dbreads/dbreadstest [no test files]
ok github.com/carlosframework/platform/internal/dbverify 0.023s
ok github.com/carlosframework/platform/internal/dnsrec 0.010s
ok github.com/carlosframework/platform/internal/docsite 0.012s
ok github.com/carlosframework/platform/internal/domains 3.221s
ok github.com/carlosframework/platform/internal/doorkey 0.015s
ok github.com/carlosframework/platform/internal/economics 0.008s
ok github.com/carlosframework/platform/internal/edge 45.845s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.136s
ok github.com/carlosframework/platform/internal/gate 1.660s
ok github.com/carlosframework/platform/internal/genselect 0.020s
ok github.com/carlosframework/platform/internal/geo 0.010s
ok github.com/carlosframework/platform/internal/geoip 0.030s
ok github.com/carlosframework/platform/internal/historyreadback 0.159s
ok github.com/carlosframework/platform/internal/host 141.297s
ok github.com/carlosframework/platform/internal/hostactors 6.619s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.005s
ok github.com/carlosframework/platform/internal/hostbox 3.097s
ok github.com/carlosframework/platform/internal/hostclaim 0.786s
ok github.com/carlosframework/platform/internal/hostenv 0.006s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 5.356s
ok github.com/carlosframework/platform/internal/hostfence 0.036s
ok github.com/carlosframework/platform/internal/hostfiles 10.366s
ok github.com/carlosframework/platform/internal/hostfloor 0.016s
ok github.com/carlosframework/platform/internal/hostguard 1.035s
ok github.com/carlosframework/platform/internal/hostops 1.937s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.004s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostrelocate 0.604s
ok github.com/carlosframework/platform/internal/hostresolve 0.209s
ok github.com/carlosframework/platform/internal/hostwrite 0.006s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 5.231s
ok github.com/carlosframework/platform/internal/instanceexec 2.796s
ok github.com/carlosframework/platform/internal/instancefs 0.007s
ok github.com/carlosframework/platform/internal/instancekey 0.006s
ok github.com/carlosframework/platform/internal/instanceupdates 0.019s
ok github.com/carlosframework/platform/internal/layout 1.706s
ok github.com/carlosframework/platform/internal/lbhealth 0.014s
ok github.com/carlosframework/platform/internal/lease 10.184s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.563s
ok github.com/carlosframework/platform/internal/ledger 0.026s
ok github.com/carlosframework/platform/internal/mail 0.250s
ok github.com/carlosframework/platform/internal/maintenance 0.085s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.594s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.090s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 22.322s
ok github.com/carlosframework/platform/internal/moves/archive 0.979s
ok github.com/carlosframework/platform/internal/objstore 0.327s
ok github.com/carlosframework/platform/internal/ops 0.234s
ok github.com/carlosframework/platform/internal/park 0.296s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 23.292s
ok github.com/carlosframework/platform/internal/publicrelease 0.012s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.618s
ok github.com/carlosframework/platform/internal/relay 7.185s
ok github.com/carlosframework/platform/internal/release 28.719s
ok github.com/carlosframework/platform/internal/rollout 3.771s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.061s
ok github.com/carlosframework/platform/internal/rootstate 0.029s
ok github.com/carlosframework/platform/internal/runs 0.587s
ok github.com/carlosframework/platform/internal/s3compat 0.008s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.679s
ok github.com/carlosframework/platform/internal/seeds 0.014s
ok github.com/carlosframework/platform/internal/sim/engines 2.624s
ok github.com/carlosframework/platform/internal/sim/hostlife 10.126s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.074s
ok github.com/carlosframework/platform/internal/status 0.071s
ok github.com/carlosframework/platform/internal/steering 0.009s
ok github.com/carlosframework/platform/internal/stopevidence 0.323s
ok github.com/carlosframework/platform/internal/svcrole 0.003s
ok github.com/carlosframework/platform/internal/telemetry 0.007s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.005s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.005s
ok github.com/carlosframework/platform/internal/usage 0.007s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.707s
=== internal_console@0 (78 s)
ok github.com/carlosframework/platform/internal/console 76.618s
=== internal_console@1 (42 s)
ok github.com/carlosframework/platform/internal/console 41.043s
=== internal_console@2 (40 s)
ok github.com/carlosframework/platform/internal/console 38.867s
=== internal_console@3 (43 s)
ok github.com/carlosframework/platform/internal/console 42.063s
=== internal_console@4 (37 s)
ok github.com/carlosframework/platform/internal/console 36.052s
=== internal_console@5 (40 s)
ok github.com/carlosframework/platform/internal/console 38.469s
=== internal_console@6 (45 s)
ok github.com/carlosframework/platform/internal/console 43.652s
=== internal_console@7 (44 s)
ok github.com/carlosframework/platform/internal/console 43.043s
=== root@0 (109 s)
ok github.com/carlosframework/platform 107.581s
=== root@1 (85 s)
ok github.com/carlosframework/platform 83.412s
=== root@2 (92 s)
ok github.com/carlosframework/platform 90.686s
=== 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: 50528418 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: downloading https://github.com/minio/minio/releases/download/RELEASE.2025-09-07T16-13-09Z/minio.linux-arm64.RELEASE.2025-09-07T16-13-09Z
minio: downloaded, digest verified
mc: downloading https://github.com/minio/mc/releases/download/RELEASE.2025-08-13T08-35-41Z/mc.linux-arm64.RELEASE.2025-08-13T08-35-41Z
mc: downloaded, 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.024s
=== RUN TestT4StorageProbeMinio
2026/10/24 00:44:06 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E150821B3A88AE, 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.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
=== RUN TestS3StoreMinioContract
=== RUN TestS3StoreMinioContract/conditional_get_and_etag_listing
=== RUN TestS3StoreMinioContract/bump_through_every_mutation_path
--- PASS: TestS3StoreMinioContract (0.02s)
--- PASS: TestS3StoreMinioContract/conditional_get_and_etag_listing (0.01s)
--- PASS: TestS3StoreMinioContract/bump_through_every_mutation_path (0.01s)
PASS
ok github.com/carlosframework/platform/internal/release 0.030s
Bucket created successfully `local/dbf-deploy`.
Bucket created successfully `local/dbf-data-ie`.
Bucket created successfully `local/dbf-data-uk`.
local/dbf-deploy versioning is enabled
Created policy `dbf-console` successfully.
Created policy `dbf-ukbox` successfully.
Added user `dbfconsole` successfully.
Added user `dbfukbox` successfully.
Attached Policies: [dbf-console]
To User: dbfconsole
Attached Policies: [dbf-ukbox]
To User: dbfukbox
=== RUN TestDBForwardMinio
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 00:44:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000002
2026/10/24 00:44:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000002 maxTXID=0000000000000002 size=370
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000002
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000002 txid.db=0000000000000003
2026/10/24 00:44:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000003 maxTXID=0000000000000003 size=454
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000003 txid.db=0000000000000003
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000000 txid.db=0000000000000001
2026/10/24 00:44:08 INFO ltx file uploaded system=store db=source.db replica=file level=0 minTXID=0000000000000001 maxTXID=0000000000000001 size=720
2026/10/24 00:44:08 INFO replica sync system=store db=source.db replica=file txid.replica=0000000000000001 txid.db=0000000000000001
2026/10/24 00:44:08 console: CARLOS_SECRETS_PUBKEY is unset, so no region outside the home can be chosen automatically
2026/10/24 00:44:08 console: could not notify member@example.com of a CLI token issue: keymail: lookup member@example.com: keymail: GET /api/federation/lookup?addr=member%40example.com&from=console%40127.0.0.1%3A34089: could not determine target mailbox
agent.go:135: dbreads: dbr_23fwkxdacz2kbxcpcv3bs2js6y: planned 3 files (1544 bytes) and restored to 3 in 9ms
agent.go:135: dbreads: dbr_23fwkxdacz2kbxcpcv3bs2js6y: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 00:44:09 console: db read dbr_23fwkxdacz2kbxcpcv3bs2js6y: relayed 1 chunks (102 bytes) in 1.003s, ended done
agent.go:135: dbreads: dbr_cszrgg75zbdpm7jpufnapglnmm: planned 1 files (720 bytes) and restored to 1 in 8ms
agent.go:135: dbreads: dbr_cszrgg75zbdpm7jpufnapglnmm: output 5 bytes in 1 confirmed chunks in 1ms
2026/10/24 00:44:10 console: db read dbr_cszrgg75zbdpm7jpufnapglnmm: relayed 1 chunks (102 bytes) in 1.003s, ended done
agent.go:135: dbreads: dbr_nuqdsyi35i6koee6hildmk6d2a: planned 3 files (1544 bytes) and held through 3 in 8ms
agent.go:135: dbreads: dbr_nuqdsyi35i6koee6hildmk6d2a: output 1544 bytes in 1 confirmed chunks in 1ms
2026/10/24 00:44:11 console: db read dbr_nuqdsyi35i6koee6hildmk6d2a: relayed 1 chunks (1641 bytes) in 1.003s, ended done
=== RUN TestDBForwardMinio/by_disconnect
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_hq5jyjuwmufogfiw5wca7buayi: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 00:44:11 console: db read dbr_hq5jyjuwmufogfiw5wca7buayi: relayed 0 chunks (0 bytes) in 16ms, ended client gone
agent.go:135: dbreads: dbr_hq5jyjuwmufogfiw5wca7buayi: output 5 bytes in 1 confirmed chunks in 103ms
=== RUN TestDBForwardMinio/by_timeout
=== NAME TestDBForwardMinio
agent.go:135: dbreads: dbr_xiwciphrctmh4koy2xkhw66cry: planned 3 files (1544 bytes) and restored to 3 in 9ms
2026/10/24 00:44:32 console: db read dbr_xiwciphrctmh4koy2xkhw66cry: relayed 0 chunks (0 bytes) in 19.998s, ended no-result
agent.go:135: dbreads: dbr_xiwciphrctmh4koy2xkhw66cry: output 5 bytes in 1 confirmed chunks in 19.973s
--- PASS: TestDBForwardMinio (23.42s)
--- PASS: TestDBForwardMinio/by_disconnect (0.22s)
--- PASS: TestDBForwardMinio/by_timeout (20.11s)
PASS
ok github.com/carlosframework/platform/internal/console 23.439s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 0a8e384f2..1cd16415e 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1187,8 +1187,12 @@ carlos gate clear --app site |
| stdin, so it need not land in your shell history. Without `--host` the |
| change applies to every instance of the app; name one to narrow it. Changing |
| or clearing the password signs every visitor out at once. Custom domains |
| -attached to the app are gated along with the platform hostname. This is a |
| -curtain in front of the whole hostname, not accounts for the app's own |
| +attached to the app are gated along with the platform hostname. So is the |
| +app's own domain (`<app>.<account>.<apps-domain>`), which serves one of its |
| +instances: it takes that instance's password, or, if that one has none and |
| +another instance does, the other's, so it is never the open way round a |
| +gated instance. It follows a password change within a few minutes rather |
| +than seconds. This is a curtain in front of the whole hostname, not accounts for the app's own |
| users; an app with real sign-in keeps doing that itself. |
| ### carlos logs |
diff --git a/internal/console/apistatuslayout.go b/internal/console/apistatuslayout.go| index 9471f25e4..c06b0fbe0 100644 |
| --- a/internal/console/apistatuslayout.go |
| +++ b/internal/console/apistatuslayout.go |
| @@ -60,7 +60,9 @@ type apiStatusLayoutBox struct { |
| // half (plan 5) keeps the warning loud for a box that has gone nested but |
| // whose only remaining rows are the ones the pass cannot move: with no |
| // nested row left to stand beside, they would otherwise read as clean. |
| -// Fenced and conflict rows are stopped and count for neither side. |
| +// Fenced and conflict rows are stopped and count for neither side, and a |
| +// platform row (a minted domain proxying another row's socket) runs no |
| +// process of its own, so it counts for neither side either. |
| func layoutMixed(mode string, rows []apiStatusLayoutRow) bool { |
| nested, unisolated := mode == "nested", false |
| for _, r := range rows { |
diff --git a/internal/console/apistatuslayout_test.go b/internal/console/apistatuslayout_test.go| index c0f81efb9..e71fcce4f 100644 |
| --- a/internal/console/apistatuslayout_test.go |
| +++ b/internal/console/apistatuslayout_test.go |
| @@ -208,3 +208,82 @@ func TestLayoutMixedNeedsANestedAndAFlatRow(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A platform row (a minted domain proxying another row's socket) runs no |
| +// process of its own: it never makes a box mixed, but an external one does. |
| +func TestLayoutMixedIgnoresPlatformRows(t *testing.T) { |
| + row := func(host, layout string) apiStatusLayoutRow { |
| + return apiStatusLayoutRow{Host: host, Layout: layout} |
| + } |
| + nested := row("a.example.com", "nested") |
| + if layoutMixed("nested", []apiStatusLayoutRow{nested, row("example.com", "platform")}) { |
| + t.Error("nested + platform under a nested intent is mixed") |
| + } |
| + if layoutMixed("flat", []apiStatusLayoutRow{nested, row("example.com", "platform")}) { |
| + t.Error("nested + platform under a flat intent is mixed") |
| + } |
| + if layoutMixed("nested", []apiStatusLayoutRow{row("example.com", "platform")}) { |
| + t.Error("a lone platform row is mixed") |
| + } |
| + if !layoutMixed("nested", []apiStatusLayoutRow{nested, row("example.com", "platform"), row("x.example.com", "external")}) { |
| + t.Error("an external row beside nested and platform is not mixed") |
| + } |
| +} |
| + |
| +// Through the door: a box of nested rows plus a platform apex is not Mixed, |
| +// and the platform row's word and reason come through. |
| +func TestAPIStatusLayoutPlatformRowIsNotMixed(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-apistatuslayoutplatform") |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seedLayoutBox(t, fx.rel, "platform-box", time.Now(), &status.LayoutReport{ |
| + Mode: "nested", |
| + Rows: map[string]status.RowLayout{ |
| + "app.example.com": {Layout: "nested"}, |
| + "example.com": {Layout: "platform", Reason: "platform domain: proxies app.example.com; runs no process of its own"}, |
| + }, |
| + }) |
| + seedLayoutBox(t, fx.rel, "external-box", time.Now(), &status.LayoutReport{ |
| + Mode: "nested", |
| + Rows: map[string]status.RowLayout{ |
| + "app.example.com": {Layout: "nested"}, |
| + "example.com": {Layout: "platform"}, |
| + "ext.example.com": {Layout: "external"}, |
| + }, |
| + }) |
| + code, body := getBearer(t, fx.ts.URL+"/api/cli/status/layout", token) |
| + if code != http.StatusOK { |
| + t.Fatalf("GET /api/cli/status/layout = %d %q", code, body) |
| + } |
| + var out struct { |
| + Boxes []apiStatusLayoutBox `json:"boxes"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + t.Fatalf("unreadable: %v: %s", err, body) |
| + } |
| + byLabel := map[string]apiStatusLayoutBox{} |
| + for _, b := range out.Boxes { |
| + byLabel[b.Box] = b |
| + } |
| + pb, ok := byLabel["platform-box"] |
| + if !ok { |
| + t.Fatalf("boxes = %+v, want platform-box", out.Boxes) |
| + } |
| + if pb.Mixed { |
| + t.Errorf("platform-box = %+v, want not Mixed", pb) |
| + } |
| + var found bool |
| + for _, r := range pb.Rows { |
| + if r.Host == "example.com" { |
| + found = r.Layout == "platform" && r.Reason != "" |
| + } |
| + } |
| + if !found { |
| + t.Errorf("platform-box rows = %+v, want the platform row with its reason", pb.Rows) |
| + } |
| + if !byLabel["external-box"].Mixed { |
| + t.Errorf("external-box = %+v, want Mixed", byLabel["external-box"]) |
| + } |
| +} |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index cd69ca76a..9bd49b3ff 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -310,6 +310,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| continue // nothing serving to copy an upstream from; next pass |
| } |
| nr := mintedRoute(host, k.app, k.acct, *src) |
| + // Born gated when it must be (section 6 keeps it so): an apex |
| + // minted open and gated a moment later is open for that moment. |
| + nr.Gate = platformGate(routes, k, nr) |
| if err := reg.Upsert(nr); err != nil { |
| log.Printf("domains: mint %s: %v", host, err) |
| continue |
| @@ -373,6 +376,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| nr := mintedRoute(host, app, acct, *src) |
| nr.Channel = channel |
| nr.Canary = true |
| + nr.Gate = platformGate(routes, k, nr) |
| if err := reg.Upsert(nr); err != nil { |
| log.Printf("domains: mint canary %s: %v", host, err) |
| continue |
| @@ -404,6 +408,31 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| } |
| routes = kept |
| + // --- 6. gates on minted routes --------------------------------------------- |
| + // A minted route proxies a socket (or serves a channel) that one of the |
| + // app's own routes already serves, so it must not be a way round that |
| + // route's password: the apex of an app whose instances are all gated |
| + // served the first one's process, /dev/mail and all, to anyone who |
| + // asked. Converged on every pass, not stamped at mint alone, because a |
| + // gate is set, changed and cleared on the instance record long after |
| + // the apex exists; SetGate rather than Upsert, because Upsert keeps a |
| + // set gate against an empty one and so could never clear it. |
| + for _, rt := range routes { |
| + if !rt.Platform { |
| + continue |
| + } |
| + k := appKey{release.RouteAccount(rt), rt.App} |
| + want := platformGate(routes, k, rt) |
| + if rt.Gate == want { |
| + continue |
| + } |
| + if err := reg.SetGate(rt.Host, want); err != nil { |
| + log.Printf("domains: converge gate on %s: %v", rt.Host, err) |
| + continue |
| + } |
| + log.Printf("domains: converged %s gate %s -> %s", rt.Host, gateWord(rt.Gate), gateWord(want)) |
| + } |
| + |
| // Sections 4 and 5 — the per-box health stamps — live in StampHealth |
| // (#303): they write THIS box's registry and must run on every box, not |
| // only on the housekeeper this pass is gated to. The agent runs it once |
| @@ -900,6 +929,59 @@ func mintedRoute(host, app, account string, src registry.Route) registry.Route { |
| return rt |
| } |
| +// platformGate is the gate a minted route p of app k must carry: the gate |
| +// of the route it serves from — the app's own route with the same Socket, |
| +// the same Addr or, for a static route, the same Channel — and, when that |
| +// one is open, the first gate any other route of the app carries, in host |
| +// order. Open only when every route of the app is open. |
| +// |
| +// The fallback is what makes it fail closed. A source can be ungated while |
| +// a sibling is gated (the operator gated some instances and not others, or |
| +// the source is unidentifiable: a fleet-placed mint copies no socket), and |
| +// "the app has a password" is the stronger statement about the apex than |
| +// "the process it happens to proxy has none". The fallback is a REAL hash, |
| +// never a sentinel: internal/gate derives the cookie key from the hash |
| +// alone, so a well-known "closed" value would be a key anybody could mint |
| +// a valid cookie with. |
| +func platformGate(routes []registry.Route, k appKey, p registry.Route) string { |
| + sibling := "" |
| + for _, rt := range routes { |
| + if rt.Platform || rt.App != k.app || release.RouteAccount(rt) != k.acct { |
| + continue |
| + } |
| + if servesSame(rt, p) && rt.Gate != "" { |
| + return rt.Gate |
| + } |
| + if sibling == "" { |
| + sibling = rt.Gate |
| + } |
| + } |
| + return sibling |
| +} |
| + |
| +// servesSame reports whether minted route p was copied from rt: the same |
| +// proxy upstream, or for a static route the same channel (mintedRoute's |
| +// copy rules). |
| +func servesSame(rt, p registry.Route) bool { |
| + switch { |
| + case p.Socket != "": |
| + return rt.Socket == p.Socket |
| + case p.Addr != "": |
| + return rt.Addr == p.Addr |
| + case p.Kind == "static": |
| + return rt.Kind == "static" && rt.Channel != "" && rt.Channel == p.Channel |
| + } |
| + return false |
| +} |
| + |
| +// gateWord names a gate in a log line without printing the hash. |
| +func gateWord(hash string) string { |
| + if hash == "" { |
| + return "open" |
| + } |
| + return "gated" |
| +} |
| + |
| // hostCertExpiry resolves host's cert expiry from the acme cache dir as the |
| // LATER of the per-host autocert entry and the covering wildcard's PEM |
| // (wildcard-<parent>.pem). Two reasons a bare per-host lookup isn't enough: a |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 4e72b2936..ca04fba92 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -24,6 +24,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/gate" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -1442,3 +1443,146 @@ func TestAccountManifestSqidIgnoresLimitsRecord(t *testing.T) { |
| t.Fatal("accounts/abc/limits.json parsed as an account manifest") |
| } |
| } |
| + |
| +// gatedInstance is a ready instance route on its own socket, with the given |
| +// gate ("" = open). |
| +func gatedInstance(host, app, sock, hash string) registry.Route { |
| + return registry.Route{Host: host, Kind: "instance", App: app, Status: "ready", |
| + Socket: sock, Gate: hash} |
| +} |
| + |
| +func reconcileOnce(t *testing.T, s release.Store, reg *registry.Registry) { |
| + t.Helper() |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// The apex is born gated: checked after the FIRST pass, not a later one. |
| +func TestReconcileMintedApexCarriesTheSourceGate(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, gatedInstance("kass.training", "kass", "/run/kass.sock", "$2a$10$sourcehash")) |
| + reconcileOnce(t, s, reg) |
| + got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| + if !got.Platform || got.Socket != "/run/kass.sock" { |
| + t.Fatalf("minted route = %+v", got) |
| + } |
| + if got.Gate != "$2a$10$sourcehash" { |
| + t.Errorf("apex Gate after first pass = %q, want the source's", got.Gate) |
| + } |
| +} |
| + |
| +// A real bcrypt hash travels verbatim. |
| +func TestReconcileMintedApexCarriesARealBcryptHash(t *testing.T) { |
| + hash, err := gate.Hash("correct horse battery") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s, reg := dirStore(t), regWith(t, gatedInstance("kass.training", "kass", "/run/kass.sock", hash)) |
| + reconcileOnce(t, s, reg) |
| + got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| + if got.Gate != hash || !strings.HasPrefix(got.Gate, "$2") { |
| + t.Errorf("apex Gate = %q, want %q", got.Gate, hash) |
| + } |
| +} |
| + |
| +// Set, change and clear on the source all reach the apex; clearing is the |
| +// path Upsert cannot do. |
| +func TestReconcileMintedApexGateConverges(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, gatedInstance("kass.training", "kass", "/run/kass.sock", "")) |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "kass.bab.oncarlos.test").Gate; g != "" { |
| + t.Fatalf("apex Gate before any gate is set = %q, want open", g) |
| + } |
| + for _, step := range []struct{ set, want string }{ |
| + {"hashA", "hashA"}, |
| + {"hashB", "hashB"}, |
| + {"", ""}, |
| + } { |
| + if err := reg.SetGate("kass.training", step.set); err != nil { |
| + t.Fatal(err) |
| + } |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "kass.bab.oncarlos.test").Gate; g != step.want { |
| + t.Errorf("source gate %q: apex Gate = %q, want %q", step.set, g, step.want) |
| + } |
| + } |
| +} |
| + |
| +// The minted source (first ready in host order) is open while a sibling is |
| +// gated: the apex takes the sibling's real hash rather than staying open. |
| +func TestReconcileMintedApexFailsClosedOnMixedGates(t *testing.T) { |
| + t.Run("mint", func(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + gatedInstance("a.kass.training", "kass", "/run/a.sock", ""), |
| + gatedInstance("b.kass.training", "kass", "/run/b.sock", "hashB"), |
| + ) |
| + reconcileOnce(t, s, reg) |
| + got := mustGet(t, reg, "kass.bab.oncarlos.test") |
| + if got.Socket != "/run/a.sock" { |
| + t.Fatalf("minted from socket %q, want the first host's /run/a.sock", got.Socket) |
| + } |
| + if got.Gate != "hashB" { |
| + t.Errorf("apex Gate = %q, want the sibling's hashB", got.Gate) |
| + } |
| + }) |
| + t.Run("converge", func(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + gatedInstance("a.kass.training", "kass", "/run/a.sock", ""), |
| + gatedInstance("b.kass.training", "kass", "/run/b.sock", "hashB"), |
| + registry.Route{Host: "kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| + Status: "ready", Socket: "/run/a.sock", Platform: true, Account: "bab"}, |
| + ) |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "kass.bab.oncarlos.test").Gate; g != "hashB" { |
| + t.Errorf("existing apex Gate = %q, want the sibling's hashB", g) |
| + } |
| + }) |
| + t.Run("own source wins", func(t *testing.T) { |
| + // The apex serves b's socket, so b's gate beats a's earlier one. |
| + s, reg := dirStore(t), regWith(t, |
| + gatedInstance("a.kass.training", "kass", "/run/a.sock", "hashA"), |
| + gatedInstance("b.kass.training", "kass", "/run/b.sock", "hashB"), |
| + registry.Route{Host: "kass.bab.oncarlos.test", Kind: "instance", App: "kass", |
| + Status: "ready", Socket: "/run/b.sock", Platform: true, Account: "bab"}, |
| + ) |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "kass.bab.oncarlos.test").Gate; g != "hashB" { |
| + t.Errorf("apex Gate = %q, want its own source's hashB", g) |
| + } |
| + }) |
| +} |
| + |
| +func TestReconcileMintedApexStaysOpenWhenNothingIsGated(t *testing.T) { |
| + s, reg := dirStore(t), regWith(t, |
| + gatedInstance("a.kass.training", "kass", "/run/a.sock", ""), |
| + gatedInstance("b.kass.training", "kass", "/run/b.sock", ""), |
| + ) |
| + reconcileOnce(t, s, reg) |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "kass.bab.oncarlos.test").Gate; g != "" { |
| + t.Errorf("apex Gate = %q, want open", g) |
| + } |
| +} |
| + |
| +// One app's gate never reaches another app's apex, in the same account or |
| +// under the same app name in another account. |
| +func TestReconcileMintedApexGateDoesNotLeakAcrossApps(t *testing.T) { |
| + s := dirStore(t) |
| + other := writeAccountManifest(t, s, 793481920157, "Other") |
| + x := gatedInstance("x.training", "x", "/run/x.sock", "hashX") |
| + y := gatedInstance("y.training", "y", "/run/y.sock", "") |
| + // Same app name as x, different account, open. |
| + x2 := gatedInstance("x.other.training", "x", "/run/x2.sock", "") |
| + x2.Account = other |
| + reg := regWith(t, x, y, x2) |
| + reconcileOnce(t, s, reg) |
| + if g := mustGet(t, reg, "x.bab.oncarlos.test").Gate; g != "hashX" { |
| + t.Errorf("x apex Gate = %q, want hashX", g) |
| + } |
| + if g := mustGet(t, reg, "y.bab.oncarlos.test").Gate; g != "" { |
| + t.Errorf("y apex Gate = %q, want open: another app's gate leaked", g) |
| + } |
| + if g := mustGet(t, reg, "x."+other+".oncarlos.test").Gate; g != "" { |
| + t.Errorf("other account's x apex Gate = %q, want open: another account's gate leaked", g) |
| + } |
| +} |
diff --git a/internal/edge/gate_test.go b/internal/edge/gate_test.go| index dbeb39b26..4adfb6003 100644 |
| --- a/internal/edge/gate_test.go |
| +++ b/internal/edge/gate_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "net/http/httptest" |
| "net/url" |
| "strings" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -506,3 +507,78 @@ func TestUngatedRouteUnchanged(t *testing.T) { |
| t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen) |
| } |
| } |
| + |
| +// A platform-minted apex row carries its source instance's gate. Cookies |
| +// are host-bound, so the source's cookie does not open the apex; only a |
| +// login on the apex itself does. |
| +func TestPlatformApexRowCarryingTheSourceGateIsGatedOnItsOwn(t *testing.T) { |
| + var hits atomic.Int32 |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + hits.Add(1) |
| + io.WriteString(w, "secret page") |
| + })) |
| + t.Cleanup(backend.Close) |
| + h, err := gate.Hash("correct horse") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + addr := backend.Listener.Addr().String() |
| + reg := openReg(t) |
| + for _, rt := range []registry.Route{ |
| + {Host: "app.example.com", App: "shop", Kind: "instance", Addr: addr, Status: "ready", Gate: h}, |
| + {Host: "example.com", App: "shop", Kind: "instance", Addr: addr, Status: "ready", Gate: h, Platform: true}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + e, err := New(reg, refusingActivator{t: t}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + for _, path := range []string{"/", "/dev/mail"} { |
| + if resp := doHost(t, srv, http.MethodGet, "example.com", path, ""); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("apex %s without cookie = %d, want 401", path, resp.StatusCode) |
| + } |
| + } |
| + if hits.Load() != 0 { |
| + t.Fatalf("backend hit %d times without a cookie", hits.Load()) |
| + } |
| + |
| + login := postLogin(t, srv, "app.example.com", "correct horse", "/") |
| + srcCookie := gateCookie(login) |
| + if login.StatusCode != http.StatusSeeOther || srcCookie == "" { |
| + t.Fatalf("source login = %d, cookie %q", login.StatusCode, srcCookie) |
| + } |
| + if resp := doHost(t, srv, http.MethodGet, "app.example.com", "/", gate.CookieName+"="+srcCookie); resp.StatusCode != http.StatusOK { |
| + t.Fatalf("source with its own cookie = %d, want 200", resp.StatusCode) |
| + } |
| + hits.Store(0) |
| + if resp := doHost(t, srv, http.MethodGet, "example.com", "/dev/mail", gate.CookieName+"="+srcCookie); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("apex with the source's cookie = %d, want 401", resp.StatusCode) |
| + } |
| + if hits.Load() != 0 { |
| + t.Fatalf("backend hit %d times with the source's cookie on the apex", hits.Load()) |
| + } |
| + |
| + wrong := postLogin(t, srv, "example.com", "wrong", "/") |
| + if gateCookie(wrong) != "" || wrong.StatusCode == http.StatusSeeOther { |
| + t.Fatalf("apex login with a wrong password = %d, cookie %q", wrong.StatusCode, gateCookie(wrong)) |
| + } |
| + apexLogin := postLogin(t, srv, "example.com", "correct horse", "/dev/mail") |
| + apexCookie := gateCookie(apexLogin) |
| + if apexLogin.StatusCode != http.StatusSeeOther || apexLogin.Header.Get("Location") != "/dev/mail" || apexCookie == "" { |
| + t.Fatalf("apex login = %d -> %q, cookie %q", apexLogin.StatusCode, apexLogin.Header.Get("Location"), apexCookie) |
| + } |
| + if !gate.Verify(apexCookie, "example.com", h, time.Now()) || gate.Verify(apexCookie, "app.example.com", h, time.Now()) { |
| + t.Fatal("apex cookie must verify for the apex host only") |
| + } |
| + resp := doHost(t, srv, http.MethodGet, "example.com", "/dev/mail", gate.CookieName+"="+apexCookie) |
| + b, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusOK || string(b) != "secret page" || hits.Load() != 1 { |
| + t.Fatalf("apex with its own cookie = %d %q (hits %d), want 200 and one backend hit", resp.StatusCode, b, hits.Load()) |
| + } |
| +} |
diff --git a/internal/host/agent_layout_test.go b/internal/host/agent_layout_test.go| index 849a38bc0..2334ecaf3 100644 |
| --- a/internal/host/agent_layout_test.go |
| +++ b/internal/host/agent_layout_test.go |
| @@ -671,6 +671,72 @@ func TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes(t *testing.T) { |
| } |
| } |
| +// platformPair is a nested source row and the Platform apex row minted onto |
| +// its socket (different host, same app). |
| +func platformPair(dd string) (src, apex registry.Route) { |
| + src = registry.Route{Host: "app.example.com", App: "shop", Kind: "instance", Hibernate: true, |
| + DBPath: provision.NestedDBPath(dd, "app.example.com"), Socket: provision.NestedSocket("app.example.com")} |
| + apex = registry.Route{Host: "example.com", App: "shop", Kind: "instance", Platform: true, |
| + Socket: src.Socket} |
| + return src, apex |
| +} |
| + |
| +// A Platform row proxying a sibling's socket is "platform" in the unmanaged |
| +// report; one whose socket nobody else has keeps its old classification. |
| +func TestUnmanagedLayoutReportPlatformRowProxiesItsSource(t *testing.T) { |
| + resetLayoutGlobals(t) |
| + t.Setenv("CARLOS_DATA_DIR", "/data") |
| + src, apex := platformPair("/data") |
| + orphan := registry.Route{Host: "orphan.example.com", App: "shop", Kind: "instance", Platform: true, |
| + Socket: provision.DefaultSocket("orphan.example.com")} |
| + got := layoutStatusReport([]registry.Route{src, apex, orphan}) |
| + if got == nil || got.Mode != LayoutModeUnmanaged { |
| + t.Fatalf("report = %+v, want unmanaged", got) |
| + } |
| + if r := got.Rows[src.Host]; r.Layout != "nested" { |
| + t.Errorf("source = %+v, want nested", r) |
| + } |
| + r := got.Rows[apex.Host] |
| + if r.Layout != LayoutPlatform || !strings.Contains(r.Reason, src.Host) { |
| + t.Errorf("apex = %+v, want platform with a reason naming %s", r, src.Host) |
| + } |
| + if r := got.Rows[orphan.Host]; r.Layout != "external" { |
| + t.Errorf("orphan = %+v, want external", r) |
| + } |
| +} |
| + |
| +// The same with a pass armed: the apex is "platform", not "external". |
| +// Armed the way TestLayoutStatusReportRowsCoverOnlyInstanceKindRoutes does. |
| +func TestLayoutStatusReportPlatformRowProxiesItsSource(t *testing.T) { |
| + resetLayoutGlobals(t) |
| + dd, sd := t.TempDir(), t.TempDir() |
| + armedLayoutPass.Store(&layout.Pass{DataDir: dd, SocketDir: sd}) |
| + noteLayoutIntent(release.LayoutModeNested) |
| + src, apex := platformPair(dd) |
| + orphan := registry.Route{Host: "orphan.example.com", App: "shop", Kind: "instance", Platform: true, |
| + Socket: "/elsewhere/orphan.sock"} |
| + // Same socket as the source but another app: not its alias. |
| + other := registry.Route{Host: "other.example.com", App: "blog", Kind: "instance", Platform: true, |
| + Socket: src.Socket} |
| + |
| + got := layoutStatusReport([]registry.Route{src, apex, orphan, other}) |
| + if got == nil || got.Mode != "nested" { |
| + t.Fatalf("report = %+v, want mode nested", got) |
| + } |
| + r := got.Rows[apex.Host] |
| + if r.Layout != LayoutPlatform || !strings.Contains(r.Reason, src.Host) { |
| + t.Errorf("apex = %+v, want platform with a reason naming %s", r, src.Host) |
| + } |
| + if r := got.Rows[src.Host]; r.Layout == LayoutPlatform || r.Layout == "" { |
| + t.Errorf("source = %+v, want its own Classify word", r) |
| + } |
| + for _, host := range []string{orphan.Host, other.Host} { |
| + if r := got.Rows[host]; r.Layout != "external" { |
| + t.Errorf("%s = %+v, want external", host, r) |
| + } |
| + } |
| +} |
| + |
| // Landing follow-up 3: a held host makes the leg come back on its own, so |
| // an operator who clears the reason on a quiet fleet waits awakeRetry and |
| // not the next serial move; an all-quiet result does not. |
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go| index 37d3b88b5..7dca9c0c2 100644 |
| --- a/internal/host/layoutleg.go |
| +++ b/internal/host/layoutleg.go |
| @@ -197,6 +197,10 @@ func layoutStatusReport(routes []registry.Route) *status.LayoutReport { |
| if rt.Kind != "instance" { |
| continue |
| } |
| + if src, ok := mintedAliasOf(rt, routes); ok { |
| + rows[rt.Host] = platformRowLayout(src) |
| + continue |
| + } |
| switch { |
| case activator.ActivatorManaged(rt): |
| word, reason := pass.Classify(rt, routes) |
| @@ -236,6 +240,10 @@ func unmanagedLayoutReport(routes []registry.Route) *status.LayoutReport { |
| if rt.Kind != "instance" { |
| continue |
| } |
| + if src, ok := mintedAliasOf(rt, routes); ok { |
| + rows[rt.Host] = platformRowLayout(src) |
| + continue |
| + } |
| switch { |
| case layout.JournalPresent(dd, rt.Host): |
| rows[rt.Host] = status.RowLayout{Layout: string(activator.LayoutFenced), Reason: "layout migration in flight; see " + provision.LayoutJournalPath(dd, rt.Host) + " (no activator on this box to recover it)"} |
| @@ -252,6 +260,39 @@ func unmanagedLayoutReport(routes []registry.Route) *status.LayoutReport { |
| return &status.LayoutReport{Mode: LayoutModeUnmanaged, Rows: rows} |
| } |
| +// LayoutPlatform is the report word for a platform-minted route (the |
| +// domains pass's app apex) that proxies another of the box's routes' |
| +// socket. It spawns nothing and runs nothing: the process behind it is the |
| +// source row's, reported under the source's own host. Before this word it |
| +// fell through to "external" (an unflagged socket route), which counted |
| +// toward the mixed-layout warning and told the operator that nested |
| +// isolation was not in force on a box where every process was nested. Old |
| +// consoles ignore a word they do not know in both the warning |
| +// (layoutMixed) and the downgrade guard, which is the right answer for it |
| +// in each. |
| +const LayoutPlatform = "platform" |
| + |
| +// mintedAliasOf names the route whose socket platform-minted rt proxies, |
| +// when it is one: Platform, a socket, and that socket served by a |
| +// non-platform row of the same app on this box. A Platform row matching |
| +// none keeps its old classification, because then nothing on the box |
| +// accounts for whatever is listening there. |
| +func mintedAliasOf(rt registry.Route, routes []registry.Route) (string, bool) { |
| + if !rt.Platform || rt.Socket == "" { |
| + return "", false |
| + } |
| + for _, o := range routes { |
| + if !o.Platform && o.Host != rt.Host && o.App == rt.App && o.Socket == rt.Socket { |
| + return o.Host, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +func platformRowLayout(src string) status.RowLayout { |
| + return status.RowLayout{Layout: LayoutPlatform, Reason: "platform domain: proxies " + src + "; runs no process of its own"} |
| +} |
| + |
| // reloadRouting asks the live edge to rebuild its routes now, if there is |
| // one yet. |
| func reloadRouting() { |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index f74e754c9..014c35742 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1441,6 +1441,24 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "79cb56b4c7d3a8e155d1eff1902715d818fcf4c09b1d8a82921c413b116938a9" |
| + // Census overlay platform-apex-gate (minted platform apexes carry their |
| + // app's gate; the layout report stops calling them external), loaded |
| + // last, after db-region-forward-3. Proposed; the ruling is to be asked |
| + // of the census owner on #280. Reconcile is re-frozen at its |
| + // seeded-instances disposition with the gate stamping and section 6's |
| + // SetGate convergence appended; the new platformGate and servesSame are |
| + // accepted legacy at their siblings' J1 root-route disposition, and the |
| + // new mintedAliasOf is diagnostic H3 with the two layout reports that |
| + // call it. Every read is of registry Route rows the agent itself wrote. |
| + // Frozen declarations are added, so the seal moves; db-region-forward-3's |
| + // is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-platform-apex-gate.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "3e06678f97f7370288aa8ebb80ef62bbb6ed36aa8e434c36fffda1221d476872" |
| 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-platform-apex-gate.tsv b/internal/provision/testdata/plan14-root-reads-platform-apex-gate.tsv| new file mode 100644 |
| index 000000000..fadbefe90 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-platform-apex-gate.tsv |
| @@ -0,0 +1,16 @@ |
| +# Census overlay platform-apex-gate (minted platform apexes carry their app's gate; the layout report stops calling them external), loaded LAST in TestPlan14RootReadCensus, after db-region-forward-3. Proposed: every new or re-frozen row carries rev3=PROPOSED-platform-apex-gate, and the ruling is to be asked of the census owner on #280; the two re-pinned layout reports keep their diagnostic H3 no-effect disposition, with sites and digest moved. |
| +# Every unit here runs in the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos) and reads only registry Route rows the box itself wrote. internal/domains: Reconcile (legacy, accepted at census-ruling-seeded-instances) is re-frozen at that disposition with this change appended; the new platformGate and servesSame are proposed accepted legacy at their siblings' J1 root-route disposition (candidateApps, firstReadyUpstream, mintedRoute). platformGate's result is written as a minted route's Gate, so it decides who the edge admits on the apex: it only ever copies a hash another route of the same app and account already carries. internal/host: layoutStatusReport and unmanagedLayoutReport are re-pinned at their diagnostic H3 no-effect disposition, and the new mintedAliasOf, which only they call, takes the same class. Frozen declarations are added, so the seal moves. |
| +@legacy 395c6f1fa44b6f3aa3caaab4734818765f9353cca05c55eab5eadd7c7873eff6 internal/domains/reconcile.go Reconcile legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop: main's body with a trashed app's route kept while an import on its host is journaled and deleted only if the row is still the one listed (DeleteIfUnchanged);rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1); main's body plus the minted apex and canary routes stamped at mint with platformGate's gate, and a new section 6 that walks the routes it already listed and, for each Platform row whose Gate differs from platformGate's, calls SetGate on the box's own registry; it reads only Route rows the box itself wrote (Gate newly, plus more sites of the App, Platform and Host fields it already read); the gate it writes is a hash another route of the same app and account already carries, so it decides who the edge admits on the apex and never opens one wider than the app's own routes; no root decision registry:Account@5579,registry:Account@5775,registry:App@10380,registry:App@11888,registry:App@14167,registry:App@15302,registry:App@5380,registry:App@5528,registry:App@7191,registry:App@7248,registry:App@7449,registry:App@7662,registry:App@7900,registry:App@8090,registry:Canary@10324,registry:Canary@13174,registry:Canary@14058,registry:Channel@11943,registry:Channel@13151,registry:Channel@14089,registry:Channel@14176,registry:Channel@14479,registry:Gate@10923,registry:Gate@13193,registry:Gate@15353,registry:Gate@15590,registry:Host@10287,registry:Host@11138,registry:Host@14214,registry:Host@14299,registry:Host@14470,registry:Host@15411,registry:Host@15490,registry:Host@15572,registry:Host@5650,registry:Host@5736,registry:Host@7298,registry:Host@7440,registry:Host@7653,registry:Host@7891,registry:Host@8081,registry:Kind@12817,registry:Platform@10308,registry:Platform@14043,registry:Platform@15232,whole:Route@10269,whole:Route@10375,whole:Route@10436,whole:Route@10642,whole:Route@10658,whole:Route@10784,whole:Route@10946,whole:Route@10957,whole:Route@10984,whole:Route@11150,whole:Route@11166,whole:Route@11174,whole:Route@11873,whole:Route@11926,whole:Route@12083,whole:Route@12099,whole:Route@13144,whole:Route@13216,whole:Route@13227,whole:Route@13254,whole:Route@13423,whole:Route@13439,whole:Route@13447,whole:Route@13998,whole:Route@14029,whole:Route@14162,whole:Route@14317,whole:Route@14331,whole:Route@14337,whole:Route@14509,whole:Route@14523,whole:Route@14529,whole:Route@14537,whole:Route@14546,whole:Route@15217,whole:Route@15297,whole:Route@15333,whole:Route@15344,whole:Route@5366,whole:Route@5380,whole:Route@5510,whole:Route@5528,whole:Route@5579,whole:Route@5650,whole:Route@5736,whole:Route@5775,whole:Route@7144,whole:Route@7176,whole:Route@7243,whole:Route@7469,whole:Route@7487,whole:Route@7497,whole:Route@7563,whole:Route@7680,whole:Route@7698,whole:Route@7708,whole:Route@7913,whole:Route@7931,whole:Route@7941,whole:Route@8119,whole:Route@8137,whole:Route@8147,whole:Route@8157,whole:Route@8166 ddcd3c768c82609363948276da13e6e4ad8d79292faf6de557d802a750e95827 |
| +@legacy abe13c4b9a722fed578b026ffc2fcba4d94f170c93f079e1f70f020f569afe8a internal/domains/reconcile.go platformGate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1), as its siblings candidateApps, firstReadyUpstream and mintedRoute; new: over the Route rows Reconcile listed, picks the Gate of the app's own non-platform route (same account and app) that the minted route serves from (servesSame), else the first Gate any other route of that app carries, in host order, else open; the result is written as the minted route's Gate, so it decides who the edge admits on the apex; it copies an existing hash and mints none; no root decision registry:App@144,registry:Gate@242,registry:Gate@268,registry:Gate@314,registry:Platform@129,whole:Route@115,whole:Route@184,whole:Route@232,whole:Route@236 586c58906ad2d5a3d40cad20ca884952eb3e386a543ddf0afcb234d187d84f50 |
| +@legacy 8872de8a504e991a5d04b1707dbec9504e43c9d6d98a5a79473a49a6973948b8 internal/domains/reconcile.go servesSame legacy accepted-legacy accepted-legacy;rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1), as its sibling mintedRoute, whose copy rules it mirrors; new: compares two Route rows Reconcile listed (Socket, else Addr, else a static route's Kind and Channel) to say whether the minted route was copied from the other; a pure comparison feeding platformGate; no root decision registry:Addr@114,registry:Addr@137,registry:Addr@148,registry:Channel@213,registry:Channel@233,registry:Channel@247,registry:Kind@161,registry:Kind@190,registry:Socket@61,registry:Socket@86,registry:Socket@99 374bf38740d3b9c4fdda36be13f8ce99e005573d698135edf727f4c5afa61fbe |
| +internal/domains/reconcile.go Reconcile legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop: main's body with a trashed app's route kept while an import on its host is journaled and deleted only if the row is still the one listed (DeleteIfUnchanged);rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1); main's body plus the minted apex and canary routes stamped at mint with platformGate's gate, and a new section 6 that walks the routes it already listed and, for each Platform row whose Gate differs from platformGate's, calls SetGate on the box's own registry; it reads only Route rows the box itself wrote (Gate newly, plus more sites of the App, Platform and Host fields it already read); the gate it writes is a hash another route of the same app and account already carries, so it decides who the edge admits on the apex and never opens one wider than the app's own routes; no root decision registry:Account@5579,registry:Account@5775,registry:App@10380,registry:App@11888,registry:App@14167,registry:App@15302,registry:App@5380,registry:App@5528,registry:App@7191,registry:App@7248,registry:App@7449,registry:App@7662,registry:App@7900,registry:App@8090,registry:Canary@10324,registry:Canary@13174,registry:Canary@14058,registry:Channel@11943,registry:Channel@13151,registry:Channel@14089,registry:Channel@14176,registry:Channel@14479,registry:Gate@10923,registry:Gate@13193,registry:Gate@15353,registry:Gate@15590,registry:Host@10287,registry:Host@11138,registry:Host@14214,registry:Host@14299,registry:Host@14470,registry:Host@15411,registry:Host@15490,registry:Host@15572,registry:Host@5650,registry:Host@5736,registry:Host@7298,registry:Host@7440,registry:Host@7653,registry:Host@7891,registry:Host@8081,registry:Kind@12817,registry:Platform@10308,registry:Platform@14043,registry:Platform@15232,whole:Route@10269,whole:Route@10375,whole:Route@10436,whole:Route@10642,whole:Route@10658,whole:Route@10784,whole:Route@10946,whole:Route@10957,whole:Route@10984,whole:Route@11150,whole:Route@11166,whole:Route@11174,whole:Route@11873,whole:Route@11926,whole:Route@12083,whole:Route@12099,whole:Route@13144,whole:Route@13216,whole:Route@13227,whole:Route@13254,whole:Route@13423,whole:Route@13439,whole:Route@13447,whole:Route@13998,whole:Route@14029,whole:Route@14162,whole:Route@14317,whole:Route@14331,whole:Route@14337,whole:Route@14509,whole:Route@14523,whole:Route@14529,whole:Route@14537,whole:Route@14546,whole:Route@15217,whole:Route@15297,whole:Route@15333,whole:Route@15344,whole:Route@5366,whole:Route@5380,whole:Route@5510,whole:Route@5528,whole:Route@5579,whole:Route@5650,whole:Route@5736,whole:Route@5775,whole:Route@7144,whole:Route@7176,whole:Route@7243,whole:Route@7469,whole:Route@7487,whole:Route@7497,whole:Route@7563,whole:Route@7680,whole:Route@7698,whole:Route@7708,whole:Route@7913,whole:Route@7931,whole:Route@7941,whole:Route@8119,whole:Route@8137,whole:Route@8147,whole:Route@8157,whole:Route@8166 ddcd3c768c82609363948276da13e6e4ad8d79292faf6de557d802a750e95827 |
| +internal/domains/reconcile.go platformGate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1), as its siblings candidateApps, firstReadyUpstream and mintedRoute; new: over the Route rows Reconcile listed, picks the Gate of the app's own non-platform route (same account and app) that the minted route serves from (servesSame), else the first Gate any other route of that app carries, in host order, else open; the result is written as the minted route's Gate, so it decides who the edge admits on the apex; it copies an existing hash and mints none; no root decision registry:App@144,registry:Gate@242,registry:Gate@268,registry:Gate@314,registry:Platform@129,whole:Route@115,whole:Route@184,whole:Route@232,whole:Route@236 586c58906ad2d5a3d40cad20ca884952eb3e386a543ddf0afcb234d187d84f50 |
| +internal/domains/reconcile.go servesSame legacy accepted-legacy accepted-legacy;rev3=PROPOSED-platform-apex-gate;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its domains loop;basis=root-route (J1), as its sibling mintedRoute, whose copy rules it mirrors; new: compares two Route rows Reconcile listed (Socket, else Addr, else a static route's Kind and Channel) to say whether the minted route was copied from the other; a pure comparison feeding platformGate; no root decision registry:Addr@114,registry:Addr@137,registry:Addr@148,registry:Channel@213,registry:Channel@233,registry:Channel@247,registry:Kind@161,registry:Kind@190,registry:Socket@61,registry:Socket@86,registry:Socket@99 374bf38740d3b9c4fdda36be13f8ce99e005573d698135edf727f4c5afa61fbe |
| +internal/host/layoutleg.go layoutStatusReport diagnostic H3 no-effect registry:Hibernate@1247,registry:Host@1128,registry:Host@1210,registry:Host@1397,registry:Host@617,registry:Host@974,registry:Kind@521,registry:Socket@1227,whole:Route@153,whole:Route@315,whole:Route@591,whole:Route@595,whole:Route@712,whole:Route@750,whole:Route@754 538b125ce4fa062d7c0d54736dff9d7b4a288b6acd89198910c3fca68dbcc452 |
| +internal/host/layoutleg.go unmanagedLayoutReport diagnostic H3 no-effect registry:Hibernate@1015,registry:Hibernate@1092,registry:Host@1037,registry:Host@1133,registry:Host@290,registry:Host@384,registry:Host@402,registry:Host@547,registry:Host@639,registry:Host@657,registry:Host@862,registry:Kind@194,registry:Socket@1108,registry:Socket@995,whole:Route@180,whole:Route@264,whole:Route@268,whole:Route@849 fa18307f29304413e034729862b80f22b0ae3bfb306539f714965161976c8146 |
| +internal/host/layoutleg.go mintedAliasOf diagnostic H3 no-effect; rev3=PROPOSED-platform-apex-gate; new: the layout report's test for a platform-minted row (Platform, a Socket served by a non-platform row of the same app on this box, over the Route rows the report already read), so the report says platform instead of external; it only chooses a report word and reason, as its two callers (layoutStatusReport, unmanagedLayoutReport) do registry:App@209,registry:App@218,registry:Host@188,registry:Host@198,registry:Host@262,registry:Platform@174,registry:Platform@85,registry:Socket@100,registry:Socket@228,registry:Socket@240,whole:Route@159 9d9802c0733c1cab22e600c26384e8288dc58fd2a974ad62669b3e38e0814a10 |
| +# Envelopes re-pinned. internal/domains: reconcile.go (platformGate, servesSame, gateWord, section 6). internal/host: layoutleg.go (LayoutPlatform, mintedAliasOf, platformRowLayout). internal/console: apistatuslayout.go, a comment only. internal/status: snapshot.go, a comment only. The scan's only read-site changes are the rows above. |
| +@coverage internal/console 083f594188480df194e0815facf1d830e02425a483a07db3c2d09b7dee8618c2 |
| +@coverage internal/domains 3b2e4ee5f81d9c8cef824642eee36988962c7c35fbc075788cfb48682e3d4a7e |
| +@coverage internal/host 3bdbf909313e0558cfe966880fdb6a6f682cb6002fc2f8ab21800a32f233597c |
| +@coverage internal/status 4758c98403313437501490763ac0f10400c7bba460266bd68a25a22fbae3ea23 |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 4dd9ef44a..868515ed5 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -222,11 +222,13 @@ type HostUpdates struct { |
| // RowLayout is one instance-kind route's layout, as the box's layout pass |
| // classified it (activator.Classify): Layout is one of flat, nested, |
| -// fenced, conflict, foreign or external, and Reason carries the detail for |
| -// anything but flat and nested — a path, or another row's host — which is |
| -// why it is never shown on the console's instance page (see |
| -// instancepage.go's handleAppInstance): a member sees the word, an operator |
| -// reading status/boxes/<label>.json sees the reason too. |
| +// fenced, conflict, foreign or external, or platform (a minted route that |
| +// proxies another row's socket and runs nothing, host.LayoutPlatform), and |
| +// Reason carries the detail for anything but flat and nested — a path, or |
| +// another row's host — which is why it is never shown on the console's |
| +// instance page (see instancepage.go's handleAppInstance): a member sees |
| +// the word, an operator reading status/boxes/<label>.json sees the reason |
| +// too. |
| type RowLayout struct { |
| Layout string `json:"layout"` |
| Reason string `json:"reason,omitempty"` |