carlos / platform Public

settings-3-console-settings

Squashed from settings-3-console-settings into main.
claude agent, operated by paul@keymail.dev 30866251ab3cac055505ebdcd6b5a827c3a8ec9b parent 5157c1e
36 files changed, +3551 −83
  • docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json +30 −0
  • docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3-R2.md +36 −0
  • docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3.md +47 −0
  • internal/activator/backend_exec.go +11 −2
  • internal/activator/prepare.go +8 −2
  • internal/activator/updates_control_test.go +126 −0
  • internal/console/apisystemgeoip.go +14 −0
  • internal/console/apiupdates.go +629 −0
  • internal/console/apiupdates_test.go +1065 −0
  • internal/console/authcli.go +12 −0
  • internal/console/claim_invariant_fixtures_test.go +36 −0
  • internal/console/claim_invariant_probes_read_test.go +2 −0
  • internal/console/svcroutes_test.go +21 −3
  • internal/console/testdata/claim-routes.txt +4 −0
  • internal/console/updatesgate.go +67 −0
  • internal/console/updatesgate_test.go +283 −0
  • internal/host/agent.go +4 −0
  • internal/instanceupdates/control.go +94 −0
  • internal/instanceupdates/control_test.go +216 −0
  • internal/provision/plan9_b8_census_test.go +5 −3
  • internal/provision/provision.go +20 −0
  • internal/provision/reconcile.go +71 −13
  • internal/provision/reconcile_test.go +12 −12
  • internal/provision/updates.go +85 −0
  • internal/provision/updates_reconcile_test.go +155 −0
  • internal/provision/updates_test.go +78 −0
  • internal/registry/coldhandover.go +1 −1
  • internal/registry/registry.go +50 −15
  • internal/registry/registry_test.go +1 −0
  • internal/registry/remote_dispatch.go +3 −0
  • internal/registry/updatesettings_test.go +82 −0
  • internal/release/updatesettings.go +89 −0
  • internal/release/updatesettings_test.go +47 −0
  • internal/status/snapshot.go +10 −0
  • internal/updatewords/words_approved_test.go +79 −32
  • internal/updatewords/words_console.go +58 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
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]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	323.118s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.173s
ok  	github.com/carlosframework/platform/internal/activator	137.700s
ok  	github.com/carlosframework/platform/internal/agentsock	0.016s
ok  	github.com/carlosframework/platform/internal/alerting	0.004s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.224s
ok  	github.com/carlosframework/platform/internal/apperr	0.071s
ok  	github.com/carlosframework/platform/internal/applog	0.121s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.453s
?   	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.008s
ok  	github.com/carlosframework/platform/internal/caps	0.011s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.189s
ok  	github.com/carlosframework/platform/internal/changelog	0.237s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	325.739s
?   	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.048s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.017s
ok  	github.com/carlosframework/platform/internal/dbverify	0.007s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.007s
ok  	github.com/carlosframework/platform/internal/docsite	0.007s
ok  	github.com/carlosframework/platform/internal/domains	1.984s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.007s
ok  	github.com/carlosframework/platform/internal/edge	25.787s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.138s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
ok  	github.com/carlosframework/platform/internal/genselect	0.027s
ok  	github.com/carlosframework/platform/internal/geo	0.013s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/host	46.133s
ok  	github.com/carlosframework/platform/internal/hostactors	0.335s
ok  	github.com/carlosframework/platform/internal/hostbox	1.390s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.237s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.012s
ok  	github.com/carlosframework/platform/internal/hostexec	0.319s
ok  	github.com/carlosframework/platform/internal/hostfence	0.038s
ok  	github.com/carlosframework/platform/internal/hostfiles	4.089s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.015s
ok  	github.com/carlosframework/platform/internal/hostguard	0.129s
ok  	github.com/carlosframework/platform/internal/hostops	1.241s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.003s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.143s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	1.704s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.695s
ok  	github.com/carlosframework/platform/internal/instancefs	0.008s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.010s
ok  	github.com/carlosframework/platform/internal/layout	1.335s
ok  	github.com/carlosframework/platform/internal/lease	9.437s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.156s
ok  	github.com/carlosframework/platform/internal/maintenance	0.081s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.522s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.073s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	20.999s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.757s
ok  	github.com/carlosframework/platform/internal/objstore	0.164s
ok  	github.com/carlosframework/platform/internal/ops	0.156s
ok  	github.com/carlosframework/platform/internal/park	0.183s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	3.145s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.006s
ok  	github.com/carlosframework/platform/internal/registry	3.570s
ok  	github.com/carlosframework/platform/internal/relay	7.214s
ok  	github.com/carlosframework/platform/internal/release	18.010s
ok  	github.com/carlosframework/platform/internal/rollout	1.489s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.227s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	2.436s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.377s
ok  	github.com/carlosframework/platform/internal/status	0.035s
ok  	github.com/carlosframework/platform/internal/steering	0.007s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.131s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.002s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.541s
=== 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
--- 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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/11 14:04:44 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DD7EA77C793582, 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.015s
minio contract: PASS
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json b/docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json
new file mode 100644
index 000000000..54a2c060a
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json
@@ -0,0 +1,30 @@
+{
+ "approved_by": "Paul",
+ "approved_on": "2026-10-11",
+ "entries": [
+ {"id": "settings.console.bad-updates", "file": "internal/updatewords/words_console.go", "text": "updates must be auto or manual"},
+ {"id": "settings.console.bad-major", "file": "internal/updatewords/words_console.go", "text": "major must be a whole number, 0 or more, or empty to clear it"},
+ {"id": "settings.console.major-off-stable", "file": "internal/updatewords/words_console.go", "text": "a major hold applies only to instances on the stable channel. %s is on %s"},
+ {"id": "settings.console.named-unit", "file": "internal/updatewords/words_console.go", "text": "%s is run by its own systemd unit, so it always takes its channel's builds. Update settings do not apply to it"},
+ {"id": "settings.console.not-instance", "file": "internal/updatewords/words_console.go", "text": "update settings apply to instances. %s is a %s route"},
+ {"id": "settings.console.channel-elsewhere", "file": "internal/updatewords/words_console.go", "text": "change the channel with carlos instances set --channel, which applies the app's pipeline rules"},
+ {"id": "settings.console.bad-pin", "file": "internal/updatewords/words_console.go", "text": "%s is not a build version"},
+ {"id": "settings.console.pin-unpromoted", "file": "internal/updatewords/words_console.go", "text": "%s cannot be pinned: it has no promotion record. Pick a newer build, or ask an operator to authorize this one"},
+ {"id": "settings.console.pin-check-failed", "file": "internal/updatewords/words_console.go", "text": "could not check the promotion record of %s. Try again"},
+ {"id": "settings.console.not-enforced", "file": "internal/updatewords/words_console.go", "text": "pin, manual and major can be turned on once every box runs a release that applies them. %d boxes are not ready yet"},
+ {"id": "settings.console.enforcement-unknown", "file": "internal/updatewords/words_console.go", "text": "could not check whether every box applies update settings, so pin, manual and major stay off for now. Try again"},
+ {"id": "settings.console.pinned", "file": "internal/updatewords/words_console.go", "text": "%s is pinned to %s, so Update now would not move it. Clear the pin first"},
+ {"id": "settings.console.fast-track-owner", "file": "internal/updatewords/words_console.go", "text": "only an account owner can skip the safety delay"},
+ {"id": "settings.console.app-keeps", "file": "internal/updatewords/words_console.go", "text": "%s is kept by the app for itself. Ask someone who can change the app's settings"},
+ {"id": "settings.console.no-manifest", "file": "internal/updatewords/words_console.go", "text": "this app does not create instances yet. Run carlos instances enable first"},
+ {"id": "settings.console.unknown-policy-key", "file": "internal/updatewords/words_console.go", "text": "%s is not an app update setting. The app sets instance_control, default_updates, default_channel and default_major; there is no default pin"},
+ {"id": "settings.console.bad-control", "file": "internal/updatewords/words_console.go", "text": "instance_control may name updates, channel, major and pin, each once"},
+ {"id": "settings.console.changed", "file": "internal/updatewords/words_console.go", "text": "this instance changed while the request was in flight. Try again"},
+ {"id": "settings.console.store-failed", "file": "internal/updatewords/words_console.go", "text": "could not save the update settings. Try again"},
+ {"id": "settings.console.read-failed", "file": "internal/updatewords/words_console.go", "text": "could not read this instance. Try again"},
+ {"id": "settings.console.policy-read-failed", "file": "internal/updatewords/words_console.go", "text": "could not read the app's update policy. Try again"},
+ {"id": "settings.console.audit-set", "file": "internal/updatewords/words_console.go", "text": "changed the update settings of %s: %s"},
+ {"id": "settings.console.audit-install", "file": "internal/updatewords/words_console.go", "text": "asked %s to update now (request %d)"},
+ {"id": "settings.console.audit-policy", "file": "internal/updatewords/words_console.go", "text": "changed the instance update policy of %s: %s"}
+ ]
+}
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3-R2.md b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3-R2.md
new file mode 100644
index 000000000..8ba5eb534
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3-R2.md
@@ -0,0 +1,36 @@
+# Astra re-review r2: settings-3-console-settings
+
+Verdict: ACCEPT
+
+Reviewed `df3def0f..d9164c9e` read-only against the binding spec and R11. Inspected regression tests and reported RED/GREEN output; tests were not rerun. No files changed.
+
+## Round-1 findings
+
+### 1. A missing registry lets the fleet gate pass without checking fleets
+
+ADDRESSED.
+
+- `internal/console/apisystemgeoip.go:267` leaves `fleetsListed` false for unset, missing or unreadable registries; it becomes true only after successful fleet listing at line 287.
+- `internal/console/updatesgate.go:59` blocks whenever that evidence is absent. Both enabling doors use this gate (`internal/console/apiupdates.go:241` and `:547`); clearing bypasses it.
+- GeoIP’s existing completeness flags and verdict remain unchanged; only the settings gate consumes the new fields.
+- `internal/console/updatesgate_test.go:187` covers both doors refusing enables without writes and accepting clears across missing/unset registry, unhealthy fleet reports and incomplete heartbeat reads.
+
+### 2. An unchanged off-stable major blocks a full-state clear
+
+ADDRESSED.
+
+- `internal/console/apiupdates.go:178` distinguishes an unchanged major from a newly set or changed one. Lines 202–208 still refuse changed majors off stable and mask unchanged stale majors during validation.
+- The separate enable calculation at line 238 still gates newly enabled manual mode, changed pins and changed majors. Resending an unchanged major cannot bypass it.
+- `internal/console/apiupdates_test.go:339` covers full-state clears with the fleet gate open and closed, preservation of the stale major, and refusal of a changed major.
+
+### 3. The control socket loses the channel for default settings
+
+ADDRESSED.
+
+- `internal/instanceupdates/control.go:89` fills a missing channel from the fetched route without modifying storage; an explicit settings channel retains precedence.
+- The fallback uses the same `reg.Get(host)` result at line 74. `Mount` passes its fixed host at line 48, so request parameters cannot select another host’s channel.
+- `internal/instanceupdates/control_test.go:166` and `:183` cover empty-column fallback, unchanged storage and explicit-channel precedence.
+
+## New findings in the fix diff
+
+None.
\ No newline at end of file
diff --git a/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3.md b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3.md
new file mode 100644
index 000000000..ff7e71419
--- /dev/null
+++ b/docs/superpowers/plans/CODEX-FINDINGS-SETTINGS-IMPL-3.md
@@ -0,0 +1,47 @@
+# Astra review: settings-3-console-settings
+
+Verdict: REJECT
+
+Reviewed `70f8119f…df3def0f` read-only. Tests were inspected, not rerun. No files changed.
+
+## Findings
+
+### 1. [P1] A missing registry lets the fleet gate pass without checking fleets
+
+**Where:** [internal/console/updatesgate.go:25](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/console/updatesgate.go:25), [internal/console/apisystemgeoip.go:258](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/console/apisystemgeoip.go:258).
+
+**Scenario:** The deployment has active fleet boxes, but the console’s registry mount is absent or its registry path is unset. `geoipInventory` returns successfully with both completeness flags still true, before listing fleets or reading their heartbeats. If the platform boxes report the new protocol, `updatesEnforced` returns true. Both settings doors then accept protected settings despite unchecked fleet boxes.
+
+**Why it matters:** Spec §4.4 requires every expected box to report protection. An unavailable fleet inventory is incomplete evidence, not evidence of zero fleets. Members can enable settings that older fleet binaries ignore.
+
+**Fix:** Make the settings inventory fail closed when the registry is unavailable. Permit a platform-only inventory only with affirmative evidence that no fleets need checking. Test missing and unset registry paths, active fleet boxes with old or missing reports, and incomplete heartbeat reads through both enabling doors. Preserve clearing behavior.
+
+### 2. [P2] An unchanged off-stable major blocks a full-state clear
+
+**Where:** [internal/console/apiupdates.go:175](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/console/apiupdates.go:175), [internal/console/apiupdates.go:197](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/console/apiupdates.go:197).
+
+**Scenario:** An instance has `major: 2` and a pin, then moves from `stable` to `beta`. The retained major is correctly ignored. A client clears the pin while resending the unchanged major: `{"major":2,"pin":""}`. `majorSet` becomes true merely because the number is present, and the handler returns 400 before calculating changes.
+
+**Why it matters:** The pin remains set. This misses the combination of spec §3.1’s ignored off-stable major, §5’s clearing behavior, R4’s preservation of stale majors, and Task 6’s explicit guarantee that unchanged fields beside a clear do not prevent clearing.
+
+**Fix:** Distinguish a changed major from an unchanged value. Apply the off-stable refusal to newly set or changed majors; mask an unchanged stale major during validation. Add full-state clearing tests on an off-stable record, including with the fleet gate closed.
+
+### 3. [P2] The control socket loses the channel for default settings
+
+**Where:** [internal/instanceupdates/control.go:80](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/instanceupdates/control.go:80), [internal/release/updatesettings.go:38](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/settings-3-console-settings/internal/release/updatesettings.go:38).
+
+**Scenario:** An ordinary instance follows `beta` with no update overrides or app policy. Its settings column correctly remains `""`. Parsing that column produces only `Updates: "auto"`, and `RegistryReader` returns it without recovering `rt.Channel`. Authenticated `GET /updates` therefore reports `"channel":""`.
+
+**Why it matters:** Spec §5.1 requires the socket to return the box’s settings, including the channel defined in §3.1. This affects the default, untouched instance population.
+
+**Fix:** Populate the reported channel from the route when decoding the empty column. Keep the stored column empty to preserve upgrade compatibility. Extend the empty-column reader test with a nonempty route channel and assert the returned channel.
+
+## Deferred minors triage
+
+- **Must fix:** Task 5’s “registry absent yields a platform-only inventory that looks complete” entry, ledger line 61. This is finding 1.
+- **Fix alongside it:** Task 5’s incomplete-read and fleet-keyed coverage gaps, ledger line 63. These tests must demonstrate refusal through both enabling doors.
+- The remaining deferred entries do not independently block merge on the evidence reviewed. Findings 2 and 3 need regression tests beyond those listed in the ledger.
+
+## Rulings
+
+No numbered ruling needs overturning. Finding 2 completes R4 and Task 6’s clearing fix. R8 remains consistent with the settled preference model; I am not requesting channel-level passkey or approval requirements for an instance install request.
\ No newline at end of file
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 96e66227d..34592be3c 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/cgroup"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
@@ -136,6 +137,9 @@ type ExecBackend struct {
// feature — binds the control socket without those verbs, so an app that
// asks gets 404 rather than a socket that isn't there.
Timers schedule.TimerStore
+ // UpdatesReader answers the control socket's read-only GET /updates
+ // (instance update settings spec §5.1); nil mounts nothing.
+ UpdatesReader func(host string) (instanceupdates.Report, error)
// LayoutFacts supplies the box facts the classification of §8.1 needs
// beyond the row: journals and peers. Zero value: never fenced, never
// conflict. Named apart from the Layout method (the Classifier
@@ -1406,9 +1410,14 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc
return env["CARLOS_ADMIN_TOKEN"], nil
}
var mount func(*http.ServeMux)
- if b.Timers != nil {
+ if b.Timers != nil || b.UpdatesReader != nil {
mount = func(mux *http.ServeMux) {
- schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n))
+ if b.Timers != nil {
+ schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, n))
+ }
+ if b.UpdatesReader != nil {
+ instanceupdates.Mount(mux, rt.Host, tokenReader, b.UpdatesReader)
+ }
}
}
ctl, err := edge.ListenControl(controlSocket, rt.Host, b.Cache, mount)
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go
index 16e3d45a8..9d1a0bb5c 100644
--- a/internal/activator/prepare.go
+++ b/internal/activator/prepare.go
@@ -19,6 +19,7 @@ import (
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -141,9 +142,14 @@ func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string,
return env["CARLOS_ADMIN_TOKEN"], nil
}
var mount func(*http.ServeMux)
- if b.Timers != nil {
+ if b.Timers != nil || b.UpdatesReader != nil {
mount = func(mux *http.ServeMux) {
- schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, gen))
+ if b.Timers != nil {
+ schedule.MountGated(mux, rt.Host, b.Timers, tokenReader, time.Now, b.writeFence(rt.Host, gen))
+ }
+ if b.UpdatesReader != nil {
+ instanceupdates.Mount(mux, rt.Host, tokenReader, b.UpdatesReader)
+ }
}
}
ctl, err := edge.ListenControl(control, rt.Host, b.Cache, mount)
diff --git a/internal/activator/updates_control_test.go b/internal/activator/updates_control_test.go
new file mode 100644
index 000000000..d7b24b2b7
--- /dev/null
+++ b/internal/activator/updates_control_test.go
@@ -0,0 +1,126 @@
+package activator
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instanceupdates"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// updatesGet asks GET /updates on a control socket with bearer ("" sends none).
+func updatesGet(t *testing.T, ctl, bearer string) (int, instanceupdates.Report) {
+ t.Helper()
+ req, err := http.NewRequest("GET", "http://carlos/updates?host=other.example.com", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ resp, err := socketClient(ctl).Do(req)
+ if err != nil {
+ t.Fatalf("dial control socket %s: %v", ctl, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ var rep instanceupdates.Report
+ _ = json.Unmarshal(body, &rep)
+ return resp.StatusCode, rep
+}
+
+func fakeUpdatesReader(host string) (instanceupdates.Report, error) {
+ return instanceupdates.Report{Settings: release.UpdateSettings{Updates: release.UpdatesManual}, Installed: "v1.2.0"}, nil
+}
+
+// With UpdatesReader set, GET /updates is served on the spawned instance's
+// control socket behind the minted token, for the host the spawn was for (a
+// ?host= on the request changes nothing); with Timers left nil the schedule
+// verbs are still absent.
+func TestExecBackendStartMountsUpdatesOnControlSocket(t *testing.T) {
+ b := testExecBackend(t) // b.Timers nil
+ b.UpdatesReader = fakeUpdatesReader
+ ctl, token, stop := startAndReachControlSocket(t, b, "upd.example.com")
+ defer stop()
+
+ code, rep := updatesGet(t, ctl, token)
+ if code != http.StatusOK || rep.Host != "upd.example.com" || rep.Installed != "v1.2.0" || rep.Settings.Updates != release.UpdatesManual {
+ t.Fatalf("GET /updates with the minted token = %d %+v", code, rep)
+ }
+ if code, _ := updatesGet(t, ctl, "not-the-token"); code != http.StatusUnauthorized {
+ t.Fatalf("wrong bearer = %d, want 401", code)
+ }
+ if code, _ := updatesGet(t, ctl, ""); code != http.StatusUnauthorized {
+ t.Fatalf("no bearer = %d, want 401", code)
+ }
+ req, _ := http.NewRequest("GET", "http://carlos/schedule", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := socketClient(ctl).Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("GET /schedule with nil Timers = %d, want 404", resp.StatusCode)
+ }
+}
+
+// A reader that errors (a host the registry has no row for) is a 503 on the
+// wire, not an empty 200.
+func TestExecBackendStartUpdatesReaderErrorIs503(t *testing.T) {
+ b := testExecBackend(t)
+ b.UpdatesReader = func(string) (instanceupdates.Report, error) { return instanceupdates.Report{}, errors.New("no route") }
+ ctl, token, stop := startAndReachControlSocket(t, b, "upd503.example.com")
+ defer stop()
+ if code, _ := updatesGet(t, ctl, token); code != http.StatusServiceUnavailable {
+ t.Fatalf("GET /updates = %d, want 503", code)
+ }
+}
+
+// With UpdatesReader nil, even with the schedule verbs mounted, /updates is
+// not served.
+func TestExecBackendStartWithNilUpdatesReaderMountsNoUpdates(t *testing.T) {
+ b := testExecBackend(t)
+ b.Timers = &fakeTimerStore{}
+ ctl, token, stop := startAndReachControlSocket(t, b, "noupd.example.com")
+ defer stop()
+ if code, _ := updatesGet(t, ctl, token); code != http.StatusNotFound {
+ t.Fatalf("GET /updates with nil UpdatesReader = %d, want 404", code)
+ }
+}
+
+// The nested layout's listener (prepareAt) mounts the same verb.
+func TestPreparedControlListenerMountsUpdates(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
+ b.GenContainment = true
+ b.UpdatesReader = fakeUpdatesReader
+ p0 := b.pathsFor(rt)
+ if err := os.MkdirAll(p0.runDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ sock := nestedGenSocketIn(p0.runDir, 7)
+ p, ctl, logs, _, err := b.prepareAt(rt, instancectl.RoleRun, sock, 7)
+ if err != nil {
+ t.Fatalf("prepareAt: %v", err)
+ }
+ if logs != nil {
+ defer logs.Close()
+ }
+ if ctl == nil {
+ t.Fatal("prepareAt bound no control listener")
+ }
+ defer ctl.Close()
+ env, err := readEnvFileBeneath(p.dataDir, rt.Host+".env")
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, rep := updatesGet(t, sock+".control", env["CARLOS_ADMIN_TOKEN"])
+ if code != http.StatusOK || rep.Host != rt.Host || rep.Installed != "v1.2.0" {
+ t.Fatalf("GET /updates on the prepared listener = %d %+v", code, rep)
+ }
+}
diff --git a/internal/console/apisystemgeoip.go b/internal/console/apisystemgeoip.go
index 51ca9fb28..bec97a9a6 100644
--- a/internal/console/apisystemgeoip.go
+++ b/internal/console/apisystemgeoip.go
@@ -203,11 +203,20 @@ func (k geoipBoxKey) String() string {
// be read: informational only, because an expected box whose record was
// unreadable is missing from boxes and blocks as "no heartbeat" on its own
// (codex R9 on R1-16).
+//
+// fleetsListed is true only once the fleet registry was opened and its
+// fleets listed; fleetsUnread says why not, for the operator log. The GeoIP
+// verdict never reads it: it treats an unset or absent registry as a
+// platform-only deployment, as it always has. The update settings gate
+// (updatesEnforced) does read it, because a fleet list it never saw is not
+// evidence that there are no fleet boxes to wait for (spec §4.4).
type geoipInventoryRead struct {
expected map[geoipBoxKey]bool
boxes map[geoipBoxKey]status.Box
expectedComplete bool
heartbeatsComplete bool
+ fleetsListed bool
+ fleetsUnread string
}
// geoipWantState is what the published pointer asks of every mirror.
@@ -256,21 +265,26 @@ func (s *Server) geoipInventory(ctx context.Context) (geoipInventoryRead, error)
return inv, err
}
if s.cfg.Registry == "" {
+ inv.fleetsUnread = "no registry path is configured"
return inv, nil
}
reg, err := s.openPassRegistry()
switch {
case errors.Is(err, os.ErrNotExist):
+ inv.fleetsUnread = err.Error()
return inv, nil
case err != nil:
inv.expectedComplete = false
+ inv.fleetsUnread = err.Error()
return inv, nil
}
fleets, err := reg.ListFleets()
if err != nil {
inv.expectedComplete = false
+ inv.fleetsUnread = "listing fleets: " + err.Error()
return inv, nil
}
+ inv.fleetsListed = true
for _, f := range fleets {
fbs, err := reg.ListFleetBoxes(f.Name)
if err != nil {
diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go
new file mode 100644
index 000000000..1a9fc9389
--- /dev/null
+++ b/internal/console/apiupdates.go
@@ -0,0 +1,629 @@
+// The instance update settings doors (instance update settings spec §3.3,
+// §4.4, §5.1): GET and PUT /api/cli/apps/{account}/{app}/instances/{host}/updates,
+// and POST …/updates/install ("Update now", handleAPIUpdatesInstall); and the
+// app's policy door, PUT /api/cli/apps/{account}/{app}/updates (§3.2,
+// handleAPIUpdatesPolicy), which writes the instances manifest.
+//
+// A member of the app sets an instance's preferences: updates (auto or
+// manual), a major hold on stable, and a pin to one authorized build. They
+// are preferences among builds §4.1 already authorizes, not a security
+// boundary (§2 decision 5), and the console never chooses a build: it writes
+// the record, and root decides on its next pass.
+//
+// Who: people only. Every service credential is refused at the role gate
+// (svcrole.Set{}): svcrole.Instance is app-wide, and the instance-bound
+// credential §3.3 limits to instance_control is #155 step 6.
+//
+// When: pin, manual and major are turned ON only while every box runs a
+// binary that applies them (§4.4, updatesEnforced); turning them off is
+// never refused. The member is told how many boxes are not ready; which
+// boxes is operator detail, logged here and never shown.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "math"
+ "net/http"
+ "os"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+type updatesSettings struct {
+ Updates string `json:"updates"`
+ Channel string `json:"channel"`
+ Major *int `json:"major"`
+ Pin string `json:"pin"`
+}
+
+// updatesView is what both doors answer with. Settings are the effective
+// values, Explicit the record's own, and Enforced whether every box applies
+// them (plan 5 renders its absence as "settings not enforced").
+type updatesView struct {
+ Host string `json:"host"`
+ Settings updatesSettings `json:"settings"`
+ Explicit updatesSettings `json:"explicit"`
+ Effective release.UpdateSettings `json:"effective"`
+ Control []string `json:"control"`
+ InstallRequest *provision.InstallRequest `json:"install_request"`
+ Enforced bool `json:"enforced"`
+ MajorIgnored bool `json:"major_ignored"`
+}
+
+func (s *Server) updatesViewOf(ctx context.Context, rec provision.Record, policy *provision.UpdatePolicy) updatesView {
+ eff := provision.EffectiveUpdates(rec, policy)
+ ok, _, _ := s.updatesEnforced(ctx)
+ return updatesView{
+ Host: rec.Host,
+ Settings: updatesSettings{Updates: eff.Updates, Channel: eff.Channel, Major: eff.Major, Pin: eff.Pin},
+ Explicit: updatesSettings{Updates: rec.Updates, Channel: rec.Channel, Major: rec.Major, Pin: rec.Pin},
+ Effective: eff,
+ Control: append([]string{}, eff.Control...),
+ InstallRequest: rec.InstallRequest,
+ Enforced: ok,
+ MajorIgnored: rec.Major != nil && eff.Major == nil,
+ }
+}
+
+// updatesTarget reads the record and the app policy for a settings door,
+// answering the family's 404 for a host with no record. ok=false means a
+// response has been written.
+func (s *Server) updatesTarget(w http.ResponseWriter, r *http.Request, ctx context.Context, acct *Account, app string) (*provision.Record, string, *provision.UpdatePolicy, bool) {
+ host := r.PathValue("host")
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ http.NotFound(w, r)
+ return nil, "", nil, false
+ case err != nil:
+ log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.read-failed"))
+ return nil, "", nil, false
+ }
+ m, err := provision.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ log.Printf("console: api updates %s/%s: manifest: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed"))
+ return nil, "", nil, false
+ }
+ var policy *provision.UpdatePolicy
+ if m != nil {
+ policy = m.Updates
+ }
+ return rec, etag, policy, true
+}
+
+// refuseShape answers §4.5's refusals: a named-unit route keeps today's
+// adoption, and only instances have update settings.
+func refuseShape(w http.ResponseWriter, rec *provision.Record) bool {
+ switch {
+ case rec.Unit != "":
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.named-unit"), rec.Host))
+ return true
+ case rec.KindOrDefault() != "instance":
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-instance"), rec.Host, rec.KindOrDefault()))
+ return true
+ }
+ return false
+}
+
+// handleAPIUpdatesGet is GET …/instances/{host}/updates (spec §5.1).
+func (s *Server) handleAPIUpdatesGet(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, _, policy, ok := s.updatesTarget(w, r, ctx, acct, app)
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy))
+}
+
+// handleAPIUpdatesPut is PUT …/instances/{host}/updates: a member sets any of
+// updates, major and pin; a key present with "" or null clears it, and any
+// other value of the wrong type is refused rather than read as a clear. It
+// writes preferences only: the console never chooses a build (spec §4).
+func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var req map[string]any
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if _, has := req["channel"]; has {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.channel-elsewhere"))
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, policy, ok := s.updatesTarget(w, r, ctx, acct, app)
+ if !ok || refuseShape(w, rec) {
+ return
+ }
+ next := *rec
+ // majorSet: this request sets major to a number the record does not
+ // already hold. A major re-sent unchanged (a form that sends its full
+ // state) is not a new major, so it is not refused off stable either.
+ majorSet := false
+ if v, has := req["updates"]; has {
+ str, ok := updatesValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates"))
+ return
+ }
+ next.Updates = str
+ }
+ if v, has := req["major"]; has {
+ major, ok := majorValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
+ return
+ }
+ next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major)
+ }
+ if v, has := req["pin"]; has {
+ var str string
+ switch p := v.(type) {
+ case nil:
+ case string:
+ str = p
+ default:
+ raw, _ := json.Marshal(v)
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(string(raw))))
+ return
+ }
+ if str != "" && (!release.ValidVersion(str) || str == "dev") {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), shownValue(str)))
+ return
+ }
+ next.Pin = str
+ }
+ // Only a major THIS request sets or changes is refused off stable. A
+ // major left by a channel move is ignored by reconcile (and shown as
+ // major_ignored); it neither blocks an unrelated change, nor a clear sent
+ // beside it unchanged, nor is it silently cleared by one (R4). Validation
+ // below runs with it masked.
+ if majorSet && next.Channel != "stable" {
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel))
+ return
+ }
+ check := next
+ if !majorSet && check.Channel != "stable" {
+ check.Major = nil
+ }
+ if err := provision.ValidateUpdates(check); err != nil {
+ // Every shape ValidateUpdates refuses is refused above with reviewed
+ // words; reaching this is a new shape, and its plain text is API detail.
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ changes := updatesChanges(*rec, next)
+ if len(changes) == 0 {
+ // Nothing changed: no write, no serial bump, no audit line, and
+ // nothing to refuse.
+ writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, *rec, policy))
+ return
+ }
+ if next.Pin != "" && next.Pin != rec.Pin {
+ if _, err := release.ReadPromotionRecord(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, next.Pin); err != nil {
+ if errors.Is(err, release.ErrNoPromotionRecord) || errors.Is(err, os.ErrNotExist) {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pin-unpromoted"), next.Pin))
+ return
+ }
+ log.Printf("console: api updates %s/%s/%s: promotion record %s: %v", acct.Sqid, app, rec.Host, next.Pin, err)
+ jsonError(w, http.StatusBadGateway, fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), next.Pin))
+ return
+ }
+ }
+ // Only a change that turns a setting ON waits for the fleet: manual newly
+ // set, a different pin, or a new major. A value re-sent unchanged beside
+ // a clear (a form that sends its full state) is not an enable, so the
+ // clear is never refused (§4.4).
+ enabling := (next.Updates == release.UpdatesManual && rec.Updates != release.UpdatesManual) ||
+ (next.Pin != "" && next.Pin != rec.Pin) ||
+ (next.Major != nil && !sameMajor(rec.Major, next.Major))
+ if enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+"/"+rec.Host) {
+ return
+ }
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
+ return
+ }
+ log.Printf("console: api updates %s/%s/%s: %v", acct.Sqid, app, rec.Host, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed"))
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "instance-updates", TargetType: "instance", Target: rec.Host,
+ Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-set"), rec.Host, strings.Join(changes, ", "))})
+ // A box-consumed write: reconcile resolves the settings into the
+ // registry from this record, so announcing it is what makes it prompt.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, s.updatesViewOf(ctx, next, policy))
+}
+
+// enableAllowed is the §4.4 gate both settings doors put in front of a change
+// that turns a setting ON. It answers true when every box applies update
+// settings; otherwise it writes the refusal and answers false. A member is
+// told how many boxes are not ready (R3); which boxes is operator detail,
+// logged with what (the target the log line names) and never shown.
+func (s *Server) enableAllowed(ctx context.Context, w http.ResponseWriter, what string) bool {
+ ok, blocking, err := s.updatesEnforced(ctx)
+ switch {
+ case err != nil:
+ log.Printf("console: api updates %s: enable refused, the box inventory could not be read: %v", what, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.enforcement-unknown"))
+ return false
+ case !ok:
+ log.Printf("console: api updates %s: enable refused, not every box applies update settings: %s", what, strings.Join(blocking, "; "))
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), len(blocking)))
+ return false
+ }
+ return true
+}
+
+// updatesValue reads an updates value from a JSON value: "auto" or "manual"
+// sets it, null or "" clears it (""), and anything else is refused.
+func updatesValue(v any) (string, bool) {
+ str, isStr := v.(string)
+ if (v != nil && !isStr) || (str != "" && str != release.UpdatesAuto && str != release.UpdatesManual) {
+ return "", false
+ }
+ return str, true
+}
+
+// majorValue reads a major hold from a JSON value: a whole number from 0 to
+// MaxInt32 sets it, null or "" clears it (nil), and anything else is refused.
+func majorValue(v any) (*int, bool) {
+ switch n := v.(type) {
+ case nil:
+ return nil, true
+ case float64:
+ if n < 0 || n > math.MaxInt32 || n != math.Trunc(n) {
+ return nil, false
+ }
+ i := int(n)
+ return &i, true
+ case string:
+ return nil, n == ""
+ default:
+ return nil, false
+ }
+}
+
+// updatesChanges names what differs between the record before and after, in
+// the activity line's words: "updates manual", "major 2", "pin cleared".
+func updatesChanges(before, after provision.Record) []string {
+ var out []string
+ if before.Updates != after.Updates {
+ out = append(out, "updates "+orCleared(after.Updates))
+ }
+ if !sameMajor(before.Major, after.Major) {
+ if after.Major == nil {
+ out = append(out, "major cleared")
+ } else {
+ out = append(out, fmt.Sprintf("major %d", *after.Major))
+ }
+ }
+ if before.Pin != after.Pin {
+ out = append(out, "pin "+orCleared(after.Pin))
+ }
+ return out
+}
+
+func sameMajor(a, b *int) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return *a == *b
+}
+
+func orCleared(v string) string {
+ if v == "" {
+ return "cleared"
+ }
+ return v
+}
+
+// shownValue is a refused value as a member may be shown it: printable
+// characters only, at most 64 of them, and "that" when nothing is left.
+func shownValue(v string) string {
+ v = strings.Map(func(r rune) rune {
+ if unicode.IsPrint(r) {
+ return r
+ }
+ return -1
+ }, v)
+ if rs := []rune(v); len(rs) > 64 {
+ v = string(rs[:64]) + "..."
+ }
+ if v == "" {
+ return "that"
+ }
+ return v
+}
+
+// handleAPIUpdatesInstall is POST …/instances/{host}/updates/install: "Update
+// now" (spec §4.2, §4.3 rule 2). It raises the record's install_request id by
+// one under the record's etag, retrying a lost race, so ids only ever
+// increase and no request is lost. Root resolves it to a build; the console
+// never names one.
+//
+// It is not one of the settings §4.4 holds until every box applies them: a
+// request a box does not yet act on asks for nothing it could not already get.
+// fast_track (skip the bake window) needs an owner of the account, the
+// authority the console's own fast-track door requires (index Q7). The API
+// cannot run that door's passkey ceremony, so it asks for the role alone.
+func (s *Server) handleAPIUpdatesInstall(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var req struct {
+ FastTrack bool `json:"fast_track"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if req.FastTrack {
+ role, err := s.store.Role(acct.ID, principal)
+ if err != nil {
+ log.Printf("console: api updates install %s/%s: fast track refused, the role could not be read: %v", acct.Sqid, app, err)
+ }
+ if err != nil || role != RoleOwner {
+ jsonError(w, http.StatusForbidden, updatewords.Text("settings.console.fast-track-owner"))
+ return
+ }
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ for attempt := 0; attempt < 5; attempt++ {
+ rec, etag, _, ok := s.updatesTarget(w, r, ctx, acct, app)
+ if !ok || refuseShape(w, rec) {
+ return
+ }
+ if rec.Pin != "" {
+ jsonError(w, http.StatusConflict, fmt.Sprintf(updatewords.Text("settings.console.pinned"), rec.Host, rec.Pin))
+ return
+ }
+ var id uint64 = 1
+ if rec.InstallRequest != nil {
+ id = rec.InstallRequest.ID + 1
+ }
+ next := *rec
+ next.InstallRequest = &provision.InstallRequest{ID: id, FastTrack: req.FastTrack, At: time.Now().UTC().Truncate(time.Second), By: principal}
+ err := provision.UpdateRecord(ctx, s.cfg.Releases, next, etag)
+ if errors.Is(err, release.ErrCASConflict) {
+ continue // another writer moved the record: read it again
+ }
+ if err != nil {
+ log.Printf("console: api updates install %s/%s/%s: %v", acct.Sqid, app, rec.Host, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed"))
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "instance-update-now", TargetType: "instance", Target: rec.Host,
+ Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-install"), rec.Host, id)})
+ // Box-consumed: root resolves the request on its next pass.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, map[string]uint64{"request": id})
+ return
+ }
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
+}
+
+// handleAPIUpdatesPolicy is PUT /api/cli/apps/{account}/{app}/updates: the
+// app's instance update policy (spec §3.2), on its instances manifest. The
+// policy is four keys: instance_control (which settings an instance may
+// change itself), default_updates, default_major, and default_channel, which
+// is the manifest's own Channel, the one new instances are created on. A key
+// present with null or "" clears it, except default_channel, which can be
+// changed but never cleared, as at the instances-manifest door. Any other
+// value of the wrong type is refused rather than read as a clear.
+//
+// Who: app members, people only. It writes the manifest, standing policy
+// about the bounds the app's instances run inside, so it is closed to every
+// service credential for the instances-manifest door's reason (svcrole.Set{}
+// at the registration, apiPerson here as the second wall).
+//
+// When: a default manual or a default major is turned ON only while every box
+// applies update settings (§4.4, enableAllowed); clearing is never refused,
+// and instance_control and default_channel never wait.
+func (s *Server) handleAPIUpdatesPolicy(w http.ResponseWriter, r *http.Request) {
+ if !s.apiPerson(w, r) {
+ return
+ }
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ var req map[string]any
+ if !readJSON(w, r, &req) {
+ return
+ }
+ // The policy has exactly these four keys (spec rev 11 §3.2): there is no
+ // default pin, and an unknown key is refused rather than ignored, so a
+ // caller who sends one learns it did nothing. Checked before the store is
+ // read, and in key order so the answer names the same key every time.
+ keys := make([]string, 0, len(req))
+ for k := range req {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ switch k {
+ case "instance_control", "default_updates", "default_channel", "default_major":
+ default:
+ jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), shownValue(k)))
+ return
+ }
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ m, etag, err := provision.GetManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.no-manifest"))
+ return
+ case err != nil:
+ log.Printf("console: api updates policy %s/%s: manifest: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.policy-read-failed"))
+ return
+ }
+ var before provision.UpdatePolicy
+ if m.Updates != nil {
+ before = *m.Updates
+ }
+ next := before
+ nextChannel := m.Channel
+ if v, has := req["instance_control"]; has {
+ control, ok := controlValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-control"))
+ return
+ }
+ next.InstanceControl = control
+ }
+ if v, has := req["default_updates"]; has {
+ str, ok := updatesValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-updates"))
+ return
+ }
+ next.DefaultUpdates = str
+ }
+ if v, has := req["default_major"]; has {
+ major, ok := majorValue(v)
+ if !ok {
+ jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
+ return
+ }
+ next.DefaultMajor = major
+ }
+ if v, has := req["default_channel"]; has {
+ // The instances-manifest door's check and its words: a manifest's
+ // channel is what new instances are created on, and "clearing" it is
+ // not a policy act this door offers.
+ str, isStr := v.(string)
+ if !isStr || !release.ValidChannel(str) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ nextChannel = str
+ }
+ if err := provision.ValidatePolicy(&next); err != nil {
+ // Every shape ValidatePolicy refuses is refused above with reviewed
+ // words; reaching this is a new shape, and its plain text is API detail.
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ changes := policyChanges(before, next, m.Channel, nextChannel)
+ if len(changes) == 0 {
+ // Nothing changed: no write, no serial bump, no audit line, and
+ // nothing to refuse.
+ writeJSON(w, http.StatusOK, policyView(m.Channel, before))
+ return
+ }
+ // Only a change that turns a default ON waits for the fleet: manual newly
+ // set, or a new major. A value re-sent unchanged beside a clear is not an
+ // enable, so the clear is never refused (§4.4).
+ enabling := (next.DefaultUpdates == release.UpdatesManual && before.DefaultUpdates != release.UpdatesManual) ||
+ (next.DefaultMajor != nil && !sameMajor(before.DefaultMajor, next.DefaultMajor))
+ if enabling && !s.enableAllowed(ctx, w, acct.Sqid+"/"+app+" policy") {
+ return
+ }
+ out := *m
+ out.Channel = nextChannel
+ out.Updates = nil
+ if next.InstanceControl != nil || next.DefaultUpdates != "" || next.DefaultMajor != nil {
+ p := next
+ out.Updates = &p
+ }
+ if err := provision.PutManifestIfMatch(ctx, s.cfg.Releases, acct.Sqid, app, out, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, updatewords.Text("settings.console.changed"))
+ return
+ }
+ log.Printf("console: api updates policy %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, updatewords.Text("settings.console.store-failed"))
+ return
+ }
+ s.auditActivity(acct, app, principal, Activity{Action: "app-update-policy", TargetType: "app", Target: app,
+ Detail: fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), app, strings.Join(changes, ", "))})
+ // Box-consumed: reconcile reads the manifest's policy and channel on its
+ // next pass, so announcing the write is what makes it prompt.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, policyView(nextChannel, next))
+}
+
+// controlValue reads instance_control from a JSON value: an array of setting
+// names, each once, sets it; null or [] clears it (nil); anything else,
+// including an array holding a non-string, is refused.
+func controlValue(v any) ([]string, bool) {
+ switch list := v.(type) {
+ case nil:
+ return nil, true
+ case []any:
+ var out []string
+ for _, x := range list {
+ str, ok := x.(string)
+ if !ok {
+ return nil, false
+ }
+ out = append(out, str)
+ }
+ return out, release.ValidControl(out)
+ default:
+ return nil, false
+ }
+}
+
+// policyChanges names what differs between the policy before and after, in
+// the activity line's words: "instance_control updates,pin", "default
+// updates cleared", "default major 2", "default channel beta".
+func policyChanges(before, after provision.UpdatePolicy, channelBefore, channelAfter string) []string {
+ var out []string
+ if !slices.Equal(before.InstanceControl, after.InstanceControl) {
+ out = append(out, "instance_control "+orCleared(strings.Join(after.InstanceControl, ",")))
+ }
+ if before.DefaultUpdates != after.DefaultUpdates {
+ out = append(out, "default updates "+orCleared(after.DefaultUpdates))
+ }
+ if !sameMajor(before.DefaultMajor, after.DefaultMajor) {
+ if after.DefaultMajor == nil {
+ out = append(out, "default major cleared")
+ } else {
+ out = append(out, fmt.Sprintf("default major %d", *after.DefaultMajor))
+ }
+ }
+ if channelBefore != channelAfter {
+ out = append(out, "default channel "+channelAfter)
+ }
+ return out
+}
+
+// policyView is the policy door's answer. instance_control is always an
+// array, [] when no setting is the instance's own.
+func policyView(channel string, p provision.UpdatePolicy) map[string]any {
+ return map[string]any{
+ "instance_control": append([]string{}, p.InstanceControl...),
+ "default_updates": p.DefaultUpdates,
+ "default_channel": channel,
+ "default_major": p.DefaultMajor,
+ }
+}
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go
new file mode 100644
index 000000000..075aeb6ad
--- /dev/null
+++ b/internal/console/apiupdates_test.go
@@ -0,0 +1,1065 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/svcrole"
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+const updURL = "/api/cli/apps/bab/km/instances/a.example.com/updates"
+
+// updatesFx is newConsoleFixture with app km in the ops account bab: v1 and
+// v2 shipped, v1 promoted to stable (so v1 has a promotion record and v2 has
+// none), and one instance record, a.example.com on stable with no settings.
+type updatesFx struct {
+ *consoleFixture
+ t *testing.T
+ store release.Store
+ member string // a CLI bearer for alice, a plain (non-owner) member of bab
+ owner string // a CLI bearer for paul, an owner of the ops account bab: the account in updURL
+ instanceToken string // an app-wide svcrole.Instance credential for km
+}
+
+func updatesFixture(t *testing.T) *updatesFx {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "km", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "km", "v1")
+ shipVersion(t, fx, "km", "v2")
+ seedChannel(t, fx, "km", "stable", "v1")
+ ux := &updatesFx{
+ consoleFixture: fx,
+ t: t,
+ store: fx.rel,
+ member: cliToken(t, fx, "alice@keymail.dev", "code-updates-member"),
+ owner: cliToken(t, fx, "paul@keymail.dev", "code-updates-owner"),
+ instanceToken: serviceToken(t, fx.st, OpsAccountID, "router", "km", svcrole.Instance),
+ }
+ ux.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable"})
+ ux.putRecord(provision.Record{Schema: 1, Host: "a.example.com", Slug: "a", App: "km",
+ Account: release.OpsAccount, Channel: "stable", CreatedAt: now.UTC()})
+ return ux
+}
+
+// putRecord replaces (or creates) the record at rec's key with rec.
+func (fx *updatesFx) putRecord(rec provision.Record) {
+ fx.t.Helper()
+ ctx := context.Background()
+ if err := fx.store.Delete(ctx, provision.RecordKey(rec.Account, rec.App, rec.Host)); err != nil {
+ fx.t.Fatal(err)
+ }
+ if err := provision.CreateRecord(ctx, fx.store, rec); err != nil {
+ fx.t.Fatal(err)
+ }
+}
+
+func (fx *updatesFx) record(host string) provision.Record {
+ fx.t.Helper()
+ rec, err := provision.GetRecord(context.Background(), fx.store, release.OpsAccount, "km", host)
+ if err != nil {
+ fx.t.Fatal(err)
+ }
+ return *rec
+}
+
+// setManifest writes km's instances manifest (and so its update policy).
+func (fx *updatesFx) setManifest(m provision.Manifest) {
+ fx.t.Helper()
+ enableInstances(fx.t, fx.consoleFixture, "km", m)
+}
+
+// manifest reads km's instances manifest back.
+func (fx *updatesFx) manifest() provision.Manifest {
+ fx.t.Helper()
+ m, err := provision.GetManifest(context.Background(), fx.store, release.OpsAccount, "km")
+ if err != nil {
+ fx.t.Fatal(err)
+ }
+ return *m
+}
+
+// createInstance creates host through the console's create door as the
+// member, naming no channel, so the record takes the manifest's.
+func (fx *updatesFx) createInstance(t *testing.T, host string) {
+ t.Helper()
+ st, _, out := rawReq(t, http.MethodPost, instancesURL(fx.consoleFixture, "bab", "km"), fx.member, `{"host":"`+host+`"}`)
+ if st != http.StatusCreated {
+ t.Fatalf("create %s: %d %s", host, st, out)
+ }
+}
+
+// boxesEnforce makes the deployment one box, ie-1: an edges record and a
+// fresh heartbeat whose binary does (on) or does not apply update settings.
+func (fx *updatesFx) boxesEnforce(on bool) {
+ fx.t.Helper()
+ if _, _, err := fx.store.Get(context.Background(), "control/edges/ie-1.json"); err != nil {
+ seedEdgesRecord(fx.t, fx.consoleFixture, "ie-1")
+ }
+ m := &status.BoxMetrics{}
+ if on {
+ m.UpdateSettings = status.UpdateSettingsProtocol
+ }
+ seedSettingsBox(fx.t, fx.consoleFixture, "ie-1", time.Now(), m)
+}
+
+// sendJSON is rawReq with body marshalled to JSON.
+func sendJSON(t *testing.T, method, u string, body any, bearer string) (int, string) {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, _, out := rawReq(t, method, u, bearer, string(raw))
+ return st, out
+}
+
+func TestUpdatesGetShowsDefaults(t *testing.T) {
+ fx := updatesFixture(t)
+ st, out := getRaw(t, fx.ts.URL+updURL, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"updates":"auto"`) || !strings.Contains(out, `"enforced":false`) {
+ t.Fatalf("GET: %d %s", st, out)
+ }
+}
+
+func TestUpdatesPutIsRefusedUntilEveryBoxEnforces(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, "every box") {
+ t.Fatalf("manual before the fleet enforces: %d %s", st, out)
+ }
+ // R3: a member is told how many boxes, never which.
+ if strings.Contains(out, "ie-1") || !strings.Contains(out, "1 boxes") {
+ t.Fatalf("the refusal must give a count and name no box: %s", out)
+ }
+ if fx.record("a.example.com").Updates != "" {
+ t.Fatal("a refused enable wrote the record")
+ }
+ fx.boxesEnforce(true)
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusOK || fx.record("a.example.com").Updates != release.UpdatesManual {
+ t.Fatalf("manual once enforced: %d %s", st, out)
+ }
+}
+
+// R3: when the gate cannot read the boxes at all, enabling is refused with
+// its own words, and the store's error text never reaches the member.
+func TestUpdatesPutIsRefusedWhenEnforcementIsUnknown(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ fx.srv.cfg.Releases = unlistableStore{fx.rel}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusBadGateway || !strings.Contains(out, updatewords.Text("settings.console.enforcement-unknown")) {
+ t.Fatalf("enable with an unreadable inventory: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+ // Clearing is still allowed.
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "auto"}, fx.member); st != http.StatusOK {
+ t.Fatalf("disabling with an unreadable inventory: %d %s", st, out)
+ }
+}
+
+// Review Focus 5.
+func TestClearingIsNeverRefused(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"})
+ fx.boxesEnforce(false)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": ""}, fx.member)
+ if st != http.StatusOK || fx.record("a.example.com").Pin != "" {
+ t.Fatalf("clearing a pin: %d %s", st, out)
+ }
+}
+
+// Fix round 1: a value re-sent unchanged beside a clear is not an enable, so
+// a client that sends its full state can still clear while the fleet does not
+// enforce. A changed value still waits.
+func TestClearingBesideAnUnchangedEnableIsNeverRefused(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable",
+ Updates: release.UpdatesManual, Pin: "v1"})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual", "pin": ""}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" || rec.Updates != release.UpdatesManual {
+ t.Fatalf("manual re-sent, pin cleared: %d %s, record %+v", st, out, rec)
+ }
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable",
+ Updates: release.UpdatesManual, Pin: "v1"})
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v1", "updates": "auto"}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "v1" || rec.Updates != release.UpdatesAuto {
+ t.Fatalf("pin re-sent, manual turned off: %d %s, record %+v", st, out, rec)
+ }
+ two := 2
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable",
+ Updates: release.UpdatesManual, Major: &two})
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "updates": "auto"}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Major == nil || *rec.Major != 2 || rec.Updates != release.UpdatesAuto {
+ t.Fatalf("major re-sent, manual turned off: %d %s, record %+v", st, out, rec)
+ }
+ // A changed value is still an enable, and still waits.
+ for _, body := range []map[string]any{{"major": 3}, {"pin": "v1", "updates": "auto"}, {"updates": "manual"}} {
+ before := fx.record("a.example.com")
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, body, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, "every box") {
+ t.Errorf("%v before the fleet enforces: %d %s", body, st, out)
+ }
+ if after := fx.record("a.example.com"); after.Updates != before.Updates || after.Pin != before.Pin || !sameMajor(after.Major, before.Major) {
+ t.Errorf("%v: a refused enable wrote the record: %+v -> %+v", body, before, after)
+ }
+ }
+}
+
+func TestPinMustHaveAPromotionRecord(t *testing.T) {
+ fx := updatesFixture(t) // ships km v1 and v2 and promotes v1 to stable
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v2"}, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, "promotion record") {
+ t.Fatalf("pin of an unpromoted build: %d %s", st, out)
+ }
+ st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v1"}, fx.member)
+ if st != http.StatusOK || fx.record("a.example.com").Pin != "v1" {
+ t.Fatalf("pin of a promoted build: %d", st)
+ }
+}
+
+// R7: a record that is there but cannot be verified is not "no record": it
+// is a 502 that asks for a retry, and the pin is not written.
+func TestPinCheckFailureIsNotNoRecord(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ if _, err := fx.store.Put(context.Background(), release.PromotionRecordKey("bab", "km", "v2"), []byte(`{"payload":"x","sig":"AAAA"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"pin": "v2"}, fx.member)
+ want := fmt.Sprintf(updatewords.Text("settings.console.pin-check-failed"), "v2")
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != want {
+ t.Fatalf("pin with an unverifiable record: %d %s", st, out)
+ }
+ if fx.record("a.example.com").Pin != "" {
+ t.Fatal("the pin was written")
+ }
+}
+
+// Strict input: a value of the wrong type is refused, never read as a clear.
+func TestUpdatesPutRefusesWrongTypes(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable",
+ Updates: release.UpdatesManual, Pin: "v1"})
+ for _, c := range []struct {
+ body string
+ want string
+ }{
+ {`{"updates":5}`, updatewords.Text("settings.console.bad-updates")},
+ {`{"updates":true}`, updatewords.Text("settings.console.bad-updates")},
+ {`{"updates":"sometimes"}`, updatewords.Text("settings.console.bad-updates")},
+ {`{"pin":true}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "true")},
+ {`{"pin":5}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "5")},
+ {`{"pin":"dev"}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "dev")},
+ {`{"pin":"../v1"}`, fmt.Sprintf(updatewords.Text("settings.console.bad-pin"), "../v1")},
+ {`{"major":-1}`, updatewords.Text("settings.console.bad-major")},
+ {`{"major":1.5}`, updatewords.Text("settings.console.bad-major")},
+ {`{"major":"2"}`, updatewords.Text("settings.console.bad-major")},
+ {`{"major":1e300}`, updatewords.Text("settings.console.bad-major")},
+ } {
+ st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+updURL, fx.member, c.body)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != c.want {
+ t.Errorf("%s: %d %s, want 400 %q", c.body, st, out, c.want)
+ }
+ }
+ if rec := fx.record("a.example.com"); rec.Updates != release.UpdatesManual || rec.Pin != "v1" || rec.Major != nil {
+ t.Fatalf("a refused request changed the record: %+v", rec)
+ }
+ // null and "" clear.
+ if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+updURL, fx.member, `{"updates":null,"pin":""}`); st != http.StatusOK {
+ t.Fatalf("clearing with null and empty: %d %s", st, out)
+ }
+ if rec := fx.record("a.example.com"); rec.Updates != "" || rec.Pin != "" {
+ t.Fatalf("null and empty did not clear: %+v", rec)
+ }
+}
+
+func TestMajorOnlyOnStable(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta"})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2}, fx.member)
+ if st != http.StatusBadRequest || !strings.Contains(out, "stable") {
+ t.Fatalf("major off stable: %d %s", st, out)
+ }
+}
+
+// R4: a major left behind by a channel move is ignored by reconcile and shown
+// as major_ignored; it neither blocks an unrelated change nor is cleared by it.
+func TestStaleMajorOffStableIsLeftAlone(t *testing.T) {
+ fx := updatesFixture(t)
+ two := 2
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta", Major: &two})
+ st, out := getRaw(t, fx.ts.URL+updURL, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"major_ignored":true`) {
+ t.Fatalf("GET of a stale major: %d %s", st, out)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "auto"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("an unrelated change on a record with a stale major: %d %s", st, out)
+ }
+ if rec := fx.record("a.example.com"); rec.Updates != release.UpdatesAuto || rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("record after the change: %+v", rec)
+ }
+ // Clearing the stale major is allowed too.
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": nil}, fx.member); st != http.StatusOK || fx.record("a.example.com").Major != nil {
+ t.Fatalf("clearing a stale major: %d %s", st, out)
+ }
+}
+
+// Astra r1 #2: a client that sends its full state resends the stale major
+// beside a clear. The unchanged major is not a new major off stable: the
+// clear goes through, the major is kept (R4), and that holds while the fleet
+// gate is closed too. A changed major off stable is still refused.
+func TestFullStateClearBesideAStaleMajor(t *testing.T) {
+ for _, enforced := range []bool{true, false} {
+ t.Run(fmt.Sprintf("enforced=%v", enforced), func(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(enforced)
+ two := 2
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta",
+ Updates: release.UpdatesManual, Major: &two, Pin: "v1"})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": ""}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" || rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("pin cleared beside the unchanged stale major: %d %s, record %+v", st, out, rec)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": "", "updates": "auto"}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Updates != release.UpdatesAuto || rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("manual turned off beside the unchanged stale major: %d %s, record %+v", st, out, rec)
+ }
+ // A changed major off stable is still refused, before the gate.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 3, "pin": ""}, fx.member)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), "a.example.com", "beta") {
+ t.Fatalf("a changed major off stable: %d %s", st, out)
+ }
+ if rec := fx.record("a.example.com"); rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("a refused major was written: %+v", rec)
+ }
+ })
+ }
+}
+
+func TestMajorOnStableIsSetAndCleared(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 3}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Major == nil || *rec.Major != 3 {
+ t.Fatalf("major on stable: %d %s", st, out)
+ }
+ if !strings.Contains(out, `"major":3`) || !strings.Contains(out, `"major_ignored":false`) {
+ t.Fatalf("the view after the change: %s", out)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": ""}, fx.member)
+ if st != http.StatusOK || fx.record("a.example.com").Major != nil {
+ t.Fatalf("clearing major with empty: %d %s", st, out)
+ }
+}
+
+// A change bumps the serial once and leaves an activity line; a request that
+// changes nothing does neither.
+func TestUpdatesPutAnnouncesOnlyChanges(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ before := serial(t, fx.consoleFixture)
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{}, fx.member); st != http.StatusOK {
+ t.Fatalf("empty PUT: %d %s", st, out)
+ }
+ if got := serial(t, fx.consoleFixture); got != before {
+ t.Fatalf("an empty PUT bumped the serial: %d -> %d", before, got)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK {
+ t.Fatalf("manual: %d %s", st, out)
+ }
+ if got := serial(t, fx.consoleFixture); got != before+1 {
+ t.Fatalf("serial = %d, want one bump from %d", got, before)
+ }
+ acts, err := fx.st.Events(OpsAccountID, "km", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := fmt.Sprintf(updatewords.Text("settings.console.audit-set"), "a.example.com", "updates manual")
+ found := false
+ for _, a := range acts {
+ if a.Action == "instance-updates" && a.Detail == want && a.Address == "alice@keymail.dev" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("no activity line %q by alice in %+v", want, acts)
+ }
+}
+
+func TestUpdatesDoorsRefuseNamedUnitsAndOtherKinds(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Unit: "console.service", Addr: "127.0.0.1:8080"})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, "systemd unit") {
+ t.Fatalf("named unit: %d %s", st, out)
+ }
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Kind: "service", Addr: "127.0.0.1:8080"})
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.not-instance"), "a.example.com", "service") {
+ t.Fatalf("service route: %d %s", st, out)
+ }
+ // Clearing is refused on these too: the door does not apply to them at all.
+ if rec := fx.record("a.example.com"); rec.Updates != "" {
+ t.Fatalf("a refused request wrote the record: %+v", rec)
+ }
+}
+
+func TestUpdatesDoorsRefuseChannel(t *testing.T) {
+ fx := updatesFixture(t)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"channel": "beta"}, fx.member)
+ if st != http.StatusBadRequest || !strings.Contains(out, updatewords.Text("settings.console.channel-elsewhere")) {
+ t.Fatalf("channel through /updates: %d %s", st, out)
+ }
+}
+
+// An unknown host is the family's 404, for both doors.
+func TestUpdatesDoorsUnknownHostIs404(t *testing.T) {
+ fx := updatesFixture(t)
+ u := fx.ts.URL + "/api/cli/apps/bab/km/instances/nope.example.com/updates"
+ if st, out := getRaw(t, u, fx.member); st != http.StatusNotFound {
+ t.Fatalf("GET of an unknown host: %d %s", st, out)
+ }
+ if st, out := sendJSON(t, http.MethodPut, u, map[string]string{"updates": "auto"}, fx.member); st != http.StatusNotFound {
+ t.Fatalf("PUT of an unknown host: %d %s", st, out)
+ }
+}
+
+// The instance-bound credential is #155 step 6: until then no service
+// credential reaches any of the four doors.
+func TestUpdatesDoorsRefuseAppWideCredentials(t *testing.T) {
+ fx := updatesFixture(t)
+ for _, c := range []struct{ method, url string }{
+ {http.MethodGet, updURL}, {http.MethodPut, updURL},
+ {http.MethodPost, updURL + "/install"}, {http.MethodPut, "/api/cli/apps/bab/km/updates"},
+ } {
+ st, _ := sendJSON(t, c.method, fx.ts.URL+c.url, map[string]string{}, fx.instanceToken)
+ if st != http.StatusNotFound && st != http.StatusForbidden {
+ t.Errorf("%s %s with an app-wide instance credential: %d", c.method, c.url, st)
+ }
+ }
+}
+
+func TestTheConsoleNeverChoosesABuild(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ before := fx.record("a.example.com")
+ if st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK {
+ t.Fatal(st)
+ }
+ after := fx.record("a.example.com")
+ if after.Pin != before.Pin || after.Channel != before.Channel {
+ t.Fatalf("a preference change moved a build: %+v -> %+v", before, after)
+ }
+ _ = context.Background()
+}
+
+func TestInstallBumpsTheRequestAndChoosesNoBuild(t *testing.T) {
+ fx := updatesFixture(t)
+ before := fx.record("a.example.com")
+ serialBefore := serial(t, fx.consoleFixture)
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"request":1`) {
+ t.Fatalf("install: %d %s", st, out)
+ }
+ rec := fx.record("a.example.com")
+ if rec.InstallRequest == nil || rec.InstallRequest.ID != 1 || rec.InstallRequest.FastTrack ||
+ rec.InstallRequest.By != "alice@keymail.dev" || rec.InstallRequest.At.IsZero() {
+ t.Fatalf("record = %+v", rec)
+ }
+ // The console never names a build: the pin, channel and settings are as
+ // they were, and only the request moved.
+ if rec.Pin != before.Pin || rec.Channel != before.Channel || rec.Updates != before.Updates || !sameMajor(rec.Major, before.Major) {
+ t.Fatalf("Update now moved a build or a setting: %+v -> %+v", before, rec)
+ }
+ if got := serial(t, fx.consoleFixture); got != serialBefore+1 {
+ t.Fatalf("serial = %d, want one bump from %d", got, serialBefore)
+ }
+ acts, err := fx.st.Events(OpsAccountID, "km", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := fmt.Sprintf(updatewords.Text("settings.console.audit-install"), "a.example.com", 1)
+ found := false
+ for _, a := range acts {
+ if a.Action == "instance-update-now" && a.Detail == want && a.Address == "alice@keymail.dev" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("no activity line %q by alice in %+v", want, acts)
+ }
+ st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"request":2`) || fx.record("a.example.com").InstallRequest.ID != 2 {
+ t.Fatalf("the second request did not increase the id: %d %s", st, out)
+ }
+}
+
+// Update now is not one of the settings §4.4 holds until every box applies
+// them: it is answered while the fleet does not enforce.
+func TestInstallIsNotHeldForTheFleet(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ if st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member); st != http.StatusOK {
+ t.Fatalf("install before the fleet enforces: %d %s", st, out)
+ }
+}
+
+// installRaceStore holds the first two reads of the instance record until
+// both have arrived, so the two requests decide from the same etag and one
+// of them must lose the write and retry.
+type installRaceStore struct {
+ release.Store
+ key string
+ mu sync.Mutex
+ arrived int
+ etags []string
+ both chan struct{}
+}
+
+func (s *installRaceStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ body, etag, err := s.Store.Get(ctx, key)
+ if key != s.key {
+ return body, etag, err
+ }
+ s.mu.Lock()
+ if s.arrived >= 2 {
+ s.mu.Unlock()
+ return body, etag, err
+ }
+ s.arrived++
+ s.etags = append(s.etags, etag)
+ if s.arrived == 2 {
+ close(s.both)
+ }
+ s.mu.Unlock()
+ select {
+ case <-s.both:
+ case <-time.After(10 * time.Second):
+ }
+ return body, etag, err
+}
+
+// installNoFatal is one install request that reports rather than fails, for
+// use off the test goroutine.
+func installNoFatal(u, bearer string) (int, string, error) {
+ req, err := http.NewRequest(http.MethodPost, u, strings.NewReader(`{}`))
+ if err != nil {
+ return 0, "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return 0, "", err
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ return res.StatusCode, string(out), err
+}
+
+// Review Focus 3: two requests that read the same etag both succeed, and the
+// ids they leave are 1 then 2. Neither is lost and no id repeats.
+func TestInstallRequestsRaceToIncreasingIDs(t *testing.T) {
+ fx := updatesFixture(t)
+ rs := &installRaceStore{Store: fx.rel, key: provision.RecordKey(release.OpsAccount, "km", "a.example.com"), both: make(chan struct{})}
+ fx.srv.cfg.Releases = rs
+ type result struct {
+ st int
+ out string
+ err error
+ }
+ done := make(chan result, 2)
+ for i := 0; i < 2; i++ {
+ go func() {
+ st, out, err := installNoFatal(fx.ts.URL+updURL+"/install", fx.member)
+ done <- result{st, out, err}
+ }()
+ }
+ var outs []string
+ for i := 0; i < 2; i++ {
+ r := <-done
+ if r.err != nil || r.st != http.StatusOK {
+ t.Fatalf("a racing request failed: %d %s %v", r.st, r.out, r.err)
+ }
+ outs = append(outs, r.out)
+ }
+ rs.mu.Lock()
+ etags := append([]string(nil), rs.etags...)
+ rs.mu.Unlock()
+ if len(etags) != 2 || etags[0] != etags[1] {
+ t.Fatalf("the two requests did not race from one read: etags %q", etags)
+ }
+ if id := fx.record("a.example.com").InstallRequest.ID; id != 2 {
+ t.Fatalf("after two racing requests the id is %d, want 2", id)
+ }
+ joined := strings.Join(outs, " ")
+ if !strings.Contains(joined, `"request":1`) || !strings.Contains(joined, `"request":2`) {
+ t.Fatalf("the two answers must be requests 1 and 2: %s", joined)
+ }
+}
+
+// installCASStore loses every record write's race, as a store would whose
+// record another writer keeps moving.
+type installCASStore struct {
+ release.Store
+ key string
+}
+
+func (s installCASStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ body, etag, err := s.Store.Get(ctx, key)
+ if key == s.key && err == nil {
+ // Each read answers a fresh etag, so the write's own read never
+ // matches the one the request decided from.
+ etag = fmt.Sprintf("%s-%d", etag, time.Now().UnixNano())
+ }
+ return body, etag, err
+}
+
+// R7: only a lost race retries, and five lost races are 409 changed; any
+// other write failure is a 502 that does not leak the store's words.
+func TestInstallGivesUpAfterFiveLostRaces(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.srv.cfg.Releases = installCASStore{fx.rel, provision.RecordKey(release.OpsAccount, "km", "a.example.com")}
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.changed") {
+ t.Fatalf("install that always loses: %d %s", st, out)
+ }
+ if fx.record("a.example.com").InstallRequest != nil {
+ t.Fatal("a lost request wrote the record")
+ }
+}
+
+func TestInstallStoreFailureIsA502(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.srv.cfg.Releases = unputtableStore{fx.rel}
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.store-failed") {
+ t.Fatalf("install with an unwritable store: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+}
+
+type unputtableStore struct{ release.Store }
+
+func (u unputtableStore) Put(context.Context, string, []byte, string) (string, error) {
+ return "", errors.New(brokenStoreErr)
+}
+
+func TestInstallIsRefusedOnAPinnedInstance(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"})
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.pinned"), "a.example.com", "v1") {
+ t.Fatalf("install on a pinned instance: %d %s", st, out)
+ }
+ if fx.record("a.example.com").InstallRequest != nil {
+ t.Fatal("a refused request wrote the record")
+ }
+}
+
+func TestInstallRefusesNamedUnitsOtherKindsAndUnknownHosts(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Unit: "console.service", Addr: "127.0.0.1:8080"})
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.named-unit"), "a.example.com") {
+ t.Fatalf("named unit: %d %s", st, out)
+ }
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Kind: "service", Addr: "127.0.0.1:8080"})
+ st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.not-instance"), "a.example.com", "service") {
+ t.Fatalf("service route: %d %s", st, out)
+ }
+ if fx.record("a.example.com").InstallRequest != nil {
+ t.Fatal("a refused request wrote the record")
+ }
+ u := fx.ts.URL + "/api/cli/apps/bab/km/instances/nope.example.com/updates/install"
+ if st, out := sendJSON(t, http.MethodPost, u, map[string]any{}, fx.member); st != http.StatusNotFound {
+ t.Fatalf("install on an unknown host: %d %s", st, out)
+ }
+}
+
+// R8: fast_track is an account owner's; the check is the role, made before
+// the store is read.
+func TestFastTrackInstallNeedsAnOwner(t *testing.T) {
+ fx := updatesFixture(t) // fx.member is a plain member of bab; fx.owner an owner of bab
+ st, out := sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, fx.member)
+ if st != http.StatusForbidden || jsonErrorText(t, out) != updatewords.Text("settings.console.fast-track-owner") {
+ t.Fatalf("a member skipped bake: %d %s", st, out)
+ }
+ if fx.record("a.example.com").InstallRequest != nil {
+ t.Fatal("a refused fast track wrote the record")
+ }
+ st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": true}, fx.owner)
+ rec := fx.record("a.example.com")
+ if st != http.StatusOK || rec.InstallRequest == nil || !rec.InstallRequest.FastTrack || rec.InstallRequest.By != "paul@keymail.dev" {
+ t.Fatalf("an owner's fast-track request: %d %s, record %+v", st, out, rec)
+ }
+ // A plain member's ordinary request is still theirs to make, and clears
+ // the fast track the next request does not ask for.
+ st, out = sendJSON(t, http.MethodPost, fx.ts.URL+updURL+"/install", map[string]any{"fast_track": false}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.InstallRequest.ID != 2 || rec.InstallRequest.FastTrack {
+ t.Fatalf("a member's ordinary request after a fast track: %d %s, record %+v", st, out, rec)
+ }
+}
+
+const policyURL = "/api/cli/apps/bab/km/updates"
+
+func TestPolicyDoorSetsDefaults(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"instance_control": []string{"updates"}, "default_updates": "manual"}, fx.member)
+ if st != http.StatusOK || !strings.Contains(out, `"default_updates":"manual"`) {
+ t.Fatalf("policy: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates == nil || m.Updates.DefaultUpdates != "manual" || m.Channel != "stable" {
+ t.Fatalf("manifest = %+v", m)
+ }
+}
+
+func TestPolicyDefaultsAreGatedLikeSettings(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": 2}, fx.member)
+ if st != http.StatusConflict {
+ t.Fatalf("a default major before every box enforces: %d", st)
+ }
+ st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"pin"}}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("instance_control enables nothing by itself: %d", st)
+ }
+ st, _ = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"bake"}}, fx.member)
+ if st != http.StatusBadRequest {
+ t.Fatalf("an unknown control name: %d", st)
+ }
+}
+
+// Spec rev 11 §3.2: there is no default pin.
+func TestPolicyDoorHasNoDefaultPin(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_pin": "v1"}, fx.member)
+ if st != http.StatusBadRequest || !strings.Contains(out, "default_pin") {
+ t.Fatalf("a default pin: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates != nil {
+ t.Fatalf("the refused request wrote a policy: %+v", m.Updates)
+ }
+}
+
+// Spec rev 11 §3.2: the channel default applies only when an instance is
+// created; every record carries its channel from creation.
+func TestDefaultChannelReachesOnlyNewInstances(t *testing.T) {
+ fx := updatesFixture(t) // a.example.com exists on stable
+ st, _ := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatal(st)
+ }
+ if ch := fx.record("a.example.com").Channel; ch != "stable" {
+ t.Fatalf("an existing instance moved to %s", ch)
+ }
+ rec := fx.record("a.example.com")
+ if u := provision.EffectiveUpdates(rec, fx.manifest().Updates); u.Channel != "stable" {
+ t.Fatalf("effective channel %s", u.Channel)
+ }
+ fx.createInstance(t, "b.example.com") // through the console's create door, no channel given
+ if ch := fx.record("b.example.com").Channel; ch != "beta" {
+ t.Fatalf("a new instance got %s, want the default beta", ch)
+ }
+}
+
+// Decision 7: the key check comes before any store read, so an unknown key
+// is the 400 even for an app with no instances manifest.
+func TestPolicyUnknownKeyIsRefusedBeforeTheStore(t *testing.T) {
+ fx := updatesFixture(t)
+ if err := fx.store.Delete(context.Background(), provision.ManifestKey(release.OpsAccount, "km")); err != nil {
+ t.Fatal(err)
+ }
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_pin": "v1", "default_updates": "auto"}, fx.member)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.unknown-policy-key"), "default_pin") {
+ t.Fatalf("an unknown key on an app with no manifest: %d %s", st, out)
+ }
+}
+
+// Decision 1: only a change that turns a default ON waits for the fleet. A
+// value re-sent unchanged beside a clear is not an enable, and clearing is
+// never refused.
+func TestPolicyClearingBesideAnUnchangedEnableIsNeverRefused(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(false)
+ two := 2
+ policy := &provision.UpdatePolicy{InstanceControl: []string{"pin"}, DefaultUpdates: release.UpdatesManual, DefaultMajor: &two}
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"default_updates": "manual", "default_major": 2, "instance_control": nil}, fx.member)
+ if p := fx.manifest().Updates; st != http.StatusOK || p == nil || p.InstanceControl != nil ||
+ p.DefaultUpdates != release.UpdatesManual || p.DefaultMajor == nil || *p.DefaultMajor != 2 {
+ t.Fatalf("manual and major re-sent, control cleared: %d %s, policy %+v", st, out, p)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": 2, "default_updates": "auto"}, fx.member)
+ if p := fx.manifest().Updates; st != http.StatusOK || p == nil || p.DefaultUpdates != release.UpdatesAuto || p.DefaultMajor == nil || *p.DefaultMajor != 2 {
+ t.Fatalf("major re-sent, manual turned off: %d %s, policy %+v", st, out, p)
+ }
+ // instance_control and default_channel never enable.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"instance_control": []string{"updates", "major"}, "default_channel": "beta"}, fx.member)
+ if st != http.StatusOK {
+ t.Fatalf("control and channel before the fleet enforces: %d %s", st, out)
+ }
+ // A changed value is still an enable, and still waits.
+ for _, body := range []map[string]any{{"default_major": 3}, {"default_updates": "manual"}, {"default_major": 3, "default_updates": "auto"}} {
+ before := fx.manifest()
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, body, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.not-enforced"), 1) {
+ t.Errorf("%v before the fleet enforces: %d %s", body, st, out)
+ }
+ if strings.Contains(out, "ie-1") {
+ t.Errorf("%v: the refusal names a box: %s", body, out)
+ }
+ after := fx.manifest()
+ if after.Updates.DefaultUpdates != before.Updates.DefaultUpdates || !sameMajor(after.Updates.DefaultMajor, before.Updates.DefaultMajor) {
+ t.Errorf("%v: a refused enable wrote the manifest: %+v -> %+v", body, before.Updates, after.Updates)
+ }
+ }
+ // Clearing them is never refused.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": nil, "default_updates": nil, "instance_control": []string{}}, fx.member)
+ if m := fx.manifest(); st != http.StatusOK || m.Updates != nil || m.Channel != "beta" {
+ t.Fatalf("clearing every default: %d %s, manifest %+v", st, out, m)
+ }
+}
+
+// R3 at the policy door: an unreadable inventory refuses an enable with its
+// own words, and a clear still goes through.
+func TestPolicyEnableIsRefusedWhenEnforcementIsUnknown(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ fx.srv.cfg.Releases = unlistableStore{fx.rel}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "manual"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.enforcement-unknown") {
+ t.Fatalf("enable with an unreadable inventory: %d %s", st, out)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member); st != http.StatusOK {
+ t.Fatalf("a non-enabling change with an unreadable inventory: %d %s", st, out)
+ }
+}
+
+// Decision 2: strict input. A value of the wrong type is refused, never read
+// as a clear, and a refused request writes nothing.
+func TestPolicyDoorRefusesWrongTypes(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ one := 1
+ policy := &provision.UpdatePolicy{InstanceControl: []string{"pin"}, DefaultUpdates: release.UpdatesManual, DefaultMajor: &one}
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ badControl := updatewords.Text("settings.console.bad-control")
+ badUpdates := updatewords.Text("settings.console.bad-updates")
+ badMajor := updatewords.Text("settings.console.bad-major")
+ for _, c := range []struct{ body, want string }{
+ {`{"instance_control":"updates"}`, badControl},
+ {`{"instance_control":""}`, badControl},
+ {`{"instance_control":5}`, badControl},
+ {`{"instance_control":{"updates":true}}`, badControl},
+ {`{"instance_control":[1]}`, badControl},
+ {`{"instance_control":["updates",null]}`, badControl},
+ {`{"instance_control":["updates","updates"]}`, badControl},
+ {`{"instance_control":["bake"]}`, badControl},
+ {`{"default_updates":5}`, badUpdates},
+ {`{"default_updates":true}`, badUpdates},
+ {`{"default_updates":"sometimes"}`, badUpdates},
+ {`{"default_major":-1}`, badMajor},
+ {`{"default_major":1.5}`, badMajor},
+ {`{"default_major":"2"}`, badMajor},
+ {`{"default_major":1e300}`, badMajor},
+ {`{"default_major":true}`, badMajor},
+ {`{"default_major":[2]}`, badMajor},
+ } {
+ st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, c.body)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != c.want {
+ t.Errorf("%s: %d %s, want 400 %q", c.body, st, out, c.want)
+ }
+ }
+ if p := fx.manifest().Updates; p == nil || len(p.InstanceControl) != 1 || p.DefaultUpdates != release.UpdatesManual || p.DefaultMajor == nil || *p.DefaultMajor != 1 {
+ t.Fatalf("a refused request changed the policy: %+v", p)
+ }
+ // null and "" clear, for every key that clears.
+ if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, `{"instance_control":null,"default_updates":"","default_major":""}`); st != http.StatusOK {
+ t.Fatalf("clearing with null and empty: %d %s", st, out)
+ }
+ if m := fx.manifest(); m.Updates != nil || m.Channel != "stable" {
+ t.Fatalf("null and empty did not clear: %+v", m)
+ }
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable", Updates: policy})
+ if st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, `{"default_updates":null,"default_major":null}`); st != http.StatusOK {
+ t.Fatalf("clearing with null: %d %s", st, out)
+ }
+ if p := fx.manifest().Updates; p == nil || p.DefaultUpdates != "" || p.DefaultMajor != nil || len(p.InstanceControl) != 1 {
+ t.Fatalf("null did not clear only what it named: %+v", p)
+ }
+}
+
+// Decision 3: default_channel is checked as the instances-manifest door
+// checks its channel, and the manifest's channel cannot be cleared here.
+func TestDefaultChannelIsValidatedAndNeverCleared(t *testing.T) {
+ fx := updatesFixture(t)
+ for _, body := range []string{
+ `{"default_channel":""}`, `{"default_channel":null}`, `{"default_channel":5}`,
+ `{"default_channel":"../x"}`, `{"default_channel":"a/b/c"}`, `{"default_channel":"Beta Two"}`,
+ } {
+ st, _, out := rawReq(t, http.MethodPut, fx.ts.URL+policyURL, fx.member, body)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != "invalid channel name" {
+ t.Errorf("%s: %d %s, want 400 invalid channel name", body, st, out)
+ }
+ }
+ if ch := fx.manifest().Channel; ch != "stable" {
+ t.Fatalf("a refused channel changed the manifest: %s", ch)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "canary/x"}, fx.member); st != http.StatusOK || fx.manifest().Channel != "canary/x" {
+ t.Fatalf("a two-part channel: %d %s", st, out)
+ }
+}
+
+// policyFailingStore fails every read of one key with brokenStoreErr.
+type policyFailingStore struct {
+ release.Store
+ key string
+}
+
+func (s policyFailingStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == s.key {
+ return nil, "", errors.New(brokenStoreErr)
+ }
+ return s.Store.Get(ctx, key)
+}
+
+// Decision 4: no manifest is 409 no-manifest; a manifest that cannot be read
+// is a 502 that does not leak the store's words.
+func TestPolicyDoorManifestReadFailures(t *testing.T) {
+ fx := updatesFixture(t)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ fx.srv.cfg.Releases = policyFailingStore{fx.rel, key}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.policy-read-failed") {
+ t.Fatalf("an unreadable manifest: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+ fx.srv.cfg.Releases = fx.rel
+ if err := fx.store.Delete(context.Background(), key); err != nil {
+ t.Fatal(err)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "auto"}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.no-manifest") {
+ t.Fatalf("no manifest: %d %s", st, out)
+ }
+ if _, _, err := fx.store.Get(context.Background(), key); err == nil {
+ t.Fatal("the refused request wrote a manifest")
+ }
+}
+
+// Decision 5: a lost race is 409 changed; any other write failure is a 502
+// that does not leak the store's words.
+func TestPolicyDoorWriteFailures(t *testing.T) {
+ fx := updatesFixture(t)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ fx.srv.cfg.Releases = installCASStore{fx.rel, key}
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusConflict || jsonErrorText(t, out) != updatewords.Text("settings.console.changed") {
+ t.Fatalf("a lost race: %d %s", st, out)
+ }
+ fx.srv.cfg.Releases = unputtableStore{fx.rel}
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_channel": "beta"}, fx.member)
+ if st != http.StatusBadGateway || jsonErrorText(t, out) != updatewords.Text("settings.console.store-failed") {
+ t.Fatalf("an unwritable store: %d %s", st, out)
+ }
+ if strings.Contains(out, brokenStoreErr) {
+ t.Fatalf("the refusal leaks the store's error: %s", out)
+ }
+ fx.srv.cfg.Releases = fx.rel
+ if ch := fx.manifest().Channel; ch != "stable" {
+ t.Fatalf("a failed write changed the manifest: %s", ch)
+ }
+}
+
+// Decisions 6 and 8: a change bumps the serial once and leaves one activity
+// line; a request that changes nothing writes, bumps and audits nothing, and
+// answers the current policy. instance_control is [] when empty, never null.
+func TestPolicyDoorAnnouncesOnlyChanges(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ key := provision.ManifestKey(release.OpsAccount, "km")
+ _, etag, err := fx.store.Get(context.Background(), key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ before := serial(t, fx.consoleFixture)
+ for _, body := range []map[string]any{{}, {"default_channel": "stable"}, {"default_updates": nil, "default_major": "", "instance_control": []string{}}} {
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, body, fx.member)
+ want := `{"default_channel":"stable","default_major":null,"default_updates":"","instance_control":[]}`
+ if st != http.StatusOK || strings.TrimSpace(out) != want {
+ t.Fatalf("%v: %d %s, want 200 %s", body, st, out, want)
+ }
+ }
+ if _, now, _ := fx.store.Get(context.Background(), key); now != etag {
+ t.Fatal("a request that changed nothing wrote the manifest")
+ }
+ if got := serial(t, fx.consoleFixture); got != before {
+ t.Fatalf("a request that changed nothing bumped the serial: %d -> %d", before, got)
+ }
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL,
+ map[string]any{"instance_control": []string{"updates", "pin"}, "default_major": 2, "default_channel": "beta"}, fx.member)
+ want := `{"default_channel":"beta","default_major":2,"default_updates":"","instance_control":["updates","pin"]}`
+ if st != http.StatusOK || strings.TrimSpace(out) != want {
+ t.Fatalf("a change: %d %s, want %s", st, out, want)
+ }
+ if got := serial(t, fx.consoleFixture); got != before+1 {
+ t.Fatalf("serial = %d, want one bump from %d", got, before)
+ }
+ acts, err := fx.st.Events(OpsAccountID, "km", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantLine := fmt.Sprintf(updatewords.Text("settings.console.audit-policy"), "km", "instance_control updates,pin, default major 2, default channel beta")
+ n := 0
+ for _, a := range acts {
+ if a.Action == "app-update-policy" {
+ n++
+ if a.Detail != wantLine || a.Address != "alice@keymail.dev" {
+ t.Errorf("activity line %+v, want %q by alice", a, wantLine)
+ }
+ }
+ }
+ if n != 1 {
+ t.Fatalf("%d policy activity lines, want 1: %+v", n, acts)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index f2012a7a6..3feb2dc43 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -643,6 +643,18 @@ func (s *Server) registerCLIRoutes() {
// onto a channel whose rules it never met. Widening it later is easy;
// narrowing an authority already granted is not.
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel)
+ // Update settings (instance update settings spec §5.1). Person-only until
+ // #155 step 6 adds an instance-bound credential: svcrole.Instance is
+ // app-wide, so it would let one instance's code set a sibling's pin.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesGet)
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances/{host}/updates", svcrole.Set{}, s.handleAPIUpdatesPut)
+ // "Update now" asks root for the newest build the settings allow; the
+ // same people-only gate, and fast_track only for an account owner.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install", svcrole.Set{}, s.handleAPIUpdatesInstall)
+ // The app's instance update policy (spec §3.2) lives on the instances
+ // manifest, so it is closed for the manifest door's reason: a credential
+ // the app holds must not rewrite the bounds its instances run inside.
+ s.cliRoute("PUT /api/cli/apps/{account}/{app}/updates", svcrole.Set{}, s.handleAPIUpdatesPolicy)
// The logs read (`carlos logs`, apilogs.go) — same gate, same 404
// discipline, and an app token may read its own app's logs.
s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs)
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go
index 25609419c..d8fa4daeb 100644
--- a/internal/console/claim_invariant_fixtures_test.go
+++ b/internal/console/claim_invariant_fixtures_test.go
@@ -792,6 +792,10 @@ var claimFixtures = map[string]claimFixture{
},
}},
+ // The update settings read (settings plan 3), apart so its long pattern
+ // leaves the block above aligned as it was.
+ "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimRead("reads one instance record, the app manifest and the box heartbeats"),
+
// ---- PATCH ----
"route PATCH /api/cli/apps/{account}/{app}/instances/{host}": {door: &claimDoor{
apps: claimRefs("bab/titogo"),
@@ -1208,6 +1212,11 @@ var claimFixtures = map[string]claimFixture{
status: http.StatusConflict,
msg: claimMoveUnsupportedMsg,
}},
+ "route POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: claimWorldInstance,
+ do: claimAPI(http.MethodPost, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates/install", `{}`),
+ }},
"route POST /api/cli/apps/{account}/{app}/keep": {door: &claimDoor{
apps: claimRefs("bab/tmp-claim-keep"),
setup: func(t *testing.T, cx *claimCase) { cx.temporary(t, "tmp-claim-keep") },
@@ -1451,6 +1460,11 @@ var claimFixtures = map[string]claimFixture{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances-manifest", `{}`),
}},
+ "route PUT /api/cli/apps/{account}/{app}/instances/{host}/updates": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: claimWorldInstance,
+ do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `{"updates":"auto"}`),
+ }},
"route PUT /api/cli/apps/{account}/{app}/pipeline": {door: &claimDoor{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/pipeline", `{"channels":[{"name":"edge"}]}`),
@@ -1463,6 +1477,28 @@ var claimFixtures = map[string]claimFixture{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/telemetry", `{"errors":{"log_level":"warn"}}`),
}},
+ // The app's instance update policy (settings plan 3 Task 8) writes the
+ // instances manifest, as the instances-manifest door does.
+ "route PUT /api/cli/apps/{account}/{app}/updates": {door: &claimDoor{
+ apps: claimRefs("bab/titogo"),
+ setup: func(t *testing.T, cx *claimCase) {
+ t.Helper()
+ cx.instanceWorld(t)
+ },
+ do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/updates", `{"default_channel":"beta","instance_control":["updates"]}`),
+ check: func(t *testing.T, cx *claimCase, open bool) string {
+ m, err := provision.GetManifest(context.Background(), cx.rel, "bab", "titogo")
+ switch {
+ case err != nil:
+ return err.Error()
+ case open && (m.Channel != "beta" || m.Updates == nil):
+ return "the titogo manifest carries no new policy after the open run: the handler never reached its write"
+ case !open && (m.Channel != "stable" || m.Updates != nil):
+ return "the titogo manifest's policy changed while titogo was closed under a foreign exclusive operation"
+ }
+ return ""
+ },
+ }},
"route PUT /api/cli/apps/{account}/{app}/version-target": {door: &claimDoor{
apps: claimRefs("bab/titogo"),
do: claimAPI(http.MethodPut, "/api/cli/apps/bab/titogo/version-target", `{"target":"0.5.0"}`),
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go
index c94acb2ac..c801bd0b6 100644
--- a/internal/console/claim_invariant_probes_read_test.go
+++ b/internal/console/claim_invariant_probes_read_test.go
@@ -190,6 +190,8 @@ var claimReadProbes = map[string]claimProbe{
"/api/cli/apps/bab/titogo/instances/acme.go.tito.io", "acme.go.tito.io"),
"route GET /api/cli/apps/{account}/{app}/instances/{host}/move": claimAPIRead(claimWorldMove,
"/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move", `"state":"none"`),
+ "route GET /api/cli/apps/{account}/{app}/instances/{host}/updates": claimAPIRead(claimWorldInstance,
+ "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/updates", `"host":"acme.go.tito.io"`, `"updates":"auto"`),
"route GET /api/cli/apps/{account}/{app}/ledgers": claimAPIRead(claimWorldLedger, "/api/cli/apps/bab/titogo/ledgers", `"carbon"`),
"route GET /api/cli/apps/{account}/{app}/ledgers/{ledger}/head": claimAPIRead(claimWorldLedger,
"/api/cli/apps/bab/titogo/ledgers/carbon/head", `"head_sha256"`),
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 61a55de7d..efcab1ca3 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -48,6 +48,9 @@ import (
// which domain, pool, regions and channel an app may ever be brought up on.
// A credential the app itself holds must not rewrite the bounds it runs
// inside, which is a fact about the door's subject and not about any role.
+// /{app}/updates is the same manifest's update policy (settings spec §3.2),
+// including which settings an instance may change itself: the bound on the
+// instance credential, which that credential must never widen.
//
// The email family adds one entry to each list, and both are facts about the
// door's subject rather than about any role:
@@ -75,7 +78,7 @@ import (
var (
mintDoors = []string{"/instance-token", "/door-keys", "/api/cli/services/", "/email/credentials"}
fleetDoors = []string{"/api/cli/fleets/"}
- policyDoors = []string{"/instances-manifest", "/{app}/routes", "/email/pause", "/build-probe"}
+ policyDoors = []string{"/instances-manifest", "/{app}/updates", "/{app}/routes", "/email/pause", "/build-probe"}
systemDoors = []string{"/api/cli/system/"}
)
@@ -118,6 +121,12 @@ var closedDoors = []string{
// the registration says so where the role table can be read.
"GET /api/cli/apps/{account}/{app}/routes",
"PUT /api/cli/apps/{account}/{app}/instances-manifest",
+ // The app's instance update policy (settings spec §3.2) is written onto
+ // that same manifest: which settings an instance may change itself, and
+ // the defaults new and existing instances follow. Shut for the manifest
+ // door's reason, and the instance-bound credential (#155 step 6) does not
+ // open it either: an instance must not widen its own control.
+ "PUT /api/cli/apps/{account}/{app}/updates",
// The fleet-wide live tenancy read (discussion #11). Shut to every
// credential for a reason that is about the door's SUBJECT rather than
// any role: the answer names every account's instances on every box.
@@ -179,6 +188,15 @@ var closedDoors = []string{
// whose rules it never met. Shut for the same reason the manifest door
// is — an app may not rewrite the bounds it runs inside.
"POST /api/cli/apps/{account}/{app}/instances/{host}/channel",
+ // Instance update settings (settings spec §3.3): a member's preference
+ // about which authorized build an instance runs. svcrole.Instance is
+ // app-wide, so it would let one instance's code pin a sibling; the
+ // instance-bound credential that may reach these is #155 step 6.
+ "GET /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ "PUT /api/cli/apps/{account}/{app}/instances/{host}/updates",
+ // "Update now" (settings plan 3 Task 7) asks root to move this instance:
+ // the same app-wide reason, and fast_track is an account owner's alone.
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install",
// The `carlos db` replica door (apidb.go) serves the whole customer
// database, file by file. No credential the app holds needs it: the app
// reads its own database live, and CI has no business pulling
@@ -917,8 +935,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 48 {
- t.Errorf("walked %d closed doors, want all %d (and 48 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)",
+ if seen != len(closedDoors) || seen != 52 {
+ t.Errorf("walked %d closed doors, want all %d (and 52 today: instance-token, three door-key doors, the routes door, instances-manifest, the app update policy, sidecars-manifest, the route-channel move, three instance update settings doors, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, the activation-cancel door, seven system doors, two deployment-wide antivirus, three platform geoip doors, two pointer digest doors, the channel resign, two release authorize doors, and four store-entitlement doors)",
seen, len(closedDoors))
}
}
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt
index 789d2c392..ec0d0ec94 100644
--- a/internal/console/testdata/claim-routes.txt
+++ b/internal/console/testdata/claim-routes.txt
@@ -173,8 +173,11 @@ route POST /api/cli/apps/{account}/{app}/features
route POST /api/cli/apps/{account}/{app}/instance-token
route POST /api/cli/apps/{account}/{app}/instances
route POST /api/cli/apps/{account}/{app}/instances/{host}/channel
+route GET /api/cli/apps/{account}/{app}/instances/{host}/updates
+route PUT /api/cli/apps/{account}/{app}/instances/{host}/updates
route POST /api/cli/apps/{account}/{app}/instances/{host}/move
route POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut
+route POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install
route POST /api/cli/apps/{account}/{app}/keep
route POST /api/cli/apps/{account}/{app}/ledgers/publish
route POST /api/cli/apps/{account}/{app}/ledgers/{ledger}/append
@@ -243,6 +246,7 @@ route PUT /api/cli/apps/{account}/{app}/instances-manifest
route PUT /api/cli/apps/{account}/{app}/pipeline
route PUT /api/cli/apps/{account}/{app}/sidecars-manifest
route PUT /api/cli/apps/{account}/{app}/telemetry
+route PUT /api/cli/apps/{account}/{app}/updates
route PUT /api/cli/apps/{account}/{app}/version-target
route PUT /api/cli/system/geoip
route PUT /api/cli/system/upload
diff --git a/internal/console/updatesgate.go b/internal/console/updatesgate.go
new file mode 100644
index 000000000..cba70c470
--- /dev/null
+++ b/internal/console/updatesgate.go
@@ -0,0 +1,67 @@
+package console
+
+import (
+ "context"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// updatesEnforced is §4.4's roll-order gate: pin, manual and major may be
+// turned on only while every box in the deployment reports a binary that
+// applies them and protects the builds they name from retention. The housekeeper
+// duty moves between boxes, so one old box anywhere could prune a pinned
+// build. Expected boxes are the edges records and active fleet members, as for
+// the GeoIP rollout verdict (geoipInventory); a box that is expected and has
+// no fresh heartbeat blocks, and so does an incomplete read. Unlike the GeoIP
+// verdict, an incomplete heartbeat read blocks here too, and so do an
+// inventory that found no box at all and a fleet registry that was never
+// read (unset, absent, or failing): a gate that has not seen every box
+// cannot vouch for every box.
+//
+// The blocking lines name platform box labels and fleet names. They are
+// operator detail, logged by the doors and never shown to app members.
+func (s *Server) updatesEnforced(ctx context.Context) (ok bool, blocking []string, err error) {
+ inv, err := s.geoipInventory(ctx)
+ if err != nil {
+ return false, nil, err
+ }
+ now := time.Now()
+ keys := map[geoipBoxKey]bool{}
+ for k := range inv.expected {
+ keys[k] = true
+ }
+ for k, b := range inv.boxes {
+ if !status.BoxStale(b, now, status.SnapshotPeriod) {
+ keys[k] = true
+ }
+ }
+ for k := range keys {
+ b, seen := inv.boxes[k]
+ switch {
+ case !seen:
+ blocking = append(blocking, k.String()+": no heartbeat")
+ case status.BoxStale(b, now, status.SnapshotPeriod):
+ blocking = append(blocking, k.String()+": no heartbeat since "+b.GeneratedAt.UTC().Format(time.RFC3339))
+ case b.Metrics == nil || b.Metrics.UpdateSettings < status.UpdateSettingsProtocol:
+ blocking = append(blocking, k.String()+": runs a binary that does not apply update settings")
+ }
+ }
+ if len(keys) == 0 {
+ blocking = append(blocking, "no box was found to report a binary")
+ }
+ // An unset or absent registry is a fleet list nobody read, not an empty
+ // one (Astra r1 #1): the GeoIP verdict may read it as platform-only, but
+ // this gate cannot vouch for fleet boxes it never listed. A registry that
+ // failed to open or list also leaves expectedComplete false; this one
+ // line, with the reason, covers both.
+ if !inv.fleetsListed {
+ blocking = append(blocking, "the fleet registry could not be read ("+inv.fleetsUnread+"), so no fleet box was checked")
+ }
+ if (inv.fleetsListed && !inv.expectedComplete) || !inv.heartbeatsComplete {
+ blocking = append(blocking, "the list of boxes could not be read in full")
+ }
+ sort.Strings(blocking)
+ return len(blocking) == 0, blocking, nil
+}
diff --git a/internal/console/updatesgate_test.go b/internal/console/updatesgate_test.go
new file mode 100644
index 000000000..c4630efd5
--- /dev/null
+++ b/internal/console/updatesgate_test.go
@@ -0,0 +1,283 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+ "github.com/carlosframework/platform/internal/updatewords"
+)
+
+// seedSettingsBox writes label's heartbeat at at carrying m as its private
+// metrics block (nil writes a heartbeat with no metrics at all).
+func seedSettingsBox(t *testing.T, fx *consoleFixture, label string, at time.Time, m *status.BoxMetrics) {
+ t.Helper()
+ b := status.CollectBox(nil, status.Probes{Release: "v1"}, at)
+ b.Label = label
+ b.Metrics = m
+ if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestUpdatesEnforcedNeedsEveryBox(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ seedEdgesRecord(t, fx, "uk-1")
+ now := time.Now()
+ seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{})
+ ok, blocking, err := fx.srv.updatesEnforced(context.Background())
+ if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "uk-1") {
+ t.Fatalf("one old binary: %v %q %v", ok, blocking, err)
+ }
+ seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ if ok, _, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil {
+ t.Fatalf("every box enforces: %v %v", ok, err)
+ }
+}
+
+// Review Focus 4.
+func TestUpdatesEnforcedNamesAStaleBox(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ seedSettingsBox(t, fx, "ie-1", time.Now().Add(-3*status.SnapshotPeriod), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ ok, blocking, _ := fx.srv.updatesEnforced(context.Background())
+ if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") {
+ t.Fatalf("a stale box: %v %q", ok, blocking)
+ }
+}
+
+func TestUpdatesEnforcedNeedsAHeartbeat(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ if ok, blocking, _ := fx.srv.updatesEnforced(context.Background()); ok || len(blocking) != 1 {
+ t.Fatalf("an expected box with no heartbeat: %v %q", ok, blocking)
+ }
+}
+
+// An empty inventory has seen no box at all, so it cannot vouch that every
+// box enforces: it blocks rather than reading as "nothing to wait on".
+func TestUpdatesEnforcedNeedsSomeBox(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); ok || err != nil || len(blocking) != 1 {
+ t.Fatalf("no box known: %v %q %v", ok, blocking, err)
+ }
+}
+
+// seedSettingsFleet registers fleet fx with one active box, bx-1, in the
+// fixture's registry, and (when at is not zero) writes bx-1's heartbeat
+// under the fleet's prefix carrying m.
+func seedSettingsFleet(t *testing.T, fx *consoleFixture, at time.Time, m *status.BoxMetrics) {
+ t.Helper()
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: fx.sqid, Bucket: "fx-data"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-1", TokenHash: strings.Repeat("a", 64)}); err != nil {
+ t.Fatal(err)
+ }
+ if at.IsZero() {
+ return
+ }
+ b := status.CollectBox(nil, status.Probes{Release: "v1"}, at)
+ b.Label = "bx-1"
+ b.Metrics = m
+ if err := status.WriteBox(context.Background(), release.NewPrefixStore(fx.rel, "fleets/fx/"), b); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Astra r1 #1: an unread fleet inventory is not an empty one. A console
+// whose registry path is unset, or names no file, has not listed the
+// fleets, so it cannot vouch for their boxes and the gate blocks, while the
+// GeoIP verdict's reading of the same inventory is unchanged (its
+// expected list stays complete, as before).
+func TestUpdatesEnforcedNeedsTheFleetRegistry(t *testing.T) {
+ for name, path := range map[string]func(t *testing.T) string{
+ "unset": func(*testing.T) string { return "" },
+ "missing": func(t *testing.T) string { return filepath.Join(t.TempDir(), "absent-registry.db") },
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ seedSettingsBox(t, fx, "ie-1", time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil {
+ t.Fatalf("with the registry read: %v %q %v", ok, blocking, err)
+ }
+ fx.srv.cfg.Registry = path(t)
+ ok, blocking, err := fx.srv.updatesEnforced(context.Background())
+ if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "the fleet registry could not be read") {
+ t.Fatalf("registry %s: %v %q %v", name, ok, blocking, err)
+ }
+ inv, err := fx.srv.geoipInventory(context.Background())
+ if err != nil || !inv.expectedComplete || !inv.heartbeatsComplete {
+ t.Fatalf("the GeoIP reading of an absent registry changed: %+v %v", inv, err)
+ }
+ })
+ }
+}
+
+// Task 5's deferred gap: an active fleet box is expected, keyed by its
+// fleet, and blocks with no report, an old binary or a stale report; once
+// it reports the protocol the gate opens.
+func TestUpdatesEnforcedNeedsEveryFleetBox(t *testing.T) {
+ now := time.Now()
+ for name, c := range map[string]struct {
+ at time.Time
+ m *status.BoxMetrics
+ want string
+ }{
+ "no report": {want: "fx/bx-1: no heartbeat"},
+ "an old binary": {at: now, m: &status.BoxMetrics{}, want: "fx/bx-1: runs a binary that does not apply update settings"},
+ "a stale report": {at: now.Add(-3 * status.SnapshotPeriod), m: &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol},
+ want: "fx/bx-1: no heartbeat since"},
+ "the protocol": {at: now, m: &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ seedSettingsFleet(t, fx, c.at, c.m)
+ ok, blocking, err := fx.srv.updatesEnforced(context.Background())
+ if c.want == "" {
+ if !ok || err != nil {
+ t.Fatalf("a fleet box that applies settings: %v %q %v", ok, blocking, err)
+ }
+ return
+ }
+ if err != nil || ok || len(blocking) != 1 || !strings.HasPrefix(blocking[0], c.want) {
+ t.Fatalf("want %q blocking: %v %q %v", c.want, ok, blocking, err)
+ }
+ })
+ }
+}
+
+// Task 5's deferred gap: a heartbeat record that cannot be read leaves the
+// read incomplete, and an incomplete read blocks even when every box that
+// was read applies settings.
+func TestUpdatesEnforcedBlocksAnIncompleteRead(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedEdgesRecord(t, fx, "ie-1")
+ seedSettingsBox(t, fx, "ie-1", time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ if _, err := fx.rel.Put(context.Background(), "status/boxes/zz-1.json", []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ ok, blocking, err := fx.srv.updatesEnforced(context.Background())
+ if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "could not be read in full") {
+ t.Fatalf("an unreadable heartbeat: %v %q %v", ok, blocking, err)
+ }
+}
+
+// Astra r1 #1 through the doors: whenever the gate cannot vouch for every
+// box (no registry path, a registry file that is not there, a fleet box with
+// no report, an old binary or a stale report, an unreadable heartbeat), both
+// enabling doors refuse with the not-enforced 409 and write nothing, and
+// clearing through both still answers 200 in that same state.
+func TestEnableDoorsFailClosedOnAnUncheckedBox(t *testing.T) {
+ now := time.Now()
+ notEnforced := strings.SplitN(updatewords.Text("settings.console.not-enforced"), "%d", 2)[0]
+ for name, breakIt := range map[string]func(t *testing.T, fx *updatesFx){
+ "registry unset": func(t *testing.T, fx *updatesFx) { fx.srv.cfg.Registry = "" },
+ "registry missing": func(t *testing.T, fx *updatesFx) {
+ fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "absent-registry.db")
+ },
+ "fleet box with no report": func(t *testing.T, fx *updatesFx) {
+ seedSettingsFleet(t, fx.consoleFixture, time.Time{}, nil)
+ },
+ "fleet box with an old binary": func(t *testing.T, fx *updatesFx) {
+ seedSettingsFleet(t, fx.consoleFixture, now, &status.BoxMetrics{})
+ },
+ "fleet box with a stale report": func(t *testing.T, fx *updatesFx) {
+ seedSettingsFleet(t, fx.consoleFixture, now.Add(-3*status.SnapshotPeriod), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ },
+ "an unreadable heartbeat": func(t *testing.T, fx *updatesFx) {
+ if _, err := fx.rel.Put(context.Background(), "status/boxes/zz-1.json", []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ two := 2
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable", Pin: "v1"})
+ fx.setManifest(provision.Manifest{Schema: 1, App: "km", Domain: "example.com", Channel: "stable",
+ Updates: &provision.UpdatePolicy{DefaultMajor: &two}})
+ breakIt(t, fx)
+
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, notEnforced) {
+ t.Errorf("instance door, manual: %d %s", st, out)
+ }
+ if rec := fx.record("a.example.com"); rec.Updates != "" {
+ t.Errorf("a refused instance enable wrote the record: %+v", rec)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "manual"}, fx.member)
+ if st != http.StatusConflict || !strings.Contains(out, notEnforced) {
+ t.Errorf("policy door, default manual: %d %s", st, out)
+ }
+ if p := fx.manifest().Updates; p == nil || p.DefaultUpdates != "" {
+ t.Errorf("a refused policy enable wrote the policy: %+v", p)
+ }
+
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"pin": ""}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" {
+ t.Fatalf("instance door, clearing a pin: %d %s, record %+v", st, out, rec)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_major": nil}, fx.member)
+ if p := fx.manifest().Updates; st != http.StatusOK || (p != nil && p.DefaultMajor != nil) {
+ t.Fatalf("policy door, clearing the default major: %d %s, policy %+v", st, out, p)
+ }
+ })
+ }
+}
+
+// The control for the test above: with the registry read and the fleet box
+// reporting the protocol, both doors enable.
+func TestEnableDoorsOpenWhenEveryFleetBoxEnforces(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(true)
+ seedSettingsFleet(t, fx.consoleFixture, time.Now(), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]string{"updates": "manual"}, fx.member); st != http.StatusOK {
+ t.Fatalf("instance door: %d %s", st, out)
+ }
+ if st, out := sendJSON(t, http.MethodPut, fx.ts.URL+policyURL, map[string]any{"default_updates": "manual"}, fx.member); st != http.StatusOK {
+ t.Fatalf("policy door: %d %s", st, out)
+ }
+}
+
+// A heartbeat with no metrics block is a binary that does not say it
+// enforces; an unexpected box that has gone stale is retired and does not
+// block; an unexpected fresh box with an old binary does.
+func TestUpdatesEnforcedBoxShapes(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ seedEdgesRecord(t, fx, "ie-1")
+ seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ seedSettingsBox(t, fx, "old-1", now.Add(-time.Hour), &status.BoxMetrics{})
+ if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil {
+ t.Fatalf("a retired box blocked: %v %q %v", ok, blocking, err)
+ }
+ seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{})
+ ok, blocking, _ := fx.srv.updatesEnforced(context.Background())
+ if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "new-1") {
+ t.Fatalf("an unlisted fresh old binary: %v %q", ok, blocking)
+ }
+ seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol})
+ seedSettingsBox(t, fx, "ie-1", now, nil)
+ ok, blocking, _ = fx.srv.updatesEnforced(context.Background())
+ if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") {
+ t.Fatalf("a heartbeat with no metrics: %v %q", ok, blocking)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index e71dd6fb0..77bfafda0 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -185,6 +185,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/instancefs"
+ "github.com/carlosframework/platform/internal/instanceupdates"
"github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
@@ -364,6 +365,9 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// so the row is due-indexed by the next tick with nothing in
// between. reg satisfies schedule.TimerStore directly.
Timers: reg,
+ // The control socket's read-only GET /updates answers from the
+ // same registry copy (instance update settings spec §5.1).
+ UpdatesReader: instanceupdates.RegistryReader(reg),
// Asked inside Acquire, before the lease (amadan #213).
ConfigGate: configGate(),
}
diff --git a/internal/instanceupdates/control.go b/internal/instanceupdates/control.go
new file mode 100644
index 000000000..a115b9e9b
--- /dev/null
+++ b/internal/instanceupdates/control.go
@@ -0,0 +1,94 @@
+// Package instanceupdates is the control socket's read-only view of an
+// instance's update settings (instance update settings spec §3.3, §5.1). It
+// is mounted beside schedule.Mount's verbs with the same bearer and the same
+// fixed-host rule. Writes ship with #155 step 6's instance-bound credential,
+// through the console, never through this socket.
+package instanceupdates
+
+import (
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Report is what GET /updates answers: the box's copy of the settings, what
+// the instance may change, and the installed, serving and desired builds with
+// root's reason they differ.
+type Report struct {
+ Host string `json:"host"`
+ Settings release.UpdateSettings `json:"settings"`
+ MayChange []string `json:"may_change"`
+ Installed string `json:"installed,omitempty"`
+ Serving string `json:"serving,omitempty"`
+ Desired string `json:"desired,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// Mount adds GET /updates. host is fixed here and never read from a request;
+// token is read on every call (the env file can change under a running
+// instance), and an empty token refuses everything.
+func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) {
+ mux.HandleFunc("GET /updates", func(w http.ResponseWriter, r *http.Request) {
+ want, err := token()
+ if err != nil || want == "" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
+ exp := sha256.Sum256([]byte("Bearer " + want))
+ if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ rep, err := read(host)
+ if err != nil {
+ http.Error(w, "update settings unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ rep.Host = host
+ if rep.MayChange == nil {
+ rep.MayChange = []string{}
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(rep)
+ })
+}
+
+// errNoRoute is what RegistryReader answers for a host the registry has no
+// row for: the verb turns it into a 503, never a 200 with an empty report.
+var errNoRoute = errors.New("instanceupdates: no route for host")
+
+// RegistryReader answers from the box's registry copy: the settings column
+// (with the route's channel when the column names none), the installed
+// build and the serving build. Root's desired build and reason
+// are added by plan 4's reader, which wraps this one.
+func RegistryReader(reg interface {
+ Get(host string) (*registry.Route, error)
+}) func(string) (Report, error) {
+ return func(host string) (Report, error) {
+ rt, err := reg.Get(host)
+ if err != nil {
+ return Report{}, err
+ }
+ if rt == nil {
+ return Report{}, errNoRoute
+ }
+ u, err := release.ParseUpdateSettings(rt.UpdateSettings)
+ if err != nil {
+ return Report{}, err
+ }
+ // Default settings are stored as "" (Column), which names no channel:
+ // the instance follows its route's. A set column is reconcile's
+ // effective copy and its channel wins; one with no channel falls back
+ // to the route's too. Only the report is filled, never the column.
+ if u.Channel == "" {
+ u.Channel = rt.Channel
+ }
+ return Report{Settings: u, MayChange: u.Control, Installed: rt.Version, Serving: rt.ServingVersion}, nil
+ }
+}
diff --git a/internal/instanceupdates/control_test.go b/internal/instanceupdates/control_test.go
new file mode 100644
index 000000000..d726686b4
--- /dev/null
+++ b/internal/instanceupdates/control_test.go
@@ -0,0 +1,216 @@
+package instanceupdates
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func serve(t *testing.T, token string) *httptest.Server {
+ t.Helper()
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return token, nil }, func(host string) (Report, error) {
+ return Report{Host: host, Settings: release.UpdateSettings{Updates: release.UpdatesManual}, MayChange: []string{"pin"}, Installed: "v1"}, nil
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func get(t *testing.T, url, bearer string) (*http.Response, Report) {
+ t.Helper()
+ req, _ := http.NewRequest(http.MethodGet, url, nil)
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var rep Report
+ _ = json.NewDecoder(res.Body).Decode(&rep)
+ return res, rep
+}
+
+func TestUpdatesNeedsTheBearer(t *testing.T) {
+ srv := serve(t, "secret")
+ if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no bearer: %d", res.StatusCode)
+ }
+ if res, _ := get(t, srv.URL+"/updates", "wrong"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("wrong bearer: %d", res.StatusCode)
+ }
+ res, rep := get(t, srv.URL+"/updates", "secret")
+ if res.StatusCode != http.StatusOK || rep.Host != "a.example.com" || rep.Settings.Updates != release.UpdatesManual {
+ t.Fatalf("GET: %d %+v", res.StatusCode, rep)
+ }
+}
+
+func TestUpdatesIsReadOnlyAndFixedHost(t *testing.T) {
+ srv := serve(t, "secret")
+ req, _ := http.NewRequest(http.MethodPut, srv.URL+"/updates", nil)
+ req.Header.Set("Authorization", "Bearer secret")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusMethodNotAllowed {
+ t.Fatalf("PUT: %d, want 405", res.StatusCode)
+ }
+ if _, rep := get(t, srv.URL+"/updates?host=other.example.com", "secret"); rep.Host != "a.example.com" {
+ t.Fatal("the host came from the request")
+ }
+}
+
+func TestAnEmptyTokenRefusesEverything(t *testing.T) {
+ srv := serve(t, "")
+ if res, _ := get(t, srv.URL+"/updates", ""); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("empty token: %d", res.StatusCode)
+ }
+}
+
+// The token is read on every call: a rewritten env file takes effect on the
+// next request with no restart.
+func TestTheTokenIsReadOnEveryCall(t *testing.T) {
+ cur := "one"
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return cur, nil }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusOK {
+ t.Fatalf("first token: %d", res.StatusCode)
+ }
+ cur = "two"
+ if res, _ := get(t, srv.URL+"/updates", "one"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("old token after rewrite: %d", res.StatusCode)
+ }
+ if res, _ := get(t, srv.URL+"/updates", "two"); res.StatusCode != http.StatusOK {
+ t.Fatalf("new token: %d", res.StatusCode)
+ }
+}
+
+func TestATokenErrorOrAReaderErrorIsRefused(t *testing.T) {
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return "", errors.New("no env") }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "x"); res.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("token error: %d", res.StatusCode)
+ }
+
+ mux2 := http.NewServeMux()
+ Mount(mux2, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, errors.New("boom") })
+ srv2 := httptest.NewServer(mux2)
+ t.Cleanup(srv2.Close)
+ if res, _ := get(t, srv2.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("reader error: %d, want 503", res.StatusCode)
+ }
+}
+
+func TestMayChangeIsAnArrayNeverNull(t *testing.T) {
+ mux := http.NewServeMux()
+ Mount(mux, "a.example.com", func() (string, error) { return "s", nil }, func(string) (Report, error) { return Report{}, nil })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ req, _ := http.NewRequest(http.MethodGet, srv.URL+"/updates", nil)
+ req.Header.Set("Authorization", "Bearer s")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var raw map[string]json.RawMessage
+ if err := json.NewDecoder(res.Body).Decode(&raw); err != nil {
+ t.Fatal(err)
+ }
+ if string(raw["may_change"]) != "[]" {
+ t.Fatalf("may_change = %s, want []", raw["may_change"])
+ }
+}
+
+type fakeReg struct {
+ rt *registry.Route
+ err error
+}
+
+func (f fakeReg) Get(string) (*registry.Route, error) { return f.rt, f.err }
+
+func TestRegistryReaderFillsFromTheRoute(t *testing.T) {
+ rt := &registry.Route{
+ Host: "a.example.com", Version: "v1.2.0", ServingVersion: "v1.1.0",
+ UpdateSettings: `{"updates":"manual","channel":"stable","pin":"v1.1.0","control":["pin"]}`,
+ }
+ rep, err := RegistryReader(fakeReg{rt: rt})("a.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.Installed != "v1.2.0" || rep.Serving != "v1.1.0" || rep.Settings.Updates != release.UpdatesManual ||
+ rep.Settings.Pin != "v1.1.0" || len(rep.MayChange) != 1 || rep.MayChange[0] != "pin" {
+ t.Fatalf("report: %+v", rep)
+ }
+ if rep.Desired != "" || rep.Reason != "" {
+ t.Fatalf("desired/reason must stay empty until plan 4: %+v", rep)
+ }
+}
+
+// An empty column is an instance with default settings (Column() writes ""
+// for them), and its channel is the route's: the report says which channel
+// the instance follows, and the stored column stays "".
+func TestRegistryReaderReadsAnEmptyColumnAsAuto(t *testing.T) {
+ rt := &registry.Route{Host: "a.example.com", Version: "v1", Channel: "beta"}
+ rep, err := RegistryReader(fakeReg{rt: rt})("a.example.com")
+ if err != nil || rep.Settings.Updates != release.UpdatesAuto || rep.Installed != "v1" {
+ t.Fatalf("%+v, %v", rep, err)
+ }
+ if rep.Settings.Channel != "beta" {
+ t.Fatalf("channel of an empty column = %q, want the route's %q", rep.Settings.Channel, "beta")
+ }
+ if rt.UpdateSettings != "" {
+ t.Fatalf("the reader wrote the column: %q", rt.UpdateSettings)
+ }
+}
+
+// A set column is reconcile's effective copy and names its own channel, so
+// it wins over the route's; a set column with no channel falls back to the
+// route's.
+func TestRegistryReaderChannelPrefersTheColumn(t *testing.T) {
+ rep, err := RegistryReader(fakeReg{rt: &registry.Route{Host: "a.example.com", Channel: "stable",
+ UpdateSettings: `{"updates":"manual","channel":"beta"}`}})("a.example.com")
+ if err != nil || rep.Settings.Channel != "beta" {
+ t.Fatalf("column beta, route stable: %+v, %v", rep, err)
+ }
+ rep, err = RegistryReader(fakeReg{rt: &registry.Route{Host: "a.example.com", Channel: "stable",
+ UpdateSettings: `{"updates":"manual"}`}})("a.example.com")
+ if err != nil || rep.Settings.Channel != "stable" {
+ t.Fatalf("column with no channel, route stable: %+v, %v", rep, err)
+ }
+}
+
+// A host the registry does not know, or cannot read, is an error: the verb
+// answers 503, never a 200 with an empty report.
+func TestRegistryReaderErrorsForNoRouteOrAReadError(t *testing.T) {
+ if _, err := RegistryReader(fakeReg{})("gone.example.com"); err == nil {
+ t.Fatal("nil route, nil error: want an error")
+ }
+ if _, err := RegistryReader(fakeReg{err: errors.New("db")})("a.example.com"); err == nil {
+ t.Fatal("read error: want an error")
+ }
+ if _, err := RegistryReader(fakeReg{rt: &registry.Route{UpdateSettings: `{"updates":"bogus"}`}})("a.example.com"); err == nil {
+ t.Fatal("corrupt column: want an error")
+ }
+
+ mux := http.NewServeMux()
+ Mount(mux, "gone.example.com", func() (string, error) { return "s", nil }, RegistryReader(fakeReg{}))
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+ if res, _ := get(t, srv.URL+"/updates", "s"); res.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("unknown host over the wire: %d, want 503", res.StatusCode)
+ }
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index e0af6c4e4..1f1083bac 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -602,9 +602,11 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "a7d5eb534b540499b9355b455966e733d61c904be5cc5a76a168833df59de1c3"},
{"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"},
{"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"},
+ {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "53044a537795302e7ffd8fa72799b7a77e7bed277f00ad17ed1fd465b7f7cfaa"},
+ {"internal/console/apiupdates.go", "Server.handleAPIUpdatesInstall", "record-writer", "key:UpdateRecord", "880c396f0c9d3365ac216f15e75a44d3e81e24191240f041132854e9b7f6e78a"},
{"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"},
// Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers.
- {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "1b93d0d7d358aeec836f88b9f6ee4920fee76a8dad52d347c47affe51557a4dc"},
+ {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "5d0bae80378429ad7967c25e7eeae38e31402c8f44d01289c64cef1493c71804"},
{"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"},
{"internal/console/channelmove.go", "Server.handleAPIInstanceChannel", "record-writer", "key:UpdateRecord", "91a2b3157bde9af832e8d95b17d0c384910229982945818f8b52f62dafddb342"},
{"internal/console/channelmove.go", "Server.executePendingRouteChannel", "record-writer", "key:UpdateRecord", "b3b9a12213261a70b680ad5290957ebe7434eff633a73c2d80c75cc24d026d9a"},
@@ -845,8 +847,8 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/provision/provision.go", "InstanceDir", "key-site", "lit:instances", "4defc7f996c83afe8d2d2efddd31f4de79d94a5c84e7ee99e8d639ca03d7bf81"},
{"internal/provision/provision.go", "InstanceRunDir", "key-site", "lit:instances", "c0e58b35ea6c773c71524a7d9f5ab22c9a7bec46d0e0ab230784b935f77186c9"},
{"internal/provision/provision.go", "NestedPaths", "key-site", "lit:instances", "64a91261fe43412aab90175f5af2333c35a5fef7232ce20c64fd678a27130826"},
- {"internal/provision/reconcile.go", "Reconcile", "key-site", "key:ParseRecordKey,key:RecordKey,lit:instances", "5d066d1045533a400b2d1c70e919507c2311cf7b633810bfbe67b3f6ce535180"},
- {"internal/provision/reconcile.go", "mintHeld", "key-site", "key:RecordKey", "71e106094dc5e7aa28c82c94d41a735a1599d95e10ac422528bdf01c2a13ba3b"},
+ {"internal/provision/reconcile.go", "Reconcile", "key-site", "key:ParseRecordKey,key:RecordKey,lit:instances", "38ba4b9f32239df40fbbfa23afaa9176f98a6309e5cf674ed24858f731580bb2"},
+ {"internal/provision/reconcile.go", "mintHeld", "key-site", "key:RecordKey", "7f8df930a87b69a7c5e995b1f29b5d1fa71a37d843393ba67e634430b8db2d29"},
{"internal/provision/reconcile.go", "ListAllRecords", "key-site", "key:ParseRecordKey,lit:instances", "2396d277161244603476c6700c71d7bf6d89e0f8ccfd8eae12f8a05fa446e75d"},
{"internal/provision/record_sequence.go", "IsRecordKey", "key-site", "key:IsRecordKey,lit:instances,lit:sidecars", "71ee3cc5e6c1399befa39612262a7a6656f2464c760419c1a87438d8c600b6cb"},
{"internal/provision/record_sequence.go", "type preparedRecordKey", "key-site", "key:preparedRecordKey", "76c4a83eb76398bbe14e31fe82f9999b863e030082c240389bcd990e2d0475dd"},
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index fdbe6cd23..98d688b7d 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -191,6 +191,21 @@ type Record struct {
// steering converger — and never read by the edge's own dispatch,
// which never hairpins a replica.
Replicas []string `json:"replicas,omitempty"`
+
+ // --- update settings (instance update settings spec §3.1, §4.2) -----
+ // Every one is omitempty with "never set" as its zero value, so a record
+ // written before them decodes with none and reconcile resolves it to
+ // auto: today's adoption. Set only through the console's /updates doors.
+
+ // Updates is "auto", "manual", or "" (never set: the app default).
+ Updates string `json:"updates,omitempty"`
+ // Major holds the instance on one semver major of stable. nil: never set.
+ Major *int `json:"major,omitempty"`
+ // Pin is the exact build to run. It must have a promotion record.
+ Pin string `json:"pin,omitempty"`
+ // InstallRequest is "Update now": an id that only ever increases. Root
+ // resolves and completes it (plan 4); the console never chooses a build.
+ InstallRequest *InstallRequest `json:"install_request,omitempty"`
}
// Health is the per-route probe config (HA spec Part II). Zero value =
@@ -265,6 +280,11 @@ type Manifest struct {
// Moves is the app's move declaration (move-any-app design §7.3 S1):
// the readiness path a probe requests. Absent means "/".
Moves *MovesManifest `json:"moves,omitempty"`
+
+ // Updates is the app's instance update policy (spec §3.2). nil: no
+ // instance may change anything itself, and the defaults are auto, this
+ // manifest's Channel, and no major hold.
+ Updates *UpdatePolicy `json:"updates,omitempty"`
}
type MovesManifest struct {
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 70b3b0509..bf86614b6 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -422,7 +422,7 @@ func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool {
// move a flat row to. Only the mint branch passes true, from
// ReconcileConfig.Layout; the drift and adoption comparisons derive flat
// and judge a nested row by NestedPaths.
-func routeFor(rec Record, dataDir, seed string, nested bool) registry.Route {
+func routeFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolicy) registry.Route {
rt := registry.Route{
Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), Unit: rec.Unit,
App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
@@ -433,6 +433,10 @@ func routeFor(rec Record, dataDir, seed string, nested bool) registry.Route {
Replica: rec.Role == "replica",
Gate: rec.Gate,
Provisioned: true, Seed: seed, Status: "ready",
+ // The record's own settings over the app's policy (settings spec
+ // §3.2); "" for default settings, so an untouched fleet mints the
+ // row it always did.
+ UpdateSettings: EffectiveUpdates(rec, policy).Column(),
}
if rec.DerivesPaths() {
rt.Socket = DefaultSocket(rec.Host)
@@ -687,7 +691,7 @@ func shapeMismatch(existing, want registry.Route) string {
// one of the two callers is exactly the single-unguarded-write failure mode
// the pass exists to prevent (design §4.3). Guarding here means both call
// sites are safe by construction, not by each one remembering to check.
-func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, dryRun bool) {
+func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record, policy *UpdatePolicy, policyKnown bool, dryRun bool) {
if rec.Channel != "" && existing.Channel != rec.Channel {
if dryRun {
log.Printf("provision: dry-run: would converge %s channel %s -> %s", rec.Host, existing.Channel, rec.Channel)
@@ -774,6 +778,20 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record,
log.Printf("provision: converged %s replica mark %v -> %v", rec.Host, existing.Replica, want)
}
}
+ // Update settings (settings spec §4): the record over the app policy,
+ // both directions, including back to "". Left alone when the policy
+ // could not be read this pass.
+ if policyKnown {
+ if want := EffectiveUpdates(rec, policy).Column(); existing.UpdateSettings != want {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want)
+ } else if err := reg.SetUpdateSettings(rec.Host, want); err != nil {
+ log.Printf("provision: converge update settings on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s update settings %q -> %q", rec.Host, existing.UpdateSettings, want)
+ }
+ }
+ }
}
// Reconcile materializes bucket instance records into registry routes for
@@ -928,8 +946,9 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// The manifest is kept only as the seed it renders to, never whole: the
// lookup below has always reduced it to that at once.
type manifestRead struct {
- seed string
- err error
+ seed string
+ policy *UpdatePolicy
+ err error
}
type tombRead struct {
tomb *release.AppTombstone
@@ -965,6 +984,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
r := manifestRead{err: merr}
if merr == nil {
r.seed = seedJSON(m)
+ r.policy = m.Updates
}
prefetchMu.Lock()
manifests[k] = r
@@ -1007,6 +1027,29 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
return seedCache[k], seedKnown[k]
}
+ // policyFor is the app's update policy for this pass (settings spec
+ // §3.2). known is false when the manifest could not be read: the caller
+ // then leaves every row's update_settings as it is, because a read
+ // failure is not evidence the policy went away. An absent manifest is
+ // known: no policy.
+ policyFor := func(acct, app string) (*UpdatePolicy, bool) {
+ k := acctApp{acct, app}
+ if r, ok := manifests[k]; ok {
+ if r.err != nil {
+ return nil, errors.Is(r.err, os.ErrNotExist)
+ }
+ return r.policy, true
+ }
+ m, err := GetManifest(ctx, s, acct, app)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ return nil, true
+ case err != nil:
+ return nil, false
+ }
+ return m.Updates, true
+ }
+
// --- trash cache, per (acct, app), lazily ---------------------------------
// An app in the trash provisions nothing, and its existing routes are
// RETIRED — both halves are this pass's job (#340).
@@ -1089,7 +1132,8 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
if cfg.Hold != nil {
if status, op, held := cfg.Hold(rec); held {
if !ok {
- mintHeld(ctx, reg, cfg, rec, seed, status, op)
+ p, known := policyFor(rec.Account, rec.App)
+ mintHeld(ctx, reg, cfg, rec, seed, status, op, p, known)
continue
}
holdExisting(ctx, s, reg, cfg, rec, existing, status, op)
@@ -1137,7 +1181,15 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// exact route this branch hardcoded before they existed — that is
// the additivity rule, pinned by TestRecordWireShapeIsAdditive and
// TestReconcileMints.
- nr := routeFor(rec, cfg.DataDir, seed, nested)
+ p, pKnown := policyFor(rec.Account, rec.App)
+ if !ok && !pKnown {
+ // A read failure is not "no policy": minting now would mint an
+ // instance that inherits manual as auto, and root would adopt
+ // the head over its data's build. Absent is known.
+ log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host)
+ continue
+ }
+ nr := routeFor(rec, cfg.DataDir, seed, nested, p)
// A box that cannot provide this instance's config must not
// admit it: it would start without its secrets, and a keyed
// sibling would be left with a route that lost the race to one
@@ -1181,7 +1233,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// deploy's SetVersions or the activator's SetNextDue would
// clobber those, and Upsert cannot express "clear Environment"
// at all (it preserves a non-empty value by design).
- convergeOwned(reg, existing, rec, cfg.DryRun)
+ convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun)
// Absent means "this record does not speak to hibernation" —
// never a derivation stamped over an operator's own setting.
//
@@ -1255,7 +1307,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// by design (the layout pass moved them), so when that is the
// only difference the line says so, rather than pointing an
// operator at `carlos remove`.
- want := routeFor(rec, cfg.DataDir, seed, false)
+ want := routeFor(rec, cfg.DataDir, seed, false, p)
unitDrift := rec.Unit != "" && want.Unit != existing.Unit
if want.Kind != existing.Kind || unitDrift ||
want.Socket != existing.Socket || want.DBPath != existing.DBPath {
@@ -1282,7 +1334,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// A hand-added row on a nested box (`carlos add` there mints
// nested too) matches the record at the nested paths; the row's
// own shape says which derivation to compare against.
- want := routeFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing))
+ want := routeFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing), p)
if reason := shapeMismatch(existing, want); reason != "" {
log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone",
rec.Host, reason)
@@ -1299,7 +1351,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// already on the record's channel. Every write inside
// convergeOwned is dry-run guarded there, so this call
// writes nothing.
- convergeOwned(reg, existing, rec, true)
+ convergeOwned(reg, existing, rec, p, pKnown, true)
return nil
}
if err := reg.SetProvisioned(rec.Host, true); err != nil {
@@ -1314,7 +1366,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// live process is allowed to sleep. It converges on the next
// pass through the owned branch above, by which point the
// adoption is already visible in the log.
- convergeOwned(reg, existing, rec, cfg.DryRun)
+ convergeOwned(reg, existing, rec, p, pKnown, cfg.DryRun)
}
return nil
}); err != nil {
@@ -1440,7 +1492,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
// a mint that is going to land. With claims off the hook is a pass-through.
// A status that is not a hold, or an empty op, mints nothing: a
// participant's row is never minted ready.
-func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string) {
+func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, rec Record, seed, status, op string, policy *UpdatePolicy, policyKnown bool) {
if !registry.IsHold(status) || op == "" {
log.Printf("provision: %s: not minting: the move's hold %q by %q is not a hold", rec.Host, status, op)
return
@@ -1450,7 +1502,13 @@ func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig,
"CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account)
return
}
- nr := routeFor(rec, cfg.DataDir, seed, false)
+ if !policyKnown {
+ // The same wait as the ordinary mint: an instance that inherits
+ // manual from its app must not land held as auto.
+ log.Printf("provision: %s: not minted this pass: the app's update policy could not be read", rec.Host)
+ return
+ }
+ nr := routeFor(rec, cfg.DataDir, seed, false, policy)
nr.Status, nr.HoldOp = status, op
if cfg.ConfigGate != nil {
if err := cfg.ConfigGate(ctx, nr); err != nil {
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 51e53e7c6..f95ce302b 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -1072,7 +1072,7 @@ func TestReconcileConvergesLegacyAddrToAddrs(t *testing.T) {
// (shapeMismatch is the adoption gate AND the drift test — the two failures
// the un-cleared addr caused, asserted at their source).
seed := seedJSON(&Manifest{Seed: []string{"seed"}}) // setup()'s manifest
- if reason := shapeMismatch(after, routeFor(rec, cfg.DataDir, seed, false)); reason != "" {
+ if reason := shapeMismatch(after, routeFor(rec, cfg.DataDir, seed, false, nil)); reason != "" {
t.Errorf("converged row still diverges from its record (%s) — the pass would log this every tick and refuse re-adoption", reason)
}
@@ -2655,7 +2655,7 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) {
t.Fatalf("fixture is not a shape this pass would ever see: %v", err)
}
fromRecord := everywhereClass(f.rec)
- fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`, false))
+ fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`, false, nil))
if fromRecord != fromRow {
t.Fatalf("class disagrees across the mint and removal gates: everywhereClass = %v, "+
"everywhereRoute(routeFor(...)) = %v — a record this box mints must be a row it can retire",
@@ -2674,7 +2674,7 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) {
func TestRouteForCarriesAddrsAndHealth(t *testing.T) {
rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable",
Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}}
- rt := routeFor(rec, "/data", "seed", false)
+ rt := routeFor(rec, "/data", "seed", false, nil)
if rt.Addrs != "10.0.0.1:3000,10.0.0.2:3000" {
t.Fatalf("Addrs = %q", rt.Addrs)
}
@@ -2687,7 +2687,7 @@ func TestRouteForCarriesAddrsAndHealth(t *testing.T) {
}
// Legacy single-addr record: byte-identical route to before this change.
old := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable", Kind: "service", Addr: "10.0.0.9:3000"}
- ort := routeFor(old, "/data", "seed", false)
+ ort := routeFor(old, "/data", "seed", false, nil)
if ort.Addrs != "" || ort.Health != "" || ort.Addr != "10.0.0.9:3000" {
t.Fatalf("legacy record's route changed shape: %+v", ort)
}
@@ -2703,7 +2703,7 @@ func TestRouteForMaterializesPartialHealthDefaults(t *testing.T) {
rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable",
Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"},
Health: &Health{Path: "/healthz"}}
- rt := routeFor(rec, "/data", "seed", false)
+ rt := routeFor(rec, "/data", "seed", false, nil)
var h Health
if err := json.Unmarshal([]byte(rt.Health), &h); err != nil {
t.Fatalf("health JSON = %q (%v)", rt.Health, err)
@@ -2851,11 +2851,11 @@ func TestReconcileUnsetInboundMintsAsBefore(t *testing.T) {
// onto the route, byte for byte — no derivation, unlike Hibernate/StatusNamed.
func TestRouteForCarriesSteering(t *testing.T) {
rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", Steering: "latency"}
- if got := routeFor(rec, "/data", "seed", false).Steering; got != "latency" {
+ if got := routeFor(rec, "/data", "seed", false, nil).Steering; got != "latency" {
t.Fatalf("Steering = %q, want latency", got)
}
unsteered := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable"}
- if got := routeFor(unsteered, "/data", "seed", false).Steering; got != "" {
+ if got := routeFor(unsteered, "/data", "seed", false, nil).Steering; got != "" {
t.Fatalf("Steering = %q, want empty for a record that never mentioned it", got)
}
}
@@ -3207,7 +3207,7 @@ func TestReconcileHoldsParticipantRows(t *testing.T) {
}
// The ie box: the source's moving row, held by the move.
- ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false)
+ ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false, nil)
ieRow.Status, ieRow.HoldOp = "moving", "mv_acme"
ie := regWith(t, ieRow)
keep := true
@@ -3307,7 +3307,7 @@ func TestReconcileHoldsAnExistingRow(t *testing.T) {
if err := CreateRecord(ctx, s, rec); err != nil {
t.Fatal(err)
}
- owned := routeFor(rec, "/data", `["seed"]`, false)
+ owned := routeFor(rec, "/data", `["seed"]`, false, nil)
cases := []struct {
name string
row registry.Route
@@ -3408,7 +3408,7 @@ func TestReconcileHoldRereadsTheRecordBeforeHoldingARow(t *testing.T) {
m.State = MoveCommitted
committed.Move = &m
cs := &changingStore{Store: s, key: RecordKey(rec.Account, rec.App, rec.Host), first: mustJSON(t, rec), later: mustJSON(t, committed)}
- reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // the target's row, opened
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil)) // the target's row, opened
var wrote holdLog
cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }}
@@ -3434,7 +3434,7 @@ func TestReconcileHoldWriteIsRefusedWhenAnotherOpTookTheRow(t *testing.T) {
if err := CreateRecord(ctx, s, rec); err != nil {
t.Fatal(err)
}
- reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // unheld at the listing
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil)) // unheld at the listing
var wrote holdLog
cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }}
@@ -3475,7 +3475,7 @@ func TestReconcileHoldWriteIsSerialisedWithTheMoveOpening(t *testing.T) {
if err := CreateRecord(ctx, s, rec); err != nil {
t.Fatal(err)
}
- reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false))
+ reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false, nil))
var wrote holdLog
cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written,
Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }}
diff --git a/internal/provision/updates.go b/internal/provision/updates.go
new file mode 100644
index 000000000..7fd06a532
--- /dev/null
+++ b/internal/provision/updates.go
@@ -0,0 +1,85 @@
+package provision
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// InstallRequest is one "Update now" (spec §4.2).
+type InstallRequest struct {
+ ID uint64 `json:"id"`
+ FastTrack bool `json:"fast_track,omitempty"`
+ At time.Time `json:"at"`
+ By string `json:"by,omitempty"`
+}
+
+// UpdatePolicy is the app's instance update policy (spec §3.2). The default
+// channel is the manifest's own Channel (index Q4).
+type UpdatePolicy struct {
+ InstanceControl []string `json:"instance_control,omitempty"`
+ DefaultUpdates string `json:"default_updates,omitempty"`
+ DefaultMajor *int `json:"default_major,omitempty"`
+}
+
+// EffectiveUpdates resolves one instance's settings: its own explicit values,
+// else the app's defaults, else today's. A major hold off stable is dropped:
+// only stable promotions cut tags (spec §3.1), so it could never hold.
+func EffectiveUpdates(rec Record, p *UpdatePolicy) release.UpdateSettings {
+ u := release.UpdateSettings{Updates: release.UpdatesAuto, Channel: rec.Channel, Pin: rec.Pin}
+ if p != nil {
+ if p.DefaultUpdates != "" {
+ u.Updates = p.DefaultUpdates
+ }
+ u.Major = p.DefaultMajor
+ u.Control = append([]string(nil), p.InstanceControl...)
+ }
+ if rec.Updates != "" {
+ u.Updates = rec.Updates
+ }
+ if rec.Major != nil {
+ u.Major = rec.Major
+ }
+ if u.Channel != "stable" {
+ u.Major = nil
+ }
+ if rec.InstallRequest != nil {
+ u.InstallRequest, u.InstallFastTrack = rec.InstallRequest.ID, rec.InstallRequest.FastTrack
+ }
+ return u
+}
+
+// ValidateUpdates is the record's door check (the console's PUT).
+func ValidateUpdates(rec Record) error {
+ if rec.Updates != "" && rec.Updates != release.UpdatesAuto && rec.Updates != release.UpdatesManual {
+ return fmt.Errorf("updates must be auto or manual")
+ }
+ if rec.Major != nil && *rec.Major < 0 {
+ return fmt.Errorf("major must be a whole number, 0 or more")
+ }
+ if rec.Major != nil && rec.Channel != "stable" {
+ return fmt.Errorf("major applies only to the stable channel")
+ }
+ if rec.Pin != "" && (!release.ValidVersion(rec.Pin) || rec.Pin == "dev") {
+ return fmt.Errorf("invalid pin %q", rec.Pin)
+ }
+ return nil
+}
+
+// ValidatePolicy is the app policy's door check.
+func ValidatePolicy(p *UpdatePolicy) error {
+ if p == nil {
+ return nil
+ }
+ if !release.ValidControl(p.InstanceControl) {
+ return fmt.Errorf("instance_control must name updates, channel, major or pin, each once")
+ }
+ if p.DefaultUpdates != "" && p.DefaultUpdates != release.UpdatesAuto && p.DefaultUpdates != release.UpdatesManual {
+ return fmt.Errorf("default_updates must be auto or manual")
+ }
+ if p.DefaultMajor != nil && *p.DefaultMajor < 0 {
+ return fmt.Errorf("default_major must be a whole number, 0 or more")
+ }
+ return nil
+}
diff --git a/internal/provision/updates_reconcile_test.go b/internal/provision/updates_reconcile_test.go
new file mode 100644
index 000000000..b35c6c10e
--- /dev/null
+++ b/internal/provision/updates_reconcile_test.go
@@ -0,0 +1,155 @@
+package provision
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// updatesFixture is reconcile_test.go's own setup (an account, the "tito"
+// app's instances manifest, a DirStore, a temp registry) with the three
+// moves these tests make: write the app's manifest with a given update
+// policy, run a pass, read a row.
+type updatesFixture struct {
+ t *testing.T
+ s *release.DirStore
+ sqid string
+ reg *registry.Registry
+ cfg ReconcileConfig
+}
+
+func newUpdatesFixture(t *testing.T) *updatesFixture {
+ t.Helper()
+ s, sqid := setup(t)
+ return &updatesFixture{t: t, s: s, sqid: sqid, reg: regWith(t),
+ cfg: ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}}
+}
+
+// putManifest replaces the app's instances manifest with one carrying p.
+func (fx *updatesFixture) putManifest(p *UpdatePolicy) {
+ fx.t.Helper()
+ m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Seed: []string{"seed"}, Updates: p}
+ putReplace(fx.t, fx.s, ManifestKey(fx.sqid, "tito"), mustJSON(fx.t, m))
+}
+
+// corruptManifest leaves unparseable bytes at the manifest's key: a read
+// failure that is not absence.
+func (fx *updatesFixture) corruptManifest() {
+ fx.t.Helper()
+ putReplace(fx.t, fx.s, ManifestKey(fx.sqid, "tito"), []byte("{"))
+}
+
+func (fx *updatesFixture) putRecord(rec Record) {
+ fx.t.Helper()
+ if err := CreateRecord(ctx, fx.s, rec); err != nil {
+ fx.t.Fatal(err)
+ }
+}
+
+func (fx *updatesFixture) run() {
+ fx.t.Helper()
+ if err := Reconcile(ctx, fx.s, fx.reg, fx.cfg); err != nil {
+ fx.t.Fatal(err)
+ }
+}
+
+func (fx *updatesFixture) row(host string) registry.Route { return *mustGet(fx.t, fx.reg, host) }
+
+func (fx *updatesFixture) hasRow(host string) bool {
+ fx.t.Helper()
+ rt, err := fx.reg.Get(host)
+ if err != nil {
+ fx.t.Fatal(err)
+ }
+ return rt != nil
+}
+
+func TestReconcileCarriesSettingsToTheRow(t *testing.T) {
+ fx := newUpdatesFixture(t)
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.putRecord(acmeRecord(fx.sqid))
+ fx.run()
+ rt := fx.row("acme.go.tito.io")
+ u, err := release.ParseUpdateSettings(rt.UpdateSettings)
+ if err != nil || u.Updates != release.UpdatesManual {
+ t.Fatalf("minted row settings %q: %+v %v", rt.UpdateSettings, u, err)
+ }
+ // The default changes: the row follows on the next pass.
+ fx.putManifest(nil)
+ fx.run()
+ if rt = fx.row("acme.go.tito.io"); rt.UpdateSettings != "" {
+ t.Fatalf("after the policy was removed: %q", rt.UpdateSettings)
+ }
+}
+
+func TestAnUntouchedFleetConvergesNothing(t *testing.T) {
+ fx := newUpdatesFixture(t)
+ fx.putRecord(acmeRecord(fx.sqid))
+ fx.run()
+ if fx.row("acme.go.tito.io").UpdateSettings != "" {
+ t.Fatal("a record with no settings wrote the column")
+ }
+ fx.run()
+ if fx.row("acme.go.tito.io").UpdateSettings != "" {
+ t.Fatal("a second pass wrote the column")
+ }
+}
+
+// Review Focus 2.
+func TestUnreadablePolicyLeavesTheColumnAlone(t *testing.T) {
+ fx := newUpdatesFixture(t)
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.putRecord(acmeRecord(fx.sqid))
+ fx.run()
+ fx.corruptManifest()
+ fx.run()
+ if u, _ := release.ParseUpdateSettings(fx.row("acme.go.tito.io").UpdateSettings); u.Updates != release.UpdatesManual {
+ t.Fatal("an unreadable policy reset the instance to auto")
+ }
+}
+
+// Astra I1 #7: an instance that inherits manual from its app, minted (on a
+// fresh box, or after a move) while the app's manifest cannot be read, must
+// not be minted as auto: it would adopt the head instead of recovering
+// last_executed. The mint waits until the policy reads; a genuinely absent
+// manifest is known (no policy) and mints at once.
+func TestMintWaitsForAReadablePolicy(t *testing.T) {
+ fx := newUpdatesFixture(t)
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.corruptManifest()
+ fx.putRecord(acmeRecord(fx.sqid))
+ fx.run()
+ if fx.hasRow("acme.go.tito.io") {
+ t.Fatal("minted while the app's update policy was unreadable")
+ }
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.run()
+ if u, _ := release.ParseUpdateSettings(fx.row("acme.go.tito.io").UpdateSettings); u.Updates != release.UpdatesManual {
+ t.Fatalf("minted with %q once the policy read", fx.row("acme.go.tito.io").UpdateSettings)
+ }
+}
+
+// Ruling R5: the moves held mint is a mint like any other. It waits while the
+// policy is unreadable, then lands held with the inherited settings.
+func TestHeldMintWaitsForAReadablePolicy(t *testing.T) {
+ fx := newUpdatesFixture(t)
+ fx.cfg.Region = "uk"
+ fx.cfg.Hold = func(Record) (string, string, bool) { return "landing", "mv_acme", true }
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.corruptManifest()
+ fx.putRecord(movingRecord(fx.sqid))
+ fx.run()
+ if fx.hasRow("acme.go.tito.io") {
+ t.Fatal("held mint landed while the app's update policy was unreadable")
+ }
+ fx.putManifest(&UpdatePolicy{DefaultUpdates: release.UpdatesManual})
+ fx.run()
+ rt := fx.row("acme.go.tito.io")
+ if rt.Status != "landing" || rt.HoldOp != "mv_acme" {
+ t.Fatalf("row is %q held by %q, want landing by mv_acme", rt.Status, rt.HoldOp)
+ }
+ if u, _ := release.ParseUpdateSettings(rt.UpdateSettings); u.Updates != release.UpdatesManual {
+ t.Fatalf("held mint carries %q once the policy read", rt.UpdateSettings)
+ }
+}
diff --git a/internal/provision/updates_test.go b/internal/provision/updates_test.go
new file mode 100644
index 000000000..a2d12f87d
--- /dev/null
+++ b/internal/provision/updates_test.go
@@ -0,0 +1,78 @@
+package provision
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func intp(n int) *int { return &n }
+
+func TestARecordWithoutSettingsIsToday(t *testing.T) {
+ rec := Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "stable"}
+ u := EffectiveUpdates(rec, nil)
+ if !u.Default() || u.Channel != "stable" || u.Column() != "" {
+ t.Fatalf("EffectiveUpdates = %+v (column %q)", u, u.Column())
+ }
+ b, _ := json.Marshal(rec)
+ for _, k := range []string{`"updates"`, `"major"`, `"pin"`, `"install_request"`} {
+ if strings.Contains(string(b), k) {
+ t.Fatalf("an unset %s leaked into the record: %s", k, b)
+ }
+ }
+}
+
+func TestDefaultsApplyWhereNothingIsSet(t *testing.T) {
+ p := &UpdatePolicy{DefaultUpdates: release.UpdatesManual, DefaultMajor: intp(3), InstanceControl: []string{"pin"}}
+ u := EffectiveUpdates(Record{Channel: "stable"}, p)
+ if u.Updates != release.UpdatesManual || *u.Major != 3 || len(u.Control) != 1 {
+ t.Fatalf("defaults = %+v", u)
+ }
+ u = EffectiveUpdates(Record{Channel: "stable", Updates: release.UpdatesAuto, Major: intp(4)}, p)
+ if u.Updates != release.UpdatesAuto || *u.Major != 4 {
+ t.Fatalf("explicit values lost to defaults: %+v", u)
+ }
+}
+
+// Review Focus 1.
+func TestEffectiveUpdatesDropsMajorOffStable(t *testing.T) {
+ u := EffectiveUpdates(Record{Channel: "beta", Major: intp(2)}, &UpdatePolicy{DefaultMajor: intp(1)})
+ if u.Major != nil {
+ t.Fatalf("major off stable = %d; it can never apply there", *u.Major)
+ }
+}
+
+func TestEffectiveUpdatesCarriesTheRequest(t *testing.T) {
+ u := EffectiveUpdates(Record{Channel: "stable", InstallRequest: &InstallRequest{ID: 7, FastTrack: true}}, nil)
+ if u.InstallRequest != 7 || !u.InstallFastTrack {
+ t.Fatalf("request = %+v", u)
+ }
+}
+
+func TestValidateUpdates(t *testing.T) {
+ ok := []Record{
+ {Channel: "stable"},
+ {Channel: "stable", Updates: "manual", Major: intp(0), Pin: "v1"},
+ }
+ for _, r := range ok {
+ if err := ValidateUpdates(r); err != nil {
+ t.Errorf("%+v: %v", r, err)
+ }
+ }
+ bad := []Record{
+ {Channel: "stable", Updates: "sometimes"},
+ {Channel: "stable", Major: intp(-1)},
+ {Channel: "beta", Major: intp(1)},
+ {Channel: "stable", Pin: "../etc"},
+ }
+ for _, r := range bad {
+ if err := ValidateUpdates(r); err == nil {
+ t.Errorf("%+v was accepted", r)
+ }
+ }
+ if ValidatePolicy(&UpdatePolicy{InstanceControl: []string{"bake"}}) == nil || ValidatePolicy(&UpdatePolicy{DefaultUpdates: "x"}) == nil {
+ t.Fatal("ValidatePolicy accepted nonsense")
+ }
+}
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go
index 4ca2b9292..4d82e992d 100644
--- a/internal/registry/coldhandover.go
+++ b/internal/registry/coldhandover.go
@@ -135,7 +135,7 @@ func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string,
return ErrClaimOwnership
}
t := next.Route
- values := []any{t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion}
+ values := []any{t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings}
if _, err = tx.ExecContext(ctx, `INSERT INTO routes (`+routeCols+`) VALUES (`+strings.TrimSuffix(strings.Repeat("?,", len(values)), ",")+`)`, values...); err != nil {
return err
}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 091d2a804..fe3457f65 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -292,6 +292,13 @@ type Route struct {
// different questions (SnapshotFingerprint's own doc comment).
Steering string
+ // UpdateSettings is the instance's effective update settings
+ // (release.UpdateSettings' canonical JSON, "" = today's adoption),
+ // converged by provision.Reconcile from the record and the app policy.
+ // Root reads it (plan 4). Deliberately NOT in Fingerprint(): it
+ // changes what root installs, not what the edge serves.
+ UpdateSettings string
+
// Replicas is the account's replica placements, comma-joined placement
// codes in canonical (sorted, deduplicated) order — the registry half of
// #162. "" on every row that predates the column = no replicas =
@@ -574,6 +581,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN good_digest TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN update_settings TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -851,7 +859,7 @@ replication, next_due, backing, status_named, platform, dns_status,
dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
provisioned, seed, unit, fleet_box, addrs, health, steering, gate, created_at,
args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op,
-good_version, good_digest, good_demand, serving_version`
+good_version, good_digest, good_demand, serving_version, update_settings`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -863,7 +871,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox,
&t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt,
&t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp,
- &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion)
+ &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -1020,8 +1028,8 @@ func (r *Registry) Delete(host string) error {
// SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route
// -revoke`), SetTunnel (`carlos route -tunnel -`), SetUnit (`carlos route
// -unit -`), SetReplication (`carlos route -replication -`), SetSeed,
-// SetFleetBox, SetArgs, SetWorkerLimits, and for `provisioned` the row's
-// own deletion.
+// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, and for
+// `provisioned` the row's own deletion.
//
// `replication` is the one column with a SECOND clearing door, and it is not
// an operator's: SetHibernate clears it when a route stops hibernating,
@@ -1109,7 +1117,7 @@ func (r *Registry) Upsert(t Route) error {
}
res, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr,
@@ -1137,7 +1145,8 @@ ON CONFLICT(host) DO UPDATE SET
gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END,
args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END,
idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END,
- max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END
+ max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END,
+ update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END
WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp
AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
@@ -1148,7 +1157,7 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla
b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health,
t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds,
t.Replicas, b2i(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp,
- t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion)
+ t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings)
if err != nil {
return err
}
@@ -1836,6 +1845,30 @@ func (r *Registry) SetSteering(host, steering string) error {
return nil
}
+// SetUpdateSettings repoints a route's effective update settings as a narrow
+// UPDATE: reconcile converges it from the record every pass, in both
+// directions, including down to "". Errors on an unmatched host, like
+// SetSteering.
+func (r *Registry) SetUpdateSettings(host, col string) error {
+ if r.remote != nil {
+ _, err := r.remote.call(nil, remoteRequest{Method: "SetUpdateSettings", Host: host, UpdateSettings: col}, nil)
+ return err
+ }
+
+ res, err := r.sql.Exec(`UPDATE routes SET update_settings = ? WHERE host = ?`, col, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetReplicas repoints a route's replica placement set (#162) as a narrow
// UPDATE: the provision pass converges this column every tick from the
// record, and the record's value is authoritative in BOTH directions —
@@ -3108,15 +3141,17 @@ var snapshotClaimCols = map[string]bool{
"claim_row_id": true,
}
-// snapshotWhenSetCols are the good-build columns (zero-downtime spec §8.5).
-// A restore has to reproduce them, so they are hashed, but only when set and
-// tagged, on the hold_op rule: a registry that has never recorded a good
-// build digests exactly as it did before the columns existed, so upgrading
-// does not force a snapshot on every box.
+// snapshotWhenSetCols are the good-build columns (zero-downtime spec §8.5)
+// and the update-settings column. A restore has to reproduce them, so they
+// are hashed, but only when set and tagged, on the hold_op rule: a registry
+// that has never recorded a good build or set update settings digests exactly
+// as it did before the columns existed, so upgrading does not force a snapshot
+// on every box.
var snapshotWhenSetCols = map[string]bool{
- "good_version": true,
- "good_digest": true,
- "good_demand": true,
+ "good_version": true,
+ "good_digest": true,
+ "good_demand": true,
+ "update_settings": true,
}
func (r *Registry) SnapshotFingerprint() (string, error) {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index b162bcc8b..ca70219d2 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -83,6 +83,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
CreatedAt: 1700000000,
GoodVersion: "good789", GoodDigest: "sha256:good", GoodDemand: "swap-demand",
ServingVersion: "serve012",
+ UpdateSettings: `{"updates":"manual","channel":"stable"}`,
}
if err := reg.Upsert(want); err != nil {
t.Fatal(err)
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go
index fc085286a..9d84778b3 100644
--- a/internal/registry/remote_dispatch.go
+++ b/internal/registry/remote_dispatch.go
@@ -52,6 +52,7 @@ type remoteRequest struct {
Addrs string
Health string
Steering string
+ UpdateSettings string
Replicas string
Replica bool
Hash string
@@ -209,6 +210,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback
err = r.SetUpstreams(q.Host, q.Addr, q.Addrs, q.Health)
case "SetSteering":
err = r.SetSteering(q.Host, q.Steering)
+ case "SetUpdateSettings":
+ err = r.SetUpdateSettings(q.Host, q.UpdateSettings)
case "SetReplicas":
err = r.SetReplicas(q.Host, q.Replicas)
case "SetReplica":
diff --git a/internal/registry/updatesettings_test.go b/internal/registry/updatesettings_test.go
new file mode 100644
index 000000000..4f2571d55
--- /dev/null
+++ b/internal/registry/updatesettings_test.go
@@ -0,0 +1,82 @@
+package registry
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestUpdateSettingsColumn(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ rt, _ := reg.Get("a.example.com")
+ if rt.UpdateSettings != "" {
+ t.Fatalf("a new row has settings %q", rt.UpdateSettings)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ col := `{"updates":"manual","channel":"stable"}`
+ if err := reg.SetUpdateSettings("a.example.com", col); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != col {
+ t.Fatalf("column = %q", rt.UpdateSettings)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after != before {
+ t.Fatal("update settings changed the edge's fingerprint")
+ }
+ // A full-row Upsert that does not carry the column keeps it.
+ if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != col {
+ t.Fatalf("Upsert cleared the column: %q", rt.UpdateSettings)
+ }
+ if err := reg.SetUpdateSettings("a.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ if rt, _ = reg.Get("a.example.com"); rt.UpdateSettings != "" {
+ t.Fatal("the narrow setter could not clear the column")
+ }
+ if err := reg.SetUpdateSettings("nobody.example.com", col); err == nil {
+ t.Fatal("an unknown host was not an error")
+ }
+}
+
+func TestUpdateSettingsMovesSnapshotFingerprint(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "r.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "s.example.com", Kind: "instance", App: "km"}); err != nil {
+ t.Fatal(err)
+ }
+ // SnapshotFingerprint with unset update_settings
+ before, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ col := `{"updates":"manual","channel":"stable"}`
+ if err := reg.SetUpdateSettings("s.example.com", col); err != nil {
+ t.Fatal(err)
+ }
+ // SnapshotFingerprint with set update_settings must be different
+ // (restores must reproduce the column value)
+ after, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after == before {
+ t.Fatal("setting update_settings did not move SnapshotFingerprint, so a restore would miss the column")
+ }
+}
diff --git a/internal/release/updatesettings.go b/internal/release/updatesettings.go
new file mode 100644
index 000000000..e2faaab6d
--- /dev/null
+++ b/internal/release/updatesettings.go
@@ -0,0 +1,89 @@
+package release
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+)
+
+// The update settings vocabulary (instance update settings spec §3).
+const (
+ UpdatesAuto = "auto"
+ UpdatesManual = "manual"
+
+ ControlUpdates = "updates"
+ ControlChannel = "channel"
+ ControlMajor = "major"
+ ControlPin = "pin"
+)
+
+// UpdateSettings are one instance's EFFECTIVE settings: the record's explicit
+// values over the app's defaults, resolved by reconcile
+// (provision.EffectiveUpdates) and carried to root in the registry's
+// update_settings column. A preference, not a security boundary (spec §2
+// decision 5): root still installs only builds §4.1 authorizes.
+type UpdateSettings struct {
+ Updates string `json:"updates"`
+ Channel string `json:"channel"`
+ Major *int `json:"major,omitempty"`
+ Pin string `json:"pin,omitempty"`
+ InstallRequest uint64 `json:"install_request,omitempty"`
+ InstallFastTrack bool `json:"install_fast_track,omitempty"`
+ Control []string `json:"control,omitempty"`
+}
+
+// ParseUpdateSettings reads the column. "" is every row before this feature,
+// and reads as auto: today's adoption, byte for byte.
+func ParseUpdateSettings(col string) (UpdateSettings, error) {
+ if col == "" {
+ return UpdateSettings{Updates: UpdatesAuto}, nil
+ }
+ var u UpdateSettings
+ if err := json.Unmarshal([]byte(col), &u); err != nil {
+ return UpdateSettings{}, fmt.Errorf("update settings: %v", err)
+ }
+ if u.Updates != UpdatesAuto && u.Updates != UpdatesManual {
+ return UpdateSettings{}, fmt.Errorf("update settings: updates %q", u.Updates)
+ }
+ if u.Major != nil && *u.Major < 0 {
+ return UpdateSettings{}, fmt.Errorf("update settings: major %d", *u.Major)
+ }
+ if u.Pin != "" && (!ValidVersion(u.Pin) || u.Pin == "dev") {
+ return UpdateSettings{}, fmt.Errorf("update settings: pin %q", u.Pin)
+ }
+ if u.Channel != "" && !ValidChannel(u.Channel) {
+ return UpdateSettings{}, fmt.Errorf("update settings: channel %q", u.Channel)
+ }
+ if !ValidControl(u.Control) {
+ return UpdateSettings{}, fmt.Errorf("update settings: control %q", u.Control)
+ }
+ return u, nil
+}
+
+// Column is the canonical JSON reconcile writes. Default settings are "":
+// a row is byte-identical to before this feature until someone sets
+// something.
+func (u UpdateSettings) Column() string {
+ if u.Default() && len(u.Control) == 0 {
+ return ""
+ }
+ b, _ := json.Marshal(u)
+ return string(b)
+}
+
+// Default reports today's behaviour: auto, no major hold, no pin, no request.
+func (u UpdateSettings) Default() bool {
+ return (u.Updates == "" || u.Updates == UpdatesAuto) && u.Major == nil && u.Pin == "" && u.InstallRequest == 0
+}
+
+// ValidControl accepts a set of setting names an instance may change itself.
+func ValidControl(names []string) bool {
+ seen := map[string]bool{}
+ for _, n := range names {
+ if seen[n] || !slices.Contains([]string{ControlUpdates, ControlChannel, ControlMajor, ControlPin}, n) {
+ return false
+ }
+ seen[n] = true
+ }
+ return true
+}
diff --git a/internal/release/updatesettings_test.go b/internal/release/updatesettings_test.go
new file mode 100644
index 000000000..c6c6f9001
--- /dev/null
+++ b/internal/release/updatesettings_test.go
@@ -0,0 +1,47 @@
+package release
+
+import "testing"
+
+func TestEmptyColumnIsToday(t *testing.T) {
+ u, err := ParseUpdateSettings("")
+ if err != nil || u.Updates != UpdatesAuto || u.Major != nil || u.Pin != "" || u.InstallRequest != 0 {
+ t.Fatalf("ParseUpdateSettings(\"\") = %+v, %v; want auto and nothing else", u, err)
+ }
+ if !u.Default() {
+ t.Fatal("the empty column is not the default")
+ }
+}
+
+func TestColumnRoundTrips(t *testing.T) {
+ two := 2
+ u := UpdateSettings{Updates: UpdatesManual, Channel: "stable", Major: &two, Pin: "v9",
+ InstallRequest: 4, InstallFastTrack: true, Control: []string{ControlUpdates, ControlPin}}
+ got, err := ParseUpdateSettings(u.Column())
+ if err != nil || got.Column() != u.Column() || *got.Major != 2 {
+ t.Fatalf("round trip = %+v, %v", got, err)
+ }
+ if u.Default() {
+ t.Fatal("manual is not the default")
+ }
+}
+
+func TestDefaultIgnoresChannelAndControl(t *testing.T) {
+ u := UpdateSettings{Updates: UpdatesAuto, Channel: "beta", Control: []string{ControlUpdates}}
+ if !u.Default() {
+ t.Fatal("auto with no major, pin or request is today's behaviour whatever the channel")
+ }
+}
+
+func TestParseRefusesNonsense(t *testing.T) {
+ for _, col := range []string{`{`, `{"updates":"sometimes"}`, `{"updates":"auto","major":-1}`, `{"updates":"auto","pin":"../x"}`} {
+ if _, err := ParseUpdateSettings(col); err == nil {
+ t.Errorf("%s was accepted", col)
+ }
+ }
+}
+
+func TestValidControl(t *testing.T) {
+ if !ValidControl(nil) || !ValidControl([]string{"updates", "pin"}) || ValidControl([]string{"updates", "updates"}) || ValidControl([]string{"bake"}) {
+ t.Fatal("ValidControl is wrong")
+ }
+}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 45788606c..424863e9b 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -443,6 +443,12 @@ type BoxMetrics struct {
// spec §5.5); its absence is how the rollout verdict tells an older
// binary, which does not strip forged geo headers.
GeoIP *GeoIPState `json:"geoip,omitempty"`
+ // UpdateSettings is the instance-update-settings protocol this binary
+ // implements: it applies pin, manual and major, and its retention
+ // protects the builds they name (settings spec §4.4). Absent (0) is a
+ // binary that ignores them; the console refuses those settings until
+ // every box reports UpdateSettingsProtocol.
+ UpdateSettings int `json:"update_settings,omitempty"`
// RouteVersions is host -> registry.Route.Version for every non-platform
// route this box carries with a Version the adopt pass has stamped
// (issue #78's staged_version chip) — a hostname-keyed map, which is
@@ -525,6 +531,10 @@ type BoxMetrics struct {
ConfigGuard string `json:"config_guard,omitempty"`
}
+// UpdateSettingsProtocol is the BoxMetrics.UpdateSettings a binary reports
+// once it applies update settings and protects the builds they name.
+const UpdateSettingsProtocol = 1
+
// AliasReading is one alias hostname's status as CollectBox saw it this
// tick: Route is the route it points at (registry.RouteAlias.Host), DNSStatus
// and CertExpiresAt are the box-stamped health columns (Task 2/5), carried
diff --git a/internal/updatewords/words_approved_test.go b/internal/updatewords/words_approved_test.go
index b18168ccc..7e73ab107 100644
--- a/internal/updatewords/words_approved_test.go
+++ b/internal/updatewords/words_approved_test.go
@@ -8,53 +8,100 @@ import (
"testing"
)
-// The settings-2 copy Paul approved (2026-10-09). The record must name exactly
-// the IDs of All(), each text must be what Text returns byte for byte, and the
-// CLI docs page must carry the authorize paragraph verbatim.
-func TestApprovedCopyIsApplied(t *testing.T) {
- raw, err := os.ReadFile("../../docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json")
+type approvedEntry struct{ ID, File, Text string }
+
+type approvedRecord struct {
+ ApprovedBy string `json:"approved_by"`
+ ApprovedOn string `json:"approved_on"`
+ Entries []approvedEntry
+}
+
+func loadApproved(t *testing.T, path string) approvedRecord {
+ t.Helper()
+ raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
- var rec struct {
- ApprovedBy string `json:"approved_by"`
- ApprovedOn string `json:"approved_on"`
- Entries []struct{ ID, File, Text string }
- }
+ var rec approvedRecord
if err := json.Unmarshal(raw, &rec); err != nil {
t.Fatal(err)
}
- if rec.ApprovedBy == "" || rec.ApprovedOn == "" {
- t.Fatal("the record has no approval")
+ return rec
+}
+
+// The copy Paul approved lives in two records: plan 2's (2026-10-09, the
+// authorize strings) and plan 3's (2026-10-11, the settings.console.*
+// strings). Each record must name exactly its own IDs, each text must be what
+// Text returns byte for byte, each entry must name the file that holds it, and
+// together the two records must name exactly the IDs of All(), so a new draft
+// with no approved record fails.
+func TestApprovedCopyIsApplied(t *testing.T) {
+ const console = "settings.console."
+ records := []struct {
+ path string
+ file string
+ in func(id string) bool
+ }{
+ {"../../docs/superpowers/plans/2026-10-07-instance-settings-2-copy.json",
+ "internal/updatewords/words_authorize.go",
+ func(id string) bool { return !strings.HasPrefix(id, console) }},
+ {"../../docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json",
+ "internal/updatewords/words_console.go",
+ func(id string) bool { return strings.HasPrefix(id, console) }},
}
- var got, want []string
+ var union []string
docs := ""
- for _, e := range rec.Entries {
- got = append(got, e.ID)
- if e.Text == "" {
- t.Errorf("%s: the approved text is empty", e.ID)
- continue
+ for _, r := range records {
+ rec := loadApproved(t, r.path)
+ if rec.ApprovedBy == "" || rec.ApprovedOn == "" {
+ t.Fatalf("%s: the record has no approval", r.path)
}
- if e.File != "internal/updatewords/words_authorize.go" {
- t.Errorf("%s: unexpected file %s", e.ID, e.File)
+ var got, want []string
+ seen := map[string]bool{}
+ for _, e := range rec.Entries {
+ got = append(got, e.ID)
+ if seen[e.ID] {
+ t.Errorf("%s: %s appears twice", r.path, e.ID)
+ }
+ seen[e.ID] = true
+ if e.Text == "" {
+ t.Errorf("%s: the approved text is empty", e.ID)
+ continue
+ }
+ if e.File != r.file {
+ t.Errorf("%s: unexpected file %s, want %s", e.ID, e.File, r.file)
+ }
+ if e.ID == "settings.authorize.docs" {
+ docs = e.Text
+ }
+ if !has(e.ID) {
+ continue // reported by the ID comparison below
+ }
+ if txt := Text(e.ID); txt != e.Text {
+ t.Errorf("%s: text is %q, approved %q", e.ID, txt, e.Text)
+ }
}
- if e.ID == "settings.authorize.docs" {
- docs = e.Text
+ for _, d := range All() {
+ if r.in(d.ID) {
+ want = append(want, d.ID)
+ }
}
- if !has(e.ID) {
- continue // reported by the ID comparison below
- }
- if txt := Text(e.ID); txt != e.Text {
- t.Errorf("%s: text is %q, approved %q", e.ID, txt, e.Text)
+ sort.Strings(got)
+ sort.Strings(want)
+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Errorf("%s: record IDs %v, want %v", r.path, got, want)
}
+ union = append(union, got...)
}
+
+ var all []string
for _, d := range All() {
- want = append(want, d.ID)
+ all = append(all, d.ID)
}
- sort.Strings(got)
- sort.Strings(want)
- if strings.Join(got, "\n") != strings.Join(want, "\n") {
- t.Errorf("record IDs %v, want the IDs of All() %v", got, want)
+ sort.Strings(union)
+ sort.Strings(all)
+ if strings.Join(union, "\n") != strings.Join(all, "\n") {
+ t.Errorf("the approved records name %v, want exactly the IDs of All() %v", union, all)
}
if docs == "" {
diff --git a/internal/updatewords/words_console.go b/internal/updatewords/words_console.go
new file mode 100644
index 000000000..d4c2e749f
--- /dev/null
+++ b/internal/updatewords/words_console.go
@@ -0,0 +1,58 @@
+package updatewords
+
+// Plan 3's member-facing strings: the console's update settings doors, the
+// install request and the app policy. Paul approved every one on 2026-10-11,
+// and docs/superpowers/plans/2026-10-07-instance-settings-3-copy.json pins
+// them. Do not edit a text here without a new copy review.
+func init() {
+ add(
+ Draft{ID: "settings.console.bad-updates", Text: "updates must be auto or manual",
+ Context: "Returned by carlos instances set and the console when updates is anything else."},
+ Draft{ID: "settings.console.bad-major", Text: "major must be a whole number, 0 or more, or empty to clear it",
+ Context: "Returned when the major hold is not a number."},
+ Draft{ID: "settings.console.bad-pin", Text: "%s is not a build version",
+ Context: "Returned when the pin is not a build version: not text, or text no build could be named. %s is the value sent, printable characters only, or the word that when none are left."},
+ Draft{ID: "settings.console.major-off-stable", Text: "a major hold applies only to instances on the stable channel. %s is on %s",
+ Context: "Returned when major is set on an instance that follows another channel. Host, channel."},
+ Draft{ID: "settings.console.pin-unpromoted", Text: "%s cannot be pinned: it has no promotion record. Pick a newer build, or ask an operator to authorize this one",
+ Context: "Returned when the pin names a build that was never promoted, or was promoted before records existed. %s is the version. Only an operator can run carlos releases authorize."},
+ Draft{ID: "settings.console.pin-check-failed", Text: "could not check the promotion record of %s. Try again",
+ Context: "Returned when the console could not read or verify the promotion record of the build being pinned. %s is the version."},
+ Draft{ID: "settings.console.not-enforced", Text: "pin, manual and major can be turned on once every box runs a release that applies them. %d boxes are not ready yet",
+ Context: "Returned when someone turns on pin, manual or major before every box is upgraded. %d is how many boxes are still on an older release. Clearing these settings is always allowed."},
+ Draft{ID: "settings.console.enforcement-unknown", Text: "could not check whether every box applies update settings, so pin, manual and major stay off for now. Try again",
+ Context: "Returned when someone turns on pin, manual or major and the console could not read which release each box runs. Clearing these settings is always allowed."},
+ Draft{ID: "settings.console.named-unit", Text: "%s is run by its own systemd unit, so it always takes its channel's builds. Update settings do not apply to it",
+ Context: "Returned for an instance whose process the platform does not start, such as a console. %s is the host."},
+ Draft{ID: "settings.console.not-instance", Text: "update settings apply to instances. %s is a %s route",
+ Context: "Returned for a static site or a service route. Host, kind."},
+ Draft{ID: "settings.console.channel-elsewhere", Text: "change the channel with carlos instances set --channel, which applies the app's pipeline rules",
+ Context: "Returned when a request to the settings door names channel. The channel has its own door with passkey and approval rules."},
+ Draft{ID: "settings.console.app-keeps", Text: "%s is kept by the app for itself. Ask someone who can change the app's settings",
+ Context: "Returned, once instances can act for themselves, when an instance tries to change a setting the app's policy keeps. %s is the setting. Not reachable until instance credentials ship."},
+ Draft{ID: "settings.console.pinned", Text: "%s is pinned to %s, so Update now would not move it. Clear the pin first",
+ Context: "Returned by carlos instances update and the Update now button on a pinned instance. Host, pinned version."},
+ Draft{ID: "settings.console.fast-track-owner", Text: "only an account owner can skip the safety delay",
+ Context: "Returned when someone who is not an account owner asks Update now to skip the bake window."},
+ Draft{ID: "settings.console.changed", Text: "this instance changed while the request was in flight. Try again",
+ Context: "Returned when two people changed the same instance at once and this request lost."},
+ Draft{ID: "settings.console.store-failed", Text: "could not save the update settings. Try again",
+ Context: "Returned when the console could not write the record."},
+ Draft{ID: "settings.console.read-failed", Text: "could not read this instance. Try again",
+ Context: "Returned by the update settings doors when the console could not read the instance's record."},
+ Draft{ID: "settings.console.policy-read-failed", Text: "could not read the app's update policy. Try again",
+ Context: "Returned by the update settings doors when the console could not read the app's instances manifest, which holds its update policy."},
+ Draft{ID: "settings.console.no-manifest", Text: "this app does not create instances yet. Run carlos instances enable first",
+ Context: "Returned by carlos apps set's update flags for an app with no instance provisioning."},
+ Draft{ID: "settings.console.unknown-policy-key", Text: "%s is not an app update setting. The app sets instance_control, default_updates, default_channel and default_major; there is no default pin",
+ Context: "Returned by the app policy door for any other key, for example a default pin. %s is the key."},
+ Draft{ID: "settings.console.bad-control", Text: "instance_control may name updates, channel, major and pin, each once",
+ Context: "Returned by carlos apps set --instance-control for anything else."},
+ Draft{ID: "settings.console.audit-set", Text: "changed the update settings of %s: %s",
+ Context: "Activity line after a settings change. Host, then what changed, for example updates manual, pin cleared."},
+ Draft{ID: "settings.console.audit-install", Text: "asked %s to update now (request %d)",
+ Context: "Activity line after Update now. Host, request number."},
+ Draft{ID: "settings.console.audit-policy", Text: "changed the instance update policy of %s: %s",
+ Context: "Activity line after an app policy change. App, then what changed."},
+ )
+}