plans-2-box-limits
Squashed from plans-2-box-limits into main.
33 files changed,
+1329
−9
cmd/console/main.go+3 −0docs/superpowers/plans/2026-10-21-plans-2-box-limits.md+227 −0docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md+35 −0docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md+42 −0internal/activator/activator.go+22 −0internal/activator/activator_idlefor_test.go+113 −0internal/console/accountplans.go+9 −2internal/console/accountplans_test.go+18 −0internal/console/apiaccountplan.go+5 −0internal/console/apiaccountplan_test.go+19 −0internal/console/claim_invariant_fixtures_test.go+1 −0internal/console/claim_invariant_probes_worker_test.go+11 −0internal/console/claim_workers.go+1 −0internal/console/plansmarker.go+63 −0internal/console/plansmarker_test.go+111 −0internal/console/testdata/claim-routes.txt+1 −0internal/edge/storedoor.go+2 −1internal/edge/storedoor_test.go+14 −0internal/host/agent.go+7 −0internal/host/plans_idle_test.go+29 −0internal/host/plans_pass.go+137 −0internal/host/plans_pass_test.go+235 −0internal/hostenv/hostenv.go+1 −0internal/hostops/limits_classifier_test.go+20 −0internal/hostops/mutation_classifier.go+1 −0internal/plans/catalog.go+21 −5internal/plans/catalog_test.go+14 −0internal/plans/marker.go+60 −0internal/plans/marker_test.go+58 −0internal/provision/plan14_root_reads_test.go+13 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/plans2_census_test.go+12 −0internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv+23 −0
CI passed — run details
CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
=== vet (18 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (165 s)
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.569s
ok github.com/carlosframework/platform/cmd/moveprobe 11.763s
ok github.com/carlosframework/platform/cmd/zddprobe 0.793s
ok github.com/carlosframework/platform/internal/activator 59.714s
ok github.com/carlosframework/platform/internal/agentsock 0.026s
ok github.com/carlosframework/platform/internal/alerting 0.010s
ok github.com/carlosframework/platform/internal/analytics 0.053s
ok github.com/carlosframework/platform/internal/appconfig 2.813s
ok github.com/carlosframework/platform/internal/apperr 0.142s
ok github.com/carlosframework/platform/internal/applog 0.250s
ok github.com/carlosframework/platform/internal/awsinv 0.009s
ok github.com/carlosframework/platform/internal/boxchoice 0.006s
ok github.com/carlosframework/platform/internal/boxid 0.903s
? 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.014s
ok github.com/carlosframework/platform/internal/certwarm 0.005s
ok github.com/carlosframework/platform/internal/cgroup 0.805s
ok github.com/carlosframework/platform/internal/changelog 0.450s
ok github.com/carlosframework/platform/internal/claimactivation 1.074s
ok github.com/carlosframework/platform/internal/clamd 0.068s
? github.com/carlosframework/platform/internal/cliflag [no test files]
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 5.676s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.012s
ok github.com/carlosframework/platform/internal/dbverify 0.008s
ok github.com/carlosframework/platform/internal/dnsrec 0.006s
ok github.com/carlosframework/platform/internal/docsite 0.011s
ok github.com/carlosframework/platform/internal/domains 2.707s
ok github.com/carlosframework/platform/internal/doorkey 0.010s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 45.692s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.011s
ok github.com/carlosframework/platform/internal/fleetapi 0.133s
ok github.com/carlosframework/platform/internal/gate 1.656s
ok github.com/carlosframework/platform/internal/genselect 0.018s
ok github.com/carlosframework/platform/internal/geo 0.013s
ok github.com/carlosframework/platform/internal/geoip 0.035s
ok github.com/carlosframework/platform/internal/historyreadback 0.155s
ok github.com/carlosframework/platform/internal/host 136.789s
ok github.com/carlosframework/platform/internal/hostactors 5.724s
ok github.com/carlosframework/platform/internal/hostactors/awsmembership 0.007s
ok github.com/carlosframework/platform/internal/hostbox 2.951s
ok github.com/carlosframework/platform/internal/hostclaim 0.822s
ok github.com/carlosframework/platform/internal/hostenv 0.007s
ok github.com/carlosframework/platform/internal/hostevidence 0.011s
ok github.com/carlosframework/platform/internal/hostexec 5.206s
ok github.com/carlosframework/platform/internal/hostfence 0.043s
ok github.com/carlosframework/platform/internal/hostfiles 10.319s
ok github.com/carlosframework/platform/internal/hostfloor 0.027s
ok github.com/carlosframework/platform/internal/hostguard 1.071s
ok github.com/carlosframework/platform/internal/hostops 1.970s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.007s
ok github.com/carlosframework/platform/internal/hostquarantine 0.007s
ok github.com/carlosframework/platform/internal/hostrelocate 0.598s
ok github.com/carlosframework/platform/internal/hostresolve 0.184s
ok github.com/carlosframework/platform/internal/hostwrite 0.007s
ok github.com/carlosframework/platform/internal/infraview 0.007s
ok github.com/carlosframework/platform/internal/instancectl 5.237s
ok github.com/carlosframework/platform/internal/instanceexec 2.794s
ok github.com/carlosframework/platform/internal/instancefs 0.011s
ok github.com/carlosframework/platform/internal/instancekey 0.007s
ok github.com/carlosframework/platform/internal/instanceupdates 0.011s
ok github.com/carlosframework/platform/internal/layout 1.740s
ok github.com/carlosframework/platform/internal/lbhealth 0.003s
ok github.com/carlosframework/platform/internal/lease 10.261s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.541s
ok github.com/carlosframework/platform/internal/ledger 0.017s
ok github.com/carlosframework/platform/internal/mail 0.283s
ok github.com/carlosframework/platform/internal/maintenance 0.094s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.596s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.117s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 22.598s
ok github.com/carlosframework/platform/internal/moves/archive 0.977s
ok github.com/carlosframework/platform/internal/objstore 0.309s
ok github.com/carlosframework/platform/internal/ops 0.182s
ok github.com/carlosframework/platform/internal/park 0.318s
ok github.com/carlosframework/platform/internal/plans 0.006s
ok github.com/carlosframework/platform/internal/privatefile 0.004s
ok github.com/carlosframework/platform/internal/provision 22.685s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 5.589s
ok github.com/carlosframework/platform/internal/relay 7.243s
ok github.com/carlosframework/platform/internal/release 27.004s
ok github.com/carlosframework/platform/internal/rollout 3.776s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/rootparticipation 0.068s
ok github.com/carlosframework/platform/internal/rootstate 0.028s
ok github.com/carlosframework/platform/internal/runs 0.511s
ok github.com/carlosframework/platform/internal/s3compat 0.007s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.600s
ok github.com/carlosframework/platform/internal/seeds 0.011s
ok github.com/carlosframework/platform/internal/sim/engines 2.560s
ok github.com/carlosframework/platform/internal/sim/hostlife 11.575s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.732s
ok github.com/carlosframework/platform/internal/status 0.077s
ok github.com/carlosframework/platform/internal/steering 0.008s
ok github.com/carlosframework/platform/internal/stopevidence 0.310s
ok github.com/carlosframework/platform/internal/svcrole 0.004s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.007s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.007s
ok github.com/carlosframework/platform/internal/updatewords 0.006s
ok github.com/carlosframework/platform/internal/usage 0.005s
ok github.com/carlosframework/platform/internal/version 0.003s
ok github.com/carlosframework/platform/internal/vet 1.719s
=== internal_console@0 (85 s)
ok github.com/carlosframework/platform/internal/console 83.199s
=== internal_console@1 (38 s)
ok github.com/carlosframework/platform/internal/console 36.125s
=== internal_console@2 (38 s)
ok github.com/carlosframework/platform/internal/console 35.876s
=== internal_console@3 (38 s)
ok github.com/carlosframework/platform/internal/console 35.870s
=== internal_console@4 (40 s)
ok github.com/carlosframework/platform/internal/console 37.749s
=== internal_console@5 (39 s)
ok github.com/carlosframework/platform/internal/console 37.106s
=== internal_console@6 (44 s)
ok github.com/carlosframework/platform/internal/console 41.691s
=== internal_console@7 (41 s)
ok github.com/carlosframework/platform/internal/console 38.791s
=== root@0 (34 s)
ok github.com/carlosframework/platform 32.204s
=== root@1 (45 s)
ok github.com/carlosframework/platform 42.973s
=== root@2 (45 s)
ok github.com/carlosframework/platform 43.508s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 50069666 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/22 08:26:30 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18E0CC9492DFFC22, 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.021s
minio contract: PASS
diff --git a/cmd/console/main.go b/cmd/console/main.go| index b2964f92e..3d0d11779 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -530,6 +530,9 @@ func serve() { |
| // The plan limits pass (internal/console/planlimits.go): publishes each |
| // account's limits record. A no-op with CARLOS_PLAN_CATALOG unset. |
| go srv.RunClaimWorker(context.Background(), "StartPlanLimitsPass") |
| + // The plans deployment marker (internal/console/plansmarker.go): once per |
| + // start. Writes nothing on a deployment that never set a catalog. |
| + go srv.RunClaimWorker(context.Background(), "PublishPlansMarker") |
| // The run sweep (internal/console/runsweep.go): expires queued runs, |
| // ends lost ones, deletes a day-old run's objects. Same placement rule. |
| go srv.RunClaimWorker(context.Background(), "StartRunSweep") |
diff --git a/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md b/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md| new file mode 100644 |
| index 000000000..30569f2a9 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-21-plans-2-box-limits.md |
| @@ -0,0 +1,227 @@ |
| +# Plans 2: Boxes Read Account Limits Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Boxes read each account's published limits and apply the plan's idle window to instances and sidecar workers. Self-hosted deployments without a plan catalog see no change. |
| + |
| +**Architecture:** The console publishes a deployment marker `control/plans.json` (enabled plus the catalog id) once at start. On every box the host agent's existing full and five-minute passes run a `plansPass` beside `telemetryPass`. It reads the marker, and only when plans are enabled does it read `accounts/<sqid>/limits.json` for the accounts this box serves, into a last-known-good in-memory cache. The activator gets an `IdleFor` hook that can only shorten an entry's idle window. A box-level switch `CARLOS_PLAN_LIMITS=off` turns all of it off with zero bucket reads. |
| + |
| +**Tech Stack:** Go; `internal/plans`, `internal/console`, `internal/host`, `internal/activator`, `internal/hostops`, `internal/edge`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md` §5.2 (idle window), §7 (how limits reach the boxes; fail open), §10 (provider boundary). Builds on Plan 1 (`docs/superpowers/plans/2026-10-20-plans-1-catalog-and-limits.md`, merged f550afa). |
| + |
| +## Self-hosters (Paul, 2026-10-21: "make sure this isn't going to interrupt them") |
| + |
| +Three guards, each pinned by a test: |
| + |
| +1. **No catalog, no change.** Without `CARLOS_PLAN_CATALOG` the console never writes `control/plans.json` as enabled. A box that finds the marker absent or disabled clears its cache and reads no limits record: one small read per pass (the marker), nothing else, and every idle window is exactly today's. |
| +2. **No record, no limits.** A box with no limits record for an account runs that account exactly as today (fail open, spec §7). |
| +3. **A box can opt out.** `CARLOS_PLAN_LIMITS=off` (also `false`, `0`, `no`) makes the box ignore plans entirely and read nothing. |
| + |
| +A deployment that turns its catalog off again: the console's next start writes the marker disabled, and boxes revert within one pass. |
| + |
| +## Scope changes from the spec's plan list (rulings, author's) |
| + |
| +- The **small memory tier** moves to Plan 2b. It touches nine allowlists across `internal/cgroup`, `internal/instancectl` and the root claim launch code, plus unit-backed apps' rendered drop-ins, and most of that is root-path code with its own census ruling. |
| +- **Throttling and the continuous-awake ceiling** move to Plan 4, where the grace engine that triggers them lives. Plan 2 carries no throttle state. |
| +- Plan 1's two deferred minors are fixed here (Task 1, Task 2). |
| + |
| +## Global Constraints |
| + |
| +- No new module dependencies. |
| +- Plans can only **shorten** an idle window: an entry's window is `min(its current window, plan idle)` (spec §5.2 "the smaller of its own and the plan's"). A plan never lengthens a box's `CARLOS_HIBERNATE_IDLE` and never touches an always-on worker. |
| +- Fail open everywhere on the box: a read error keeps the last good value, an absent record means no limits. |
| +- New box env vars go in `hostenv.Optional` (`internal/hostenv/hostenv.go:71`). |
| +- New bucket keys need a hostops classification and, if fleet boxes read them, the storedoor allowlist (`internal/edge/storedoor.go:436`). |
| +- New console workers: `claim_workers.go`, `cmd/console/main.go` RunClaimWorker, `testdata/claim-routes.txt`, a claim fixture and probe. |
| +- CI shards tests: no test depends on another top-level test having run first. New `internal/host` tests call `t.Parallel()` unless they set env, cwd, umask, log output or package vars; the cache is a package var, so tests that touch it stay serial. New `internal/activator` tests call `t.Parallel()` too unless they write package vars, env, log output or the umask, or listen on TCP (the rule above activator's TestMain, ci-speed-activator 4f55bfb). The Task 5 overlay loads after `plan14-root-reads-ci-speed-activator`. |
| +- Every change to covered packages re-pins the #280 root-read overlay; the record-write census layer may need entries too (Task 5). |
| +- Copy: this plan adds no human-facing text except log lines (not reviewed). |
| +- Local testing: touched packages only, with `-run`; CI runs the full suite. |
| + |
| +## Review Focus |
| + |
| +1. **Plans off.** With the marker absent, a box must make exactly one bucket read per pass (the marker) and apply no limits. Pinned in Task 3 (`TestPlansPassOffReadsOnlyTheMarker`). |
| +2. **Box switch off.** `CARLOS_PLAN_LIMITS=off` must make zero bucket reads and clear any cache. Pinned in Task 3 (`TestPlansPassBoxSwitchOffReadsNothing`). |
| +3. **Bucket blips.** A marker read error keeps the whole last-good cache; a per-account read error keeps that account's last value. Pinned in Task 3 (`TestPlansPassKeepsLastGoodOnErrors`). |
| +4. **Plans turned off again.** Marker enabled, then disabled: the next pass clears the cache and idle windows revert. Pinned in Task 3 (`TestPlansPassClearsWhenDisabled`) and Task 4 (`TestIdleForNilOrZeroChangesNothing`). |
| +5. **A plan idle longer than the box's default.** The box default stands. Pinned in Task 4 (`TestPlanIdleOnlyShortens`). |
| + |
| +--- |
| + |
| +### Task 1: The deployment marker, and checked pack arithmetic |
| + |
| +**Files:** |
| +- Create: `internal/plans/marker.go`, `internal/plans/marker_test.go` |
| +- Modify: `internal/plans/catalog.go` (`Effective`), `internal/plans/catalog_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + const MarkerSchema = 1 |
| + type Marker struct { |
| + Schema int `json:"schema"` |
| + Enabled bool `json:"enabled"` |
| + CatalogID string `json:"catalog_id,omitempty"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| + } |
| + func MarkerKey() string // "control/plans.json" |
| + func GetMarker(ctx context.Context, s release.Store) (*Marker, string, error) // nil, "", nil when absent |
| + func PutMarker(ctx context.Context, s release.Store, m *Marker, ifMatch string) (string, error) |
| + ``` |
| + `GetMarker` refuses an unknown schema (error, so the box keeps its last good state). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +func TestMarkerKey(t *testing.T) // MarkerKey() == "control/plans.json" |
| +func TestMarkerRoundTrip(t *testing.T) // PutMarker create-only then GetMarker equal; absent -> nil, "", nil |
| +func TestMarkerRefusesUnknownSchema(t *testing.T) // a stored {"schema": 2} -> GetMarker error |
| +func TestPutMarkerIsCAS(t *testing.T) // second create-only put -> release.ErrCASConflict |
| +func TestEffectiveRefusesOverflow(t *testing.T) // pack storage adds 1<<62, quantity 2, ceiling 100 -> error wrapping ErrPackQuantity, |
| + // never a negative or wrapped limit (Astra plans-1 finding 5) |
| +``` |
| + |
| +- [ ] **Step 2: Run them to verify they fail** — `go test ./internal/plans/` → FAIL (undefined `MarkerKey`). |
| + |
| +- [ ] **Step 3: Implement** `marker.go` on the pattern of `record.go`. In `Effective`, compute each pack's contribution with checked multiplication and addition (`math/bits.Mul64`/overflow checks, or compare against `math.MaxInt64 / qty`) and return `fmt.Errorf("%w: %q overflows", ErrPackQuantity, name)` on overflow. |
| + |
| +- [ ] **Step 4: Run** `go test ./internal/plans/` → PASS. |
| + |
| +- [ ] **Step 5: Commit** `plans: the deployment marker, and checked pack arithmetic` |
| + |
| +### Task 2: The console publishes the marker; plan writes need a live account |
| + |
| +**Files:** |
| +- Create: `internal/console/plansmarker.go`, `internal/console/plansmarker_test.go` |
| +- Modify: `internal/console/accountplans.go` (`SetAccountPlan`), `internal/console/accountplans_test.go`, `internal/console/apiaccountplan.go`, `internal/console/apiaccountplan_test.go` |
| +- Modify: `internal/console/claim_workers.go`, `cmd/console/main.go`, `internal/console/testdata/claim-routes.txt`, `internal/console/claim_invariant_fixtures_test.go`, `internal/console/claim_invariant_probes_worker_test.go` |
| +- Modify: `internal/hostops/mutation_classifier.go`, `internal/hostops/limits_classifier_test.go` |
| +- Modify: `internal/edge/storedoor.go` (`aclRead`), its test file |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1. |
| +- Produces: |
| + ```go |
| + func (s *Server) PublishPlansMarker(ctx context.Context) // claim worker "PublishPlansMarker"; runs once |
| + var ErrAccountGone = errors.New("account deleted") // console package |
| + ``` |
| + `SetAccountPlan` returns `ErrAccountGone` and writes nothing when the account's `deleted_at` is set (Astra plans-1 finding 3); the plan door answers 404 for it. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// plansmarker_test.go |
| +func TestPublishPlansMarkerOffAndAbsentWritesNothing(t *testing.T) // no catalog, no marker: bucket unchanged |
| +func TestPublishPlansMarkerEnables(t *testing.T) // catalog set: marker Enabled, CatalogID == catalog.ID() |
| +func TestPublishPlansMarkerIsIdempotent(t *testing.T) // second run with the same catalog: etag unchanged |
| +func TestPublishPlansMarkerDisablesWhenCatalogRemoved(t *testing.T) // marker enabled, then a run with no catalog: Enabled false |
| +// accountplans_test.go |
| +func TestSetAccountPlanRefusesADeletedAccount(t *testing.T) // deleted_at set: ErrAccountGone, no row |
| +// apiaccountplan_test.go |
| +func TestAccountPlanSetOnADeletedAccountIs404(t *testing.T) // deleted between lookup and write: 404, no record published |
| +// hostops limits_classifier_test.go |
| +func TestClassifyPlansMarkerIsExact(t *testing.T) // Put on control/plans.json: non-app; Delete and lookalikes: ErrUnclassified |
| +// edge storedoor test |
| +func TestFleetBoxCanReadThePlansMarker(t *testing.T) // aclRead(sqid, fleet, "control/plans.json") == true |
| +``` |
| + |
| +- [ ] **Step 2: Run** the touched packages' tests → FAIL. |
| + |
| +- [ ] **Step 3: Implement.** `PublishPlansMarker`: with a catalog, `GetMarker`; if absent or different (`!Enabled || CatalogID != cat.ID()`), `PutMarker` with the read etag. Without a catalog, `GetMarker`; only if present and `Enabled`, write it `Enabled: false`. Log, never fail. `SetAccountPlan`: guard the insert and the update with `EXISTS (SELECT 1 FROM accounts WHERE id = ? AND deleted_at = 0)`, returning `ErrAccountGone` when no row changes. hostops: add `{exact: "control/plans.json", reason: "plans deployment marker published by the console; no app state"}` to `nonAppFamilies` (deletes false). storedoor: add `plans.MarkerKey()` to the exact case in `aclRead`. |
| + |
| +- [ ] **Step 4: Register and start the worker** (`claim_workers.go`, `cmd/console/main.go` beside `StartPlanLimitsPass`), add `worker PublishPlansMarker` to `claim-routes.txt`, a `claimNonApp` fixture and a probe asserting the marker is written when a catalog is set. |
| + |
| +- [ ] **Step 5: Run** `go test ./internal/plans/ ./internal/hostops/ ./internal/edge/ -run 'Marker|Classify|Fleet'`, `go test ./internal/console/ -run 'PlansMarker|AccountPlan|ClaimConsoleRouteCensus'`, `go test ./cmd/console/ -run TestEveryRegisteredClaimWorkerIsStarted` → PASS. |
| + |
| +- [ ] **Step 6: Commit** `console: publish the plans marker; plan writes need a live account` |
| + |
| +### Task 3: The box's plans pass and cache |
| + |
| +**Files:** |
| +- Create: `internal/host/plans_pass.go`, `internal/host/plans_pass_test.go` |
| +- Modify: `internal/host/agent.go` (call `plansPass` beside both `telemetryPass` calls, `agent.go:2809` and `:3395`), `internal/hostenv/hostenv.go` (`CARLOS_PLAN_LIMITS`) |
| + |
| +**Interfaces:** |
| +- Consumes: `plans.GetMarker`, `plans.GetLimits` (Task 1, Plan 1). |
| +- Produces: |
| + ```go |
| + func plansPass(ctx context.Context, s release.Store, reg *registry.Registry, getenv func(string) string) |
| + func planLimitsFor(sqid string) (plans.Limits, bool) // false when plans are off or the account has no record |
| + func planLimitsSwitchOn(getenv func(string) string) bool // false for off/false/0/no (case-insensitive) |
| + ``` |
| + The cache is a package-level `atomic.Pointer` to an immutable snapshot `{byAccount map[string]plans.Limits}`; `nil` means off. |
| + |
| +- [ ] **Step 1: Write the failing tests** (serial: they touch the package cache), using `release.NewDirStore(t.TempDir())` wrapped in a counting store that records every `Get` key and can fail a key once, and a registry with routes for two accounts: |
| + |
| +```go |
| +func TestPlanLimitsSwitch(t *testing.T) // "", "on": true; "off", "OFF", "false", "0", "no": false |
| +func TestPlansPassOffReadsOnlyTheMarker(t *testing.T) // no marker: Gets == ["control/plans.json"]; planLimitsFor(any) false |
| +func TestPlansPassBoxSwitchOffReadsNothing(t *testing.T) // CARLOS_PLAN_LIMITS=off with an enabled marker and records: zero Gets; cache cleared |
| +func TestPlansPassLoadsServedAccounts(t *testing.T) // enabled marker, records for A and B: planLimitsFor(A).IdleSeconds == 90; an account with no routes on this box is not read |
| +func TestPlansPassAbsentRecordMeansNoLimits(t *testing.T) // enabled, no record for B: planLimitsFor(B) false |
| +func TestPlansPassKeepsLastGoodOnErrors(t *testing.T) // after a good pass: marker Get fails -> A and B unchanged; A's record Get fails -> A unchanged, B refreshed |
| +func TestPlansPassClearsWhenDisabled(t *testing.T) // enabled then marker Enabled false: planLimitsFor(A) false |
| +``` |
| + |
| +- [ ] **Step 2: Run** `go test ./internal/host/ -run 'PlansPass|PlanLimitsSwitch'` → FAIL. |
| + |
| +- [ ] **Step 3: Implement** `plans_pass.go` after `telemetry_pass.go`'s shape: switch off → store nil, return. `GetMarker` error → log, return (keep). Absent or disabled → store nil, return. Otherwise collect distinct `release.RouteAccount(rt)` from `reg.List()` (skip status routes), `GetLimits` each: error → copy the previous value for that account if any; nil → omit; else set. Store the new snapshot. Log first failure and every tenth, as `homemap.go`'s loop does. |
| + |
| +- [ ] **Step 4: Wire** `plansPass(ctx, store, reg, os.Getenv)` after both `telemetryPass` calls; add `"CARLOS_PLAN_LIMITS"` to `hostenv.Optional` in sorted position. |
| + |
| +- [ ] **Step 5: Run** `go test ./internal/host/ -run 'PlansPass|PlanLimitsSwitch' ./internal/hostenv/` → PASS. |
| + |
| +- [ ] **Step 6: Commit** `host: read account limits each pass, marker-gated, with a box off switch` |
| + |
| +### Task 4: The activator's idle hook |
| + |
| +**Files:** |
| +- Modify: `internal/activator/activator.go` (`Service` gains `IdleFor`; the sweep at `:4646`) |
| +- Modify: `internal/activator/activator_test.go` |
| +- Modify: `internal/host/agent.go` (set `svc.IdleFor` after `activator.New`, `agent.go:490`) |
| +- Create: `internal/host/plans_idle_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `planLimitsFor` (Task 3). |
| +- Produces: |
| + ```go |
| + // Service field, set before the service is published: |
| + IdleFor func(registry.Route) time.Duration // 0 = no opinion; nil = today's behaviour |
| + func planIdleFor(rt registry.Route) time.Duration // internal/host: Limits.IdleSeconds as a duration, 0 when none |
| + ``` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +// activator_test.go, beside TestActivatorIdleSleep and TestWorkerRouteIdleWindowOverridesPlatformDefault |
| +func TestIdleForShortensAnInstance(t *testing.T) // cfg.Idle 15m, IdleFor returns 90s: the entry sleeps after 90s idle, not before |
| +func TestPlanIdleOnlyShortens(t *testing.T) // cfg.Idle 5m, IdleFor 15m: sleeps at 5m |
| +func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) // worker IdleSeconds 600, IdleFor 90s: 90s; worker 60, IdleFor 90s: 60s |
| +func TestIdleForNilOrZeroChangesNothing(t *testing.T) // nil hook, and a hook returning 0: today's windows |
| +func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) // Hibernate=false worker with IdleFor 90s: never stopped for idleness |
| +// plans_idle_test.go (serial) |
| +func TestPlanIdleFor(t *testing.T) // cache off: 0; account with IdleSeconds 90: 90s; account absent: 0 |
| +``` |
| + |
| +- [ ] **Step 2: Run** `go test ./internal/activator/ -run IdleFor` and `go test ./internal/host/ -run TestPlanIdleFor` → FAIL. |
| + |
| +- [ ] **Step 3: Implement.** In the sweep, after the current `idleWindow` is chosen: `if f := a.IdleFor; f != nil { if d := f(e.route); d > 0 && d < idleWindow { idleWindow = d } }`. The "held for tick" check already uses `idleWindow`. `planIdleFor` reads `planLimitsFor(release.RouteAccount(rt))`. In `agent.go`, set `svc.IdleFor = planIdleFor` right after `activator.New`. |
| + |
| +- [ ] **Step 4: Run** the two commands above and `go test ./internal/activator/ -run 'Idle|Worker|AlwaysOn'` → PASS. |
| + |
| +- [ ] **Step 5: Commit** `activator: a plan can shorten an entry's idle window` |
| + |
| +### Task 5: Census, then the branch gates |
| + |
| +**Files:** `internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv` (new), `internal/provision/plan14_root_reads_test.go`, possibly a record-write census layer beside `internal/provision/plans_caps_census_test.go`. |
| + |
| +- [ ] **Step 1:** Run `GOFLAGS=-buildvcs=false go test ./internal/provision/ -run 'TestPlan14RootReadCensus|TestPlan9B8'`. For each discrepancy, regenerate the overlay with the scratch-generator recipe used for Plan 1 (load every overlay, print `@legacy`/rows/`@coverage` for changed and new units, compute the seal), with basis text naming the executing identity: the box agent's plans pass reads two bucket objects and makes no root decision; the activator change narrows an idle window. Load it last; mark `rev3=PROPOSED-plans-2-box-limits`. |
| +- [ ] **Step 2:** Run the full claim invariant once: `nice -n 19 go test ./internal/console/ -run TestClaimConsoleMutationInvariant -timeout 30m` → ok. |
| +- [ ] **Step 3:** Commit `plans 2: census overlay`, merge current main, push, wait for CI green, ask for the census ruling on #280. |
| + |
| +## Done when |
| + |
| +- The commands in each task pass locally; CI is green on the tip after merging current main. |
| +- Review Focus 1–5 each have their named test passing. |
| +- The branch's final review (Astra, whole branch) has no open Critical or Important finding. |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md| new file mode 100644 |
| index 000000000..a785eb0ec |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-PLANS-2-CODE.md |
| @@ -0,0 +1,35 @@ |
| +1. **Important — A transient startup failure can leave plans enabled indefinitely.** [internal/console/plansmarker.go:21](internal/console/plansmarker.go:21) |
| + |
| + Remove the catalog and restart the console while the marker GET or PUT fails. The worker logs the error and exits permanently; no later pass retries it. Boxes continue reading the old enabled marker and enforcing limits after the bucket recovers. Initial enablement can likewise remain ineffective indefinitely. |
| + |
| + **Fix:** retry startup reconciliation with backoff until the desired marker state is established, rereading after CAS conflicts. Keep the successful no-catalog/no-marker path inert. Add transient read/write failure tests covering disablement. |
| + |
| +2. **Important — Concurrent passes can restore disabled limits or overwrite newer values.** [internal/host/plans_pass.go:107](internal/host/plans_pass.go:107) |
| + |
| + `fastConvergeLoop` and `instancesLoop` independently call `plansPass`. Pass A can read an enabled marker and stall fetching limits. After the marker is disabled, pass B clears the cache; A then finishes and unconditionally installs its enabled snapshot. Limits return until another successful pass. Similarly, A’s captured `prev` can overwrite limits that B refreshed while A encountered a per-account error. |
| + |
| + Atomic pointers prevent data races, but do not order these operations. |
| + |
| + **Fix:** serialize the complete pass, including disable paths, or introduce publication ordering that rejects stale work. Add a blocking-store test that forces these interleavings. |
| + |
| +3. **Minor — The refreshed census justification misdescribes the sweep change.** [internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv:9](internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv:9) |
| + |
| + Both the sweep row and its `@legacy` declaration retain “diagnostic throttling only” while freezing a body that now uses account limits to change hibernation and held-for-tick decisions. A reviewer relying on that justification receives an inaccurate account of the behavioral change. |
| + |
| + **Fix:** update both justifications to describe the idle hook, executing identity and retained lifecycle guards; regenerate the affected seal. |
| + |
| +**Declined to judge** |
| + |
| +- Spec §3’s settled commercial and hibernation policies — explicitly excluded from reconsideration. |
| +- Small memory tiers — explicitly deferred to Plan 2b. |
| +- Throttling and continuous-awake ceilings — explicitly deferred to Plan 4. |
| +- Preserving always-on workers — required by this plan; admission restrictions belong to separate work. |
| +- The atomic `SetIdleFor` ruling — accepted; it safely publishes the callback. |
| +- The prescribed full/five-minute refresh cadence — accepted as scope. |
| +- Final census-owner approval and remote CI status — not verified in this environment. |
| + |
| +The five required cases look correct in sequential execution; finding 2 breaks disablement and last-good preservation under overlap. The live-account SQL passed an in-memory SQLite probe covering live inserts/updates and missing/deleted accounts. Changed Go files are formatted, and `git diff --check` passed. |
| + |
| +Go tests could not start because the sandbox refused creation of Go’s build directory. Darwin/Windows builds remain unverified. HEAD remains `5e5a13e745a16bd0d44aedaa5d8555685dd06726`; the working tree is unchanged. |
| + |
| +**Verdict: ready with fixes. Resolve both Important findings and run the blocked checks before merging.** |
| \ No newline at end of file |
diff --git a/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md| new file mode 100644 |
| index 000000000..da1a28407 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-REVIEW-BRIEF-PLANS-2-CODE.md |
| @@ -0,0 +1,42 @@ |
| +# Whole-branch code review brief: Plans 2 (boxes read account limits) |
| + |
| +You are a senior Go reviewer on the carlos platform. Review branch |
| +`plans-2-box-limits`, range `4a1e4b29a7196a2a0acb505cd4f8a3dcea054ad0..HEAD` (`git diff 4a1e4b29a7196a2a0acb505cd4f8a3dcea054ad0..HEAD`; the |
| +docs under docs/superpowers/ are requirements, not code to review). The |
| +review is read-only: do not modify the working tree, index, HEAD or |
| +branches. You may run `go test` for packages you want to probe (set |
| +GOFLAGS=-buildvcs=false). |
| + |
| +Requirements, in order of authority: |
| +1. Spec: docs/superpowers/specs/2026-10-20-plans-caps-packs-design.md |
| + (§5.2 idle window, §7 limits reach the boxes and fail open, §10). Its |
| + section 3 decisions are not to be argued with. |
| +2. Plan: docs/superpowers/plans/2026-10-21-plans-2-box-limits.md, especially |
| + "Self-hosters": Paul's requirement that deployments without a plan |
| + catalog are never interrupted. |
| +3. Executor rulings: `.superpowers/sdd/2026-10-21-plans-2-box-limits/progress.md`, |
| + lines containing `Ruling:`. Weigh each. |
| + |
| +The spec is a vision document. For behaviour it is silent on, judge by what a |
| +reasonable operator, self-hoster or customer would expect, and grade by effect. |
| + |
| +Check these deliberately (the plan's Review Focus): |
| +1. Plans off (marker absent): exactly one bucket read per pass, no limits. |
| +2. CARLOS_PLAN_LIMITS=off: zero bucket reads, cache cleared. |
| +3. Bucket blips: marker read error keeps the whole cache; a per-account |
| + error keeps that account's last value. |
| +4. Plans turned off again: marker disabled clears the cache; windows revert. |
| +5. A plan idle longer than the box default: the box default stands. |
| + |
| +Also look hard at: any path where a self-hosted box (no catalog ever) changes |
| +behaviour or cost; the activator sweep change (always-on workers, held-for-tick, |
| +probe entries, sidecar workers); races on the package-level cache and the |
| +atomic hook; the console marker publisher (once per start, disable path, |
| +CAS); SetAccountPlan's live-account guard SQL; the hostops and storedoor |
| +entries for control/plans.json; the census overlay rows' honesty; and |
| +darwin/windows builds. |
| + |
| +Output: findings most severe first, each Critical / Important / Minor with |
| +file:line, the concrete failure scenario and a suggested fix; then "Declined |
| +to judge" (every behaviour set aside, one line each with the reason); then a |
| +verdict: ready to merge, ready with fixes, or not ready. |
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index a0d56891b..dbd881d66 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1139,6 +1139,11 @@ type Service struct { |
| // edge chose before recovery moved their upstream. |
| startupSeenAt atomic.Int64 |
| + // idleFor is the plans idle hook (plans 2), set through SetIdleFor: given an |
| + // entry's route it names the plan's idle window, or 0 for no opinion. It |
| + // can only shorten a window. nil means today's behaviour. |
| + idleFor atomic.Pointer[func(registry.Route) time.Duration] |
| + |
| cfg Config |
| backend Backend |
| due DueStore |
| @@ -4647,6 +4652,11 @@ func (a *Service) sweepOnce() { |
| if e.workerIdle > 0 { |
| idleWindow = e.workerIdle |
| } |
| + if f := a.idleFor.Load(); f != nil { |
| + if d := (*f)(e.route); d > 0 && d < idleWindow { |
| + idleWindow = d |
| + } |
| + } |
| if hibernates && e.inflight == 0 && e.cmd == nil && now.Sub(e.lastActive) >= idleWindow { |
| // Don't sleep into an imminent tick. A wake/sleep pair is a full |
| // checkpoint-and-restore round trip; spending one to be absent |
| @@ -5446,3 +5456,15 @@ func (a *Service) serveWakeError(w http.ResponseWriter, r *http.Request, rt regi |
| } |
| return true |
| } |
| + |
| +// SetIdleFor installs the plans idle hook (plans 2). A plan can only shorten |
| +// an entry's idle window: the sweep takes the smaller of the entry's own |
| +// window and the hook's answer, and ignores 0. Always-on workers have no idle |
| +// clock, so the hook never reaches them. nil removes the hook. |
| +func (a *Service) SetIdleFor(f func(registry.Route) time.Duration) { |
| + if f == nil { |
| + a.idleFor.Store(nil) |
| + return |
| + } |
| + a.idleFor.Store(&f) |
| +} |
diff --git a/internal/activator/activator_idlefor_test.go b/internal/activator/activator_idlefor_test.go| new file mode 100644 |
| index 000000000..7eb8f2835 |
| --- /dev/null |
| +++ b/internal/activator/activator_idlefor_test.go |
| @@ -0,0 +1,113 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The plans idle hook (plans 2): a plan may only shorten an entry's idle |
| +// window, never lengthen it, and never touches an always-on worker. |
| + |
| +func idleFor(d time.Duration) func(registry.Route) time.Duration { |
| + return func(registry.Route) time.Duration { return d } |
| +} |
| + |
| +func wakeInstance(t *testing.T, a *Service, host string) { |
| + t.Helper() |
| + release, err := a.beginRequest(context.Background(), testRoute(host)) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| +} |
| + |
| +func TestIdleForShortensAnInstance(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, 15*time.Minute) |
| + a.SetIdleFor(idleFor(90 * time.Second)) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(80 * time.Second) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("slept before the plan's 90s window") |
| + } |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "plan idle sleep", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +func TestPlanIdleOnlyShortens(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, 5*time.Minute) |
| + a.SetIdleFor(idleFor(15 * time.Minute)) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(5*time.Minute + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "box-default sleep", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) { |
| + t.Parallel() |
| + for _, c := range []struct { |
| + name string |
| + worker int |
| + plan, sleep time.Duration |
| + }{ |
| + {"plan shorter than the worker's own", 600, 90 * time.Second, 90 * time.Second}, |
| + {"worker's own shorter than the plan", 60, 90 * time.Second, 60 * time.Second}, |
| + } { |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + a.SetIdleFor(idleFor(c.plan)) |
| + rt := testWorkerRoute("bot.x.internal") |
| + rt.IdleSeconds = c.worker |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("%s: WakeWorker: %v", c.name, err) |
| + } |
| + clk.advance(c.sleep - 5*time.Second) |
| + a.sweepOnce() |
| + if !a.entryAwake("bot.x.internal") { |
| + t.Fatalf("%s: slept early", c.name) |
| + } |
| + clk.advance(6 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, c.name, func() bool { return a.entryCount() == 0 }) |
| + } |
| +} |
| + |
| +func TestIdleForNilOrZeroChangesNothing(t *testing.T) { |
| + t.Parallel() |
| + for name, hook := range map[string]func(registry.Route) time.Duration{"nil": nil, "zero": idleFor(0)} { |
| + a, _, clk, _ := testActivator(t, 10*time.Second) |
| + a.SetIdleFor(hook) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(9 * time.Second) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatalf("%s hook: slept before the box default", name) |
| + } |
| + clk.advance(2 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, name+" hook: default sleep", func() bool { return a.entryCount() == 0 }) |
| + } |
| +} |
| + |
| +func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + a.SetIdleFor(idleFor(90 * time.Second)) |
| + rt := testWorkerRoute("bot.x.internal") |
| + rt.Hibernate = false |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + for i := 0; i < 5; i++ { |
| + clk.advance(time.Hour) |
| + a.sweepOnce() |
| + } |
| + if a.entryCount() != 1 { |
| + t.Fatal("an always-on worker was stopped by the plan's idle window") |
| + } |
| +} |
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go| index 2f5a5ce7c..2a2cac308 100644 |
| --- a/internal/console/accountplans.go |
| +++ b/internal/console/accountplans.go |
| @@ -18,6 +18,10 @@ const ( |
| PlanSourceBilling = "billing" |
| ) |
| +// ErrAccountGone refuses a plan write for an account that does not exist or |
| +// has been deleted. |
| +var ErrAccountGone = errors.New("account deleted") |
| + |
| // planDefaultActor is who a default row names as its writer. |
| const planDefaultActor = "plans" |
| @@ -93,12 +97,15 @@ func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]in |
| var rev int64 |
| err = s.db.QueryRow(`INSERT INTO account_plans |
| (account_id, plan, packs, source, sponsored, reason, revision, updated_by, updated_at) |
| - VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?) |
| + SELECT ?, ?, ?, ?, ?, ?, 1, ?, ? WHERE EXISTS (SELECT 1 FROM accounts WHERE id = ? AND deleted_at = 0) |
| ON CONFLICT(account_id) DO UPDATE SET plan = excluded.plan, packs = excluded.packs, |
| source = excluded.source, sponsored = excluded.sponsored, reason = excluded.reason, |
| updated_by = excluded.updated_by, updated_at = excluded.updated_at, revision = revision + 1 |
| RETURNING revision`, |
| - accountID, plan, string(pj), source, sj, reason, by, now.Unix()).Scan(&rev) |
| + accountID, plan, string(pj), source, sj, reason, by, now.Unix(), accountID).Scan(&rev) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return 0, ErrAccountGone |
| + } |
| return rev, err |
| } |
diff --git a/internal/console/accountplans_test.go b/internal/console/accountplans_test.go| index bd099ba56..4b1553c22 100644 |
| --- a/internal/console/accountplans_test.go |
| +++ b/internal/console/accountplans_test.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "errors" |
| "path/filepath" |
| "reflect" |
| "testing" |
| @@ -165,3 +166,20 @@ func TestMarkPlanPublishedIgnoresAStaleRevision(t *testing.T) { |
| t.Fatalf("published %d / %q", row.PublishedRevision, row.PublishedCatalog) |
| } |
| } |
| + |
| +// Astra plans-1 finding 3: a plan write must not land on a deleted account. |
| +func TestSetAccountPlanRefusesADeletedAccount(t *testing.T) { |
| + st, ids := planStore(t, "gone") |
| + if _, err := st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), ids["gone"]); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := st.SetAccountPlan(ids["gone"], "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); !errors.Is(err, ErrAccountGone) { |
| + t.Fatalf("err %v, want ErrAccountGone", err) |
| + } |
| + if row, _ := st.AccountPlan(ids["gone"]); row != nil { |
| + t.Fatalf("wrote %+v", row) |
| + } |
| + if _, err := st.SetAccountPlan(999999, "free", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); !errors.Is(err, ErrAccountGone) { |
| + t.Fatalf("unknown account: err %v, want ErrAccountGone", err) |
| + } |
| +} |
diff --git a/internal/console/apiaccountplan.go b/internal/console/apiaccountplan.go| index 88c5cc853..d21fceb2b 100644 |
| --- a/internal/console/apiaccountplan.go |
| +++ b/internal/console/apiaccountplan.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "errors" |
| "fmt" |
| "log" |
| "net/http" |
| @@ -119,6 +120,10 @@ func (s *Server) handleAPIAccountPlanSet(w http.ResponseWriter, r *http.Request) |
| } |
| now := time.Now() |
| if _, err := s.store.SetAccountPlan(acct.ID, req.Plan, req.Packs, source, req.Sponsored, reason, addr, now); err != nil { |
| + if errors.Is(err, ErrAccountGone) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| log.Printf("console: api account plan %s: save: %v", acct.Sqid, err) |
| jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| return |
diff --git a/internal/console/apiaccountplan_test.go b/internal/console/apiaccountplan_test.go| index cfa1382a4..5084b18ab 100644 |
| --- a/internal/console/apiaccountplan_test.go |
| +++ b/internal/console/apiaccountplan_test.go |
| @@ -200,3 +200,22 @@ func TestAccountPlanSetRefusesUnknownFields(t *testing.T) { |
| t.Fatalf("a refused POST wrote %+v", row) |
| } |
| } |
| + |
| +// Astra plans-1 finding 3: an account deleted between the door's lookup and |
| +// its write gets no plan row and no published record. |
| +func TestAccountPlanSetOnADeletedAccountIs404(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-deleted") |
| + if _, err := fx.st.db.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, planNow.Unix(), acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _ := postRaw(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "free", "reason": "r"}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("POST on a deleted account = %d, want 404", status) |
| + } |
| + if r, _ := readLimits(t, fx, acct.Sqid); r != nil { |
| + t.Fatalf("published %+v for a deleted account", r) |
| + } |
| +} |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 6553bfad9..f409dfd1e 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1926,6 +1926,7 @@ var claimFixtures = map[string]claimFixture{ |
| "worker StartHostReports": claimNonApp("this console's census report (status/consoles) and roster activation release"), |
| "worker StartInfraPass": claimNonApp("the deployment's infrastructure inventory snapshot"), |
| "worker StartPlanLimitsPass": claimNonApp("account plan rows and their limits records under accounts/<sqid>/limits.json; names no app"), |
| + "worker PublishPlansMarker": claimNonApp("the plans deployment marker (control/plans.json): whether boxes read limits; names no app"), |
| "worker StartClaimRecoveryPass": {door: &claimDoor{ |
| apps: claimRefs("bab/titogo"), |
| setup: func(t *testing.T, cx *claimCase) { cx.crashedRestore(t) }, |
diff --git a/internal/console/claim_invariant_probes_worker_test.go b/internal/console/claim_invariant_probes_worker_test.go| index 765f8db9a..a79737061 100644 |
| --- a/internal/console/claim_invariant_probes_worker_test.go |
| +++ b/internal/console/claim_invariant_probes_worker_test.go |
| @@ -194,6 +194,17 @@ var claimWorkerProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "worker PublishPlansMarker": { |
| + setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + do: claimWorkerReply("PublishPlansMarker"), |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + var m plans.Marker |
| + if !cx.bucketJSON(plans.MarkerKey(), &m) || !m.Enabled { |
| + return "no enabled plans marker was published" |
| + } |
| + return "" |
| + }, |
| + }, |
| "worker StartPlanLimitsPass": { |
| setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| do: claimWorkerReply("StartPlanLimitsPass"), |
diff --git a/internal/console/claim_workers.go b/internal/console/claim_workers.go| index 803e6bbc7..ed947d88c 100644 |
| --- a/internal/console/claim_workers.go |
| +++ b/internal/console/claim_workers.go |
| @@ -15,6 +15,7 @@ func (s *Server) registerClaimWorkers() { |
| "StartScanDrain": s.StartScanDrain, |
| "StartExpiryPass": s.StartExpiryPass, |
| "StartPlanLimitsPass": s.StartPlanLimitsPass, |
| + "PublishPlansMarker": s.PublishPlansMarker, |
| "StartRunSweep": s.StartRunSweep, |
| "StartSeedsPass": s.StartSeedsPass, |
| "StartRelay": s.StartRelay, |
diff --git a/internal/console/plansmarker.go b/internal/console/plansmarker.go| new file mode 100644 |
| index 000000000..4493771bd |
| --- /dev/null |
| +++ b/internal/console/plansmarker.go |
| @@ -0,0 +1,63 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// plansMarkerRetry is the first wait between attempts; it doubles up to |
| +// plansMarkerRetryMax. A var so a test can shrink it. |
| +var plansMarkerRetry = 5 * time.Second |
| + |
| +const plansMarkerRetryMax = 5 * time.Minute |
| + |
| +// PublishPlansMarker tells boxes whether plans are on (plans 2). It runs once |
| +// per console start. With a catalog it makes sure control/plans.json is |
| +// enabled under this catalog's id; without one it touches nothing unless an |
| +// enabled marker is left over, which it turns off so boxes stop applying |
| +// limits within a pass. A self-hosted deployment that never configured a |
| +// catalog never gets a marker. |
| +func (s *Server) PublishPlansMarker(ctx context.Context) { |
| + if s.cfg.Releases == nil { |
| + return |
| + } |
| + // Retry until the marker is right: a bucket blip at start must not leave |
| + // boxes applying limits after the catalog was removed, or never applying |
| + // them after it was added (Astra plans-2 finding 1). |
| + wait := plansMarkerRetry |
| + for !s.publishPlansMarkerOnce(ctx) { |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-time.After(wait): |
| + } |
| + wait = min(2*wait, plansMarkerRetryMax) |
| + } |
| +} |
| + |
| +// publishPlansMarkerOnce makes one attempt and reports whether the marker is |
| +// now as it should be (including "nothing to do"). |
| +func (s *Server) publishPlansMarkerOnce(ctx context.Context) bool { |
| + cur, etag, err := plans.GetMarker(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: plans marker: read: %v", err) |
| + return false |
| + } |
| + want := plans.Marker{UpdatedAt: time.Now().UTC().Truncate(time.Second)} |
| + if cat := s.cfg.PlanCatalog; cat != nil { |
| + if cur != nil && cur.Enabled && cur.CatalogID == cat.ID() { |
| + return true |
| + } |
| + want.Enabled, want.CatalogID = true, cat.ID() |
| + } else if cur == nil || !cur.Enabled { |
| + return true |
| + } |
| + if _, err := plans.PutMarker(ctx, s.cfg.Releases, &want, etag); err != nil { |
| + log.Printf("console: plans marker: write: %v", err) |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/internal/console/plansmarker_test.go b/internal/console/plansmarker_test.go| new file mode 100644 |
| index 000000000..ab19bf98a |
| --- /dev/null |
| +++ b/internal/console/plansmarker_test.go |
| @@ -0,0 +1,111 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func readMarker(t *testing.T, fx *consoleFixture) (*plans.Marker, string) { |
| + t.Helper() |
| + m, etag, err := plans.GetMarker(context.Background(), fx.rel) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m, etag |
| +} |
| + |
| +func TestPublishPlansMarkerOffAndAbsentWritesNothing(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + if m, _ := readMarker(t, fx); m != nil { |
| + t.Fatalf("wrote a marker with no catalog: %+v", m) |
| + } |
| +} |
| + |
| +func TestPublishPlansMarkerEnables(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + m, _ := readMarker(t, fx) |
| + if m == nil || !m.Enabled || m.CatalogID != fx.srv.cfg.PlanCatalog.ID() { |
| + t.Fatalf("marker %+v", m) |
| + } |
| +} |
| + |
| +func TestPublishPlansMarkerIsIdempotent(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + _, first := readMarker(t, fx) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + if _, second := readMarker(t, fx); second != first { |
| + t.Fatal("an up-to-date marker was rewritten") |
| + } |
| +} |
| + |
| +func TestPublishPlansMarkerFollowsACatalogChange(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + next := bumpedCatalog(t, "2026-10-22-v2", "4") |
| + fx.srv.cfg.PlanCatalog = next |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + if m, _ := readMarker(t, fx); m.CatalogID != next.ID() { |
| + t.Fatalf("marker %+v, want catalog %s", m, next.ID()) |
| + } |
| +} |
| + |
| +func TestPublishPlansMarkerDisablesWhenCatalogRemoved(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + fx.srv.cfg.PlanCatalog = nil |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + if m, _ := readMarker(t, fx); m == nil || m.Enabled { |
| + t.Fatalf("marker %+v, want disabled", m) |
| + } |
| +} |
| + |
| +// Astra plans-2 finding 1: a bucket failure at start must not leave the |
| +// marker in the wrong state for the life of the console. The worker retries |
| +// until the marker is right. |
| +func TestPublishPlansMarkerRetriesUntilDisabled(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + fx.srv.PublishPlansMarker(context.Background()) |
| + defer func(d time.Duration) { plansMarkerRetry = d }(plansMarkerRetry) |
| + plansMarkerRetry = time.Millisecond |
| + fp := &planFailingPuts{Store: fx.rel} |
| + fp.n.Store(3) // the first three writes fail |
| + fx.srv.cfg.Releases = fp |
| + fx.srv.cfg.PlanCatalog = nil |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + fx.srv.PublishPlansMarker(ctx) |
| + fx.srv.cfg.Releases = fp.Store |
| + if m, _ := readMarker(t, fx); m == nil || m.Enabled { |
| + t.Fatalf("marker %+v after transient write failures, want disabled", m) |
| + } |
| +} |
| + |
| +func TestPublishPlansMarkerStopsWithItsContext(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + defer func(d time.Duration) { plansMarkerRetry = d }(plansMarkerRetry) |
| + plansMarkerRetry = time.Millisecond |
| + fp := &planFailingPuts{Store: fx.rel} |
| + fp.n.Store(1 << 30) // never succeeds |
| + fx.srv.cfg.Releases = fp |
| + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) |
| + defer cancel() |
| + done := make(chan struct{}) |
| + go func() { fx.srv.PublishPlansMarker(ctx); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("PublishPlansMarker kept retrying after its context ended") |
| + } |
| +} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 3a3c4ada4..bb81d7f5f 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -287,6 +287,7 @@ route PUT /api/cli/system/geoip |
| route PUT /api/cli/system/upload |
| worker LogPlacementDiscrepancies |
| worker PublishMovesConsole |
| +worker PublishPlansMarker |
| worker ReportApexSquats |
| worker StartAlertWatcher |
| worker StartClaimRecoveryPass |
diff --git a/internal/edge/storedoor.go b/internal/edge/storedoor.go| index e341b9712..2d014501e 100644 |
| --- a/internal/edge/storedoor.go |
| +++ b/internal/edge/storedoor.go |
| @@ -30,6 +30,7 @@ import ( |
| "strings" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -433,7 +434,7 @@ func aclRead(sqid, fleet, key string) bool { |
| return false |
| } |
| switch key { |
| - case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(): |
| + case "control/serial", "bootstrap/carlos", release.LayoutIntentKey(), plans.MarkerKey(): |
| return true |
| } |
| if systemReadKey(key) || promotionRecordRead(sqid, key) { |
diff --git a/internal/edge/storedoor_test.go b/internal/edge/storedoor_test.go| index c67b38de6..9b7ae7e08 100644 |
| --- a/internal/edge/storedoor_test.go |
| +++ b/internal/edge/storedoor_test.go |
| @@ -23,6 +23,7 @@ import ( |
| "testing" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "time" |
| @@ -1009,3 +1010,16 @@ func TestStoreDoorClaimsAreOwnAccountAndRedacted(t *testing.T) { |
| t.Errorf("dirs under domains/ = %s, want []", w.Body.String()) |
| } |
| } |
| + |
| +// TestFleetBoxCanReadThePlansMarker: a fleet box reads the plans marker |
| +// (plans 2) to learn whether to read limits at all; the same two-identity |
| +// guard applies. |
| +func TestFleetBoxCanReadThePlansMarker(t *testing.T) { |
| + key := plans.MarkerKey() |
| + if !aclRead(testSqid, testFleet, key) { |
| + t.Errorf("aclRead(%q, %q, %q) = false, want true", testSqid, testFleet, key) |
| + } |
| + if aclRead("", testFleet, key) || aclRead(testSqid, "", key) { |
| + t.Error("the plans marker was readable without both identities") |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 559c0a27c..3229da5c2 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -495,6 +495,10 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // (design §6.8). The registry satisfies RouteLookup with its own |
| // Get, exactly as DueStore above. |
| svc.Routes = reg |
| + // Plans 2: an account's plan can shorten its entries' idle window. With |
| + // plans off (no marker, CARLOS_PLAN_LIMITS=off, or no record) the hook |
| + // answers 0 and every window is the box's own. |
| + svc.SetIdleFor(planIdleFor) |
| // Settings spec §4.2: before any process runs a build against an |
| // instance's database, the build state names it, under the lease. A |
| // store that failed to start refuses every spawn (never "run |
| @@ -2808,6 +2812,8 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| } |
| telemetryPass(ctx, store, reg) |
| mark("telemetry") |
| + plansPass(ctx, store, reg, os.Getenv) |
| + mark("plans") |
| runMovesPass(ctx, store) |
| mark("moves") |
| runRunsPass(ctx) |
| @@ -3393,6 +3399,7 @@ func instancesLoop(reg *registry.Registry, writes hostwrite.Config, bs *buildSta |
| bs.refreshAll(ctx) |
| } |
| telemetryPass(ctx, store, reg) |
| + plansPass(ctx, store, reg, os.Getenv) |
| runMovesPass(ctx, store) |
| runRunsPass(ctx) |
| deploymentRoutes := edgesfile.DeploymentRoutes(ctx, store, time.Now()) |
diff --git a/internal/host/plans_idle_test.go b/internal/host/plans_idle_test.go| new file mode 100644 |
| index 000000000..867033b43 |
| --- /dev/null |
| +++ b/internal/host/plans_idle_test.go |
| @@ -0,0 +1,29 @@ |
| +package host |
| + |
| +// Serial: it writes the package-level plans cache. |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlanIdleFor(t *testing.T) { |
| + t.Cleanup(func() { planLimits.Store(nil) }) |
| + rt := registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web"} |
| + planLimits.Store(nil) |
| + if d := planIdleFor(rt); d != 0 { |
| + t.Fatalf("plans off: %v, want 0", d) |
| + } |
| + planLimits.Store(&planLimitsSnapshot{byAccount: map[string]plans.Limits{"bab": {IdleSeconds: 90}}}) |
| + if d := planIdleFor(rt); d != 90*time.Second { |
| + t.Fatalf("bab: %v, want 90s", d) |
| + } |
| + other := rt |
| + other.Account = "bdk" |
| + if d := planIdleFor(other); d != 0 { |
| + t.Fatalf("account with no record: %v, want 0", d) |
| + } |
| +} |
diff --git a/internal/host/plans_pass.go b/internal/host/plans_pass.go| new file mode 100644 |
| index 000000000..e45961e89 |
| --- /dev/null |
| +++ b/internal/host/plans_pass.go |
| @@ -0,0 +1,137 @@ |
| +// The plans pass (plans-caps-packs spec §7, plans 2): learn whether plans are |
| +// on for this deployment and, only if they are, read the limits record of |
| +// every account this box serves into a last-known-good cache the activator |
| +// asks. Rides the same passes as telemetryPass. |
| +// |
| +// Self-hosters are never interrupted: with no marker (no catalog on the |
| +// console) the pass reads one object and applies nothing, a box with |
| +// CARLOS_PLAN_LIMITS=off reads nothing at all, and an account with no record |
| +// runs exactly as it always has. |
| +package host |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "os" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// planLimitsSnapshot is immutable once stored; nil in planLimits means plans |
| +// are off on this box. |
| +type planLimitsSnapshot struct { |
| + byAccount map[string]plans.Limits |
| +} |
| + |
| +var planLimits atomic.Pointer[planLimitsSnapshot] |
| + |
| +// plansPassMu serialises whole passes: the full pass and the five-minute pass |
| +// both run plansPass, and a stalled pass must not publish over a newer one |
| +// (Astra plans-2 finding 2). Readers never take it. |
| +var plansPassMu sync.Mutex |
| + |
| +// planLimitsFailures counts consecutive failing passes for log throttling. |
| +var planLimitsFailures atomic.Int64 |
| + |
| +// planLimitsSwitchOn reads CARLOS_PLAN_LIMITS: off, false, 0 or no turn plans |
| +// off on this box; anything else, including unset, leaves them to the marker. |
| +func planLimitsSwitchOn(getenv func(string) string) bool { |
| + switch strings.ToLower(strings.TrimSpace(getenv("CARLOS_PLAN_LIMITS"))) { |
| + case "off", "false", "0", "no": |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// planLimitsFor is sqid's limits, or false when plans are off or the account |
| +// has no record. |
| +func planLimitsFor(sqid string) (plans.Limits, bool) { |
| + snap := planLimits.Load() |
| + if snap == nil { |
| + return plans.Limits{}, false |
| + } |
| + l, ok := snap.byAccount[sqid] |
| + return l, ok |
| +} |
| + |
| +func plansPass(ctx context.Context, s release.Store, reg *registry.Registry, getenv func(string) string) { |
| + plansPassMu.Lock() |
| + defer plansPassMu.Unlock() |
| + if getenv == nil { |
| + getenv = os.Getenv |
| + } |
| + if !planLimitsSwitchOn(getenv) { |
| + planLimits.Store(nil) |
| + return |
| + } |
| + marker, _, err := plans.GetMarker(ctx, s) |
| + if err != nil { |
| + planLimitsFailed("marker: %v", err) |
| + return // keep the last good cache |
| + } |
| + if marker == nil || !marker.Enabled { |
| + planLimits.Store(nil) |
| + planLimitsFailures.Store(0) |
| + return |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + planLimitsFailed("routes: %v", err) |
| + return |
| + } |
| + prev := planLimits.Load() |
| + next := &planLimitsSnapshot{byAccount: map[string]plans.Limits{}} |
| + seen := map[string]bool{} |
| + failed := false |
| + for _, rt := range routes { |
| + if rt.Kind == "status" { |
| + continue |
| + } |
| + sqid := release.RouteAccount(rt) |
| + if sqid == "" || seen[sqid] { |
| + continue |
| + } |
| + seen[sqid] = true |
| + rec, _, err := plans.GetLimits(ctx, s, sqid) |
| + switch { |
| + case err != nil: |
| + failed = true |
| + planLimitsFailed("%s: %v", sqid, err) |
| + if prev != nil { |
| + if l, ok := prev.byAccount[sqid]; ok { |
| + next.byAccount[sqid] = l |
| + } |
| + } |
| + case rec != nil: |
| + next.byAccount[sqid] = rec.Limits |
| + } |
| + } |
| + planLimits.Store(next) |
| + if !failed { |
| + planLimitsFailures.Store(0) |
| + } |
| +} |
| + |
| +// planLimitsFailed logs the first failure of a run and every tenth after it. |
| +func planLimitsFailed(format string, args ...any) { |
| + if n := planLimitsFailures.Add(1); n == 1 || n%10 == 0 { |
| + log.Printf("agent: plans pass: "+format+" (keeping the last good limits)", args...) |
| + } |
| +} |
| + |
| +// planIdleFor is the activator's plans idle hook: the route's account's idle |
| +// window from its limits, or 0 (no opinion) when plans are off or the account |
| +// has no record. |
| +func planIdleFor(rt registry.Route) time.Duration { |
| + l, ok := planLimitsFor(release.RouteAccount(rt)) |
| + if !ok || l.IdleSeconds <= 0 { |
| + return 0 |
| + } |
| + return time.Duration(l.IdleSeconds) * time.Second |
| +} |
diff --git a/internal/host/plans_pass_test.go b/internal/host/plans_pass_test.go| new file mode 100644 |
| index 000000000..843bbbe06 |
| --- /dev/null |
| +++ b/internal/host/plans_pass_test.go |
| @@ -0,0 +1,235 @@ |
| +package host |
| + |
| +// These tests share the package-level plans cache, so they stay serial. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "slices" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// plansCountingStore records every Get and can fail chosen keys. |
| +type plansCountingStore struct { |
| + release.Store |
| + mu sync.Mutex |
| + gets []string |
| + fail map[string]bool |
| +} |
| + |
| +func (c *plansCountingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + c.mu.Lock() |
| + c.gets = append(c.gets, key) |
| + failing := c.fail[key] |
| + c.mu.Unlock() |
| + if failing { |
| + return nil, "", errors.New("bucket unavailable") |
| + } |
| + return c.Store.Get(ctx, key) |
| +} |
| + |
| +func (c *plansCountingStore) reset() { |
| + c.mu.Lock() |
| + c.gets, c.fail = nil, map[string]bool{} |
| + c.mu.Unlock() |
| +} |
| + |
| +func plansWorld(t *testing.T) (*plansCountingStore, *registry.Registry) { |
| + t.Helper() |
| + t.Cleanup(func() { planLimits.Store(nil) }) |
| + s := &plansCountingStore{Store: release.NewDirStore(t.TempDir()), fail: map[string]bool{}} |
| + reg := edgesTestRegistry(t) |
| + for _, rt := range []registry.Route{ |
| + {Host: "a.example", Kind: "instance", Account: "bab", App: "web", Status: "ready"}, |
| + {Host: "b.example", Kind: "instance", Account: "bdk", App: "shop", Status: "ready"}, |
| + {Host: "status.example", Kind: "status", Status: "ready"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return s, reg |
| +} |
| + |
| +func putMarker(t *testing.T, s release.Store, enabled bool) { |
| + t.Helper() |
| + ctx := context.Background() |
| + _, etag, err := plans.GetMarker(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := plans.PutMarker(ctx, s, &plans.Marker{Enabled: enabled, CatalogID: "v1@0123456789ab"}, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func putAccountLimits(t *testing.T, s release.Store, sqid string, idle int) { |
| + t.Helper() |
| + ctx := context.Background() |
| + _, etag, err := plans.GetLimits(ctx, s, sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + r := &plans.Record{Account: sqid, CatalogID: "v1@0123456789ab", Plan: "free", Revision: 1, Limits: plans.Limits{IdleSeconds: idle}} |
| + if _, err := plans.PutLimits(ctx, s, r, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func plansNoEnv(string) string { return "" } |
| + |
| +func TestPlanLimitsSwitch(t *testing.T) { |
| + for v, want := range map[string]bool{"": true, "on": true, "off": false, "OFF": false, "false": false, "0": false, "no": false, " off ": false} { |
| + if got := planLimitsSwitchOn(func(string) string { return v }); got != want { |
| + t.Errorf("CARLOS_PLAN_LIMITS=%q: on = %v, want %v", v, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestPlansPassOffReadsOnlyTheMarker(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putAccountLimits(t, s, "bab", 90) |
| + s.reset() |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if !slices.Equal(s.gets, []string{plans.MarkerKey()}) { |
| + t.Fatalf("reads %v, want only the marker", s.gets) |
| + } |
| + if _, ok := planLimitsFor("bab"); ok { |
| + t.Fatal("limits applied with no marker") |
| + } |
| +} |
| + |
| +func TestPlansPassBoxSwitchOffReadsNothing(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if _, ok := planLimitsFor("bab"); !ok { |
| + t.Fatal("setup: limits not loaded") |
| + } |
| + s.reset() |
| + plansPass(context.Background(), s, reg, func(string) string { return "off" }) |
| + if len(s.gets) != 0 { |
| + t.Fatalf("switched-off box read %v", s.gets) |
| + } |
| + if _, ok := planLimitsFor("bab"); ok { |
| + t.Fatal("switched-off box kept limits") |
| + } |
| +} |
| + |
| +func TestPlansPassLoadsServedAccounts(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + putAccountLimits(t, s, "bdk", 900) |
| + putAccountLimits(t, s, "zzz", 60) |
| + s.reset() |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 { |
| + t.Fatalf("bab: %+v, %v", l, ok) |
| + } |
| + if l, ok := planLimitsFor("bdk"); !ok || l.IdleSeconds != 900 { |
| + t.Fatalf("bdk: %+v, %v", l, ok) |
| + } |
| + if slices.Contains(s.gets, plans.LimitsKey("zzz")) { |
| + t.Fatal("read the limits of an account with no routes on this box") |
| + } |
| +} |
| + |
| +func TestPlansPassAbsentRecordMeansNoLimits(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if _, ok := planLimitsFor("bdk"); ok { |
| + t.Fatal("an account with no record got limits") |
| + } |
| +} |
| + |
| +func TestPlansPassKeepsLastGoodOnErrors(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + putAccountLimits(t, s, "bdk", 900) |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + |
| + s.fail[plans.MarkerKey()] = true |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 { |
| + t.Fatalf("a marker read error dropped bab: %+v, %v", l, ok) |
| + } |
| + |
| + s.reset() |
| + putAccountLimits(t, s, "bdk", 600) |
| + s.fail[plans.LimitsKey("bab")] = true |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if l, ok := planLimitsFor("bab"); !ok || l.IdleSeconds != 90 { |
| + t.Fatalf("a record read error dropped bab: %+v, %v", l, ok) |
| + } |
| + if l, _ := planLimitsFor("bdk"); l.IdleSeconds != 600 { |
| + t.Fatalf("bdk not refreshed: %+v", l) |
| + } |
| +} |
| + |
| +func TestPlansPassClearsWhenDisabled(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + putMarker(t, s, false) |
| + plansPass(context.Background(), s, reg, plansNoEnv) |
| + if _, ok := planLimitsFor("bab"); ok { |
| + t.Fatal("limits kept after plans were disabled") |
| + } |
| +} |
| + |
| +// blockingLimitsStore blocks reads of one limits key until released, and |
| +// says when a read has reached it. |
| +type blockingLimitsStore struct { |
| + release.Store |
| + key string |
| + reached chan struct{} |
| + release chan struct{} |
| + once sync.Once |
| +} |
| + |
| +func (b *blockingLimitsStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if key == b.key { |
| + b.once.Do(func() { close(b.reached); <-b.release }) |
| + } |
| + return b.Store.Get(ctx, key) |
| +} |
| + |
| +// Astra plans-2 finding 2: the full pass and the five-minute pass both run |
| +// plansPass. A pass that read an enabled marker and stalled must not install |
| +// its snapshot after a newer pass saw plans disabled. |
| +func TestPlansPassesDoNotRestoreDisabledLimits(t *testing.T) { |
| + s, reg := plansWorld(t) |
| + putMarker(t, s, true) |
| + putAccountLimits(t, s, "bab", 90) |
| + b := &blockingLimitsStore{Store: s.Store, key: plans.LimitsKey("bab"), reached: make(chan struct{}), release: make(chan struct{})} |
| + |
| + aDone := make(chan struct{}) |
| + go func() { plansPass(context.Background(), b, reg, plansNoEnv); close(aDone) }() |
| + <-b.reached // pass A read the enabled marker and is stalled on bab's record |
| + |
| + putMarker(t, s, false) |
| + bDone := make(chan struct{}) |
| + go func() { plansPass(context.Background(), s.Store, reg, plansNoEnv); close(bDone) }() |
| + select { // without ordering, pass B finishes now and clears the cache |
| + case <-bDone: |
| + case <-time.After(200 * time.Millisecond): |
| + } |
| + close(b.release) |
| + <-aDone |
| + <-bDone |
| + if _, ok := planLimitsFor("bab"); ok { |
| + t.Fatal("a stalled pass restored limits after plans were disabled") |
| + } |
| +} |
diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go| index 26458e0ee..e89154183 100644 |
| --- a/internal/hostenv/hostenv.go |
| +++ b/internal/hostenv/hostenv.go |
| @@ -84,6 +84,7 @@ var Optional = []string{ |
| "CARLOS_LAYOUT_DEFAULT", |
| "CARLOS_MOVES_PHASE1", |
| "CARLOS_MOVE_READY_DELTA_BYTES", |
| + "CARLOS_PLAN_LIMITS", |
| "CARLOS_REQUIRE_POINTER_DIGEST", |
| "CARLOS_S3_ENDPOINT", |
| "CARLOS_S3_QUIRKS", |
diff --git a/internal/hostops/limits_classifier_test.go b/internal/hostops/limits_classifier_test.go| index 2e714c110..189a3919e 100644 |
| --- a/internal/hostops/limits_classifier_test.go |
| +++ b/internal/hostops/limits_classifier_test.go |
| @@ -43,3 +43,23 @@ func TestClassifyAccountLimitsRecordIsExact(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestClassifyPlansMarkerIsExact: the console's deployment marker (plans 2) |
| +// is a reviewed non-app family, put only. |
| +func TestClassifyPlansMarkerIsExact(t *testing.T) { |
| + ctx := context.Background() |
| + key := "control/plans.json" |
| + got, err := ClassifyMutation(ctx, nil, nil, MutationTarget{Surface: "bucket", Method: "Put", Key: key, After: []byte(`{}`)}) |
| + if err != nil || got.NonApp == "" || len(got.Scopes) != 0 { |
| + t.Fatalf("put: %+v, %v; want a non-app family", got, err) |
| + } |
| + for _, c := range []MutationTarget{ |
| + {Surface: "bucket", Method: "Delete", Key: key}, |
| + {Surface: "bucket", Method: "Put", Key: "control/plans.json.bak", After: []byte(`{}`)}, |
| + {Surface: "bucket", Method: "Put", Key: "control/plans", After: []byte(`{}`)}, |
| + } { |
| + if got, err := ClassifyMutation(ctx, nil, nil, c); !errors.Is(err, ErrUnclassified) { |
| + t.Errorf("%s %s: classified %+v, err %v; want ErrUnclassified", c.Method, c.Key, got, err) |
| + } |
| + } |
| +} |
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index 430496ec5..298489074 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -48,6 +48,7 @@ var nonAppFamilies = []nonAppFamily{ |
| {exact: "control/serial", reason: "deployment convergence notification counter; contains no app state"}, |
| {exact: "control/relay-writer.json", reason: "deployment relay writer lease; app effects remain separately admitted"}, |
| {exact: "control/seeds-protocol.json", reason: "operator-published seeds floor; a protocol number and a release, no app state"}, |
| + {exact: "control/plans.json", reason: "plans deployment marker published by the console; says whether boxes read limits; no app state"}, |
| {prefix: "status/boxes/", deletes: true, reason: "per-box heartbeats; no app state"}, |
| {exact: "status/fleet.json", reason: "deployment fleet status summary"}, |
| {exact: "status/index.html", reason: "deployment status page"}, |
diff --git a/internal/plans/catalog.go b/internal/plans/catalog.go| index cd9c2734b..a9a9592cb 100644 |
| --- a/internal/plans/catalog.go |
| +++ b/internal/plans/catalog.go |
| @@ -14,6 +14,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "math" |
| "os" |
| "slices" |
| ) |
| @@ -211,11 +212,15 @@ func (c *Catalog) Effective(plan string, packs map[string]int, sponsored *Limits |
| if qty < 1 || qty > pk.MaxQuantity { |
| return Limits{}, fmt.Errorf("%w: %q has %d, allowed 1 to %d", ErrPackQuantity, name, qty, pk.MaxQuantity) |
| } |
| - l.RunningThings += qty * pk.Adds.RunningThings |
| - l.DiskBytes += int64(qty) * pk.Adds.DiskBytes |
| - l.StorageBytes += int64(qty) * pk.Adds.StorageBytes |
| - l.EmailPerDay += qty * pk.Adds.EmailPerDay |
| - l.BandwidthBytesMonth += int64(qty) * pk.Adds.BandwidthBytesMonth |
| + fits := true |
| + l.RunningThings = addPack(l.RunningThings, qty, pk.Adds.RunningThings, &fits) |
| + l.DiskBytes = addPack(l.DiskBytes, qty, pk.Adds.DiskBytes, &fits) |
| + l.StorageBytes = addPack(l.StorageBytes, qty, pk.Adds.StorageBytes, &fits) |
| + l.EmailPerDay = addPack(l.EmailPerDay, qty, pk.Adds.EmailPerDay, &fits) |
| + l.BandwidthBytesMonth = addPack(l.BandwidthBytesMonth, qty, pk.Adds.BandwidthBytesMonth, &fits) |
| + if !fits { |
| + return Limits{}, fmt.Errorf("%w: %q overflows", ErrPackQuantity, name) |
| + } |
| } |
| ce := c.Ceilings |
| l.RunningThings = clamp(l.RunningThings, ce.RunningThings) |
| @@ -261,3 +266,14 @@ func (l Limits) generous(o Limits) Limits { |
| } |
| return l |
| } |
| + |
| +// addPack is v + qty*add, refusing (ok = false) anything that would overflow |
| +// int64. Values are non-negative (validate), so only the upper bound matters; |
| +// int is 64-bit on every platform this builds for. |
| +func addPack[T int | int64](v T, qty int, add T, fits *bool) T { |
| + if add != 0 && int64(qty) > (math.MaxInt64-int64(v))/int64(add) { |
| + *fits = false |
| + return v |
| + } |
| + return v + T(qty)*add |
| +} |
diff --git a/internal/plans/catalog_test.go b/internal/plans/catalog_test.go| index ce6c86c0f..3728f2649 100644 |
| --- a/internal/plans/catalog_test.go |
| +++ b/internal/plans/catalog_test.go |
| @@ -202,3 +202,17 @@ func TestEffectiveSponsoredZeroIsUnset(t *testing.T) { |
| t.Fatalf("min_schedule_seconds %d, want 60", got.MinScheduleSeconds) |
| } |
| } |
| + |
| +// Astra plans-1 finding 5: an accepted catalog with a huge pack increment |
| +// must never wrap to a negative limit that the ceiling cannot correct. |
| +func TestEffectiveRefusesOverflow(t *testing.T) { |
| + c := exampleCatalog(t) |
| + pk := c.Packs["storage"] |
| + pk.Adds.StorageBytes = 1 << 62 |
| + c.Packs["storage"] = pk |
| + c.Ceilings.StorageBytes = 100 |
| + got, err := c.Effective("personal", map[string]int{"storage": 2}, nil) |
| + if !errors.Is(err, ErrPackQuantity) { |
| + t.Fatalf("err %v (limits %+v), want ErrPackQuantity", err, got) |
| + } |
| +} |
diff --git a/internal/plans/marker.go b/internal/plans/marker.go| new file mode 100644 |
| index 000000000..d39ee9e6c |
| --- /dev/null |
| +++ b/internal/plans/marker.go |
| @@ -0,0 +1,60 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// MarkerSchema is the deployment marker's wire version. |
| +const MarkerSchema = 1 |
| + |
| +// Marker says whether plans are on for the deployment (plans 2). The console |
| +// writes it once at start; boxes read it on every pass and read no limits |
| +// record while it is absent or disabled, so a deployment without a catalog |
| +// costs a box one small read per pass and changes nothing. |
| +type Marker struct { |
| + Schema int `json:"schema"` |
| + Enabled bool `json:"enabled"` |
| + CatalogID string `json:"catalog_id,omitempty"` |
| + UpdatedAt time.Time `json:"updated_at"` |
| +} |
| + |
| +// MarkerKey is where the deployment marker lives. |
| +func MarkerKey() string { return "control/plans.json" } |
| + |
| +// GetMarker reads the marker and its etag. Absent is nil, "", nil. An unknown |
| +// schema is an error, so a reader keeps whatever it last knew. |
| +func GetMarker(ctx context.Context, s release.Store) (*Marker, string, error) { |
| + raw, etag, err := s.Get(ctx, MarkerKey()) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, "", nil |
| + } |
| + return nil, "", err |
| + } |
| + var m Marker |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, "", fmt.Errorf("plans: %s: %w", MarkerKey(), err) |
| + } |
| + if m.Schema != MarkerSchema { |
| + return nil, "", fmt.Errorf("plans: %s: schema %d, want %d", MarkerKey(), m.Schema, MarkerSchema) |
| + } |
| + return &m, etag, nil |
| +} |
| + |
| +// PutMarker CAS-writes the marker: ifMatch "" is create-only, otherwise |
| +// replace-only. Schema is stamped here. |
| +func PutMarker(ctx context.Context, s release.Store, m *Marker, ifMatch string) (string, error) { |
| + m.Schema = MarkerSchema |
| + body, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return "", err |
| + } |
| + return s.Put(ctx, MarkerKey(), append(body, '\n'), ifMatch) |
| +} |
diff --git a/internal/plans/marker_test.go b/internal/plans/marker_test.go| new file mode 100644 |
| index 000000000..b01b1ee20 |
| --- /dev/null |
| +++ b/internal/plans/marker_test.go |
| @@ -0,0 +1,58 @@ |
| +package plans |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestMarkerKey(t *testing.T) { |
| + if got := MarkerKey(); got != "control/plans.json" { |
| + t.Fatalf("MarkerKey = %q", got) |
| + } |
| +} |
| + |
| +func TestMarkerRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if m, etag, err := GetMarker(ctx, s); m != nil || etag != "" || err != nil { |
| + t.Fatalf("absent: %+v, %q, %v", m, etag, err) |
| + } |
| + want := &Marker{Enabled: true, CatalogID: "v1@0123456789ab", UpdatedAt: time.Unix(1_800_000_000, 0).UTC()} |
| + if _, err := PutMarker(ctx, s, want, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, etag, err := GetMarker(ctx, s) |
| + if err != nil || etag == "" { |
| + t.Fatalf("get: %q, %v", etag, err) |
| + } |
| + want.Schema = MarkerSchema |
| + if *got != *want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestMarkerRefusesUnknownSchema(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, MarkerKey(), []byte(`{"schema": 2, "enabled": true}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if m, _, err := GetMarker(ctx, s); err == nil { |
| + t.Fatalf("accepted schema 2: %+v", m) |
| + } |
| +} |
| + |
| +func TestPutMarkerIsCAS(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := PutMarker(ctx, s, &Marker{Enabled: true}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := PutMarker(ctx, s, &Marker{Enabled: false}, ""); !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatalf("second create-only put: %v", err) |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 40cb11596..770724ec2 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -1297,6 +1297,19 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| t.Fatal(err) |
| } |
| seal = "b25dfda5a68d2183af759b42398f79bb471749d46e68d267545647d1a2e6046b" |
| + // Census overlay plans-2-box-limits (boxes read account limits behind the |
| + // console's plans marker; the activator's idle hook), loaded last, after |
| + // email-signin. PROPOSED-plans-2-box-limits, asked of the census owner on |
| + // #280. The new agent units read routes and two bucket objects and only |
| + // narrow idle windows. Frozen declarations are added, so the seal moves; |
| + // email-signin's is checked first. |
| + if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 { |
| + t.Fatal(errs) |
| + } |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + seal = "d044224434b0b375599a076e573c7c4164e3777ae6295abf95d9d9b9ed69e482" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 094df27bd..3694cedd5 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -366,7 +366,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) { |
| if len(got) < 100 { |
| t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root) |
| } |
| - inventory := plan14ClaimRosterBindingWriteCensusInventory() |
| + inventory := plans2WriteCensusInventory() |
| problems := append(censusDiff(inventory, got), censusPolicy(inventory)...) |
| if len(problems) > 0 { |
| t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n ")) |
diff --git a/internal/provision/plans2_census_test.go b/internal/provision/plans2_census_test.go| new file mode 100644 |
| index 000000000..6828edf4a |
| --- /dev/null |
| +++ b/internal/provision/plans2_census_test.go |
| @@ -0,0 +1,12 @@ |
| +package provision |
| + |
| +// Record-write census entry for plans-2-box-limits, layered on |
| +// movesOfflinePrecopyWriteCensusInventory. Reviewed by the branch author; the |
| +// census owner rules on #280 (PROPOSED-plans-2-box-limits). |
| +// - plans.PutMarker writes control/plans.json, the deployment's plans |
| +// marker, never an instance or sidecar record: nonrecord. |
| +func plans2WriteCensusInventory() []censusEntry { |
| + return append(append([]censusEntry(nil), plan14ClaimRosterBindingWriteCensusInventory()...), |
| + censusEntry{"internal/plans/marker.go", "PutMarker", "nonrecord", "sel:Put", "28e544ee616f632d5ea97c3c29247d3afd37de80192413e00c69f6bf449a1eaf"}, |
| + ) |
| +} |
diff --git a/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv b/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv| new file mode 100644 |
| index 000000000..c2b97fdd4 |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-plans-2-box-limits.tsv |
| @@ -0,0 +1,23 @@ |
| +# Census overlay plans-2-box-limits (boxes read account limits, marker-gated; the activator's plans idle hook; the console's plans marker), loaded LAST, after email-signin. PROPOSED-plans-2-box-limits, asked of the census owner on #280. |
| +# New units plansPass and planIdleFor run in the box agent, read their own routes' accounts and two bucket objects, and only narrow idle windows (accepted legacy at telemetrySwitches' J1 root-route disposition); plansPass is serialised by its own mutex. Service.sweepOnce is re-frozen with a basis that names the idle hook; serve and Store.SetAccountPlan are re-frozen; runAgent, fastConvergeLoop and type Service are re-pinned at their authority class. Frozen declarations are added, so the seal moves. |
| +@legacy 1b1ec6ba471451bfb94da5a1e1bbebad0eef99735f95f4376edbb53e28f11743 internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; registry remains legacy; PROPOSED-plans-2-box-limits: the sweep also narrows an entry's idle window to its account's plan idle through the SetIdleFor hook (never lengthens it; always-on workers and held probe entries are untouched; the hibernate, inflight and held-for-tick guards are unchanged). registry:Hibernate@12328,registry:Hibernate@21499,registry:Hibernate@5124,registry:Hibernate@6794,registry:Hibernate@9319,registry:Host@21568,registry:Host@21626,registry:Host@21998,registry:Host@22091,registry:Host@22826,registry:Host@22908,registry:Host@23973,registry:Host@24152,registry:Host@24201,registry:NextDue@22781,registry:Status@21515,whole:Route@12330,whole:Route@14263,whole:Route@15973,whole:Route@16163,whole:Route@16456,whole:Route@16628,whole:Route@16775,whole:Route@20986,whole:Route@22495,whole:Route@22835,whole:Route@22842,whole:Route@23788,whole:Route@24185,whole:Route@5126,whole:Route@6796,whole:Route@7230,whole:Route@9321 fa0ced864fd598142b3f93ecd05a8f28ea85da83a0e8f9f056f37f1a6def42fb |
| +@legacy 1c311d4b2c462944cbed211a693f04e7752526ca8a871330a5269ee0d4e1a6e2 internal/host/plans_pass.go planIdleFor legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. whole:Route@97 eea0d90d956f443a16446d17ba76312f07a26a4f5421b33cbd9c0f385801e4cc |
| +@legacy 25dde7240be6c5fa3f45ef1764cd03ea4f0f261359f0610e7788eaf78d86378b internal/host/plans_pass.go plansPass legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. registry:Kind@778,whole:Route@764,whole:Route@847 47aa10719db662b78dec476944ac91745bf61ab60cc24283531e61ae8bee87c3 |
| +@legacy 55cbbc6af84bef1c0f30e8ce2b923641a4596dda7a2e251d87179e9e23268464 internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 36db76c1951df91bff3f8f5960434235b87b1e526c4ff91ade2c8cb8f4b01e06 |
| +@legacy 80bb2ce8fb1489f707b71d5523b708b2026e8580ee56c1614e379200442df514 cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added data-literal@4077,data-literal@5178,data-literal@5415 edeae271d6037cab9ccbd63ea281787de6c23b2cc8295fca95bdb19658460098 |
| +cmd/console/main.go serve legacy accepted-legacy accepted-legacy;rev3=PROPOSED-scan-demand-revival;basis=Console service wires the optional callback signer and starts the claim-guarded queue worker. The same three data path literals move; it does not add a privileged reader.;rev3=PROPOSED-email-signin;identity=the console (cmd/console serve under console.service, user carlos-console);basis=passes CARLOS_CONSOLE_NAME to NewServer as Config.ConsoleName, the name the console's emails use; the same three /data literals at the same offsets; no read added data-literal@4077,data-literal@5178,data-literal@5415 edeae271d6037cab9ccbd63ea281787de6c23b2cc8295fca95bdb19658460098 |
| +internal/activator/activator.go Service.sweepOnce legacy accepted-legacy accepted-legacy;rev3=PROPOSED-seed;basis=census-ruling-FS-R6; registry remains legacy; PROPOSED-plans-2-box-limits: the sweep also narrows an entry's idle window to its account's plan idle through the SetIdleFor hook (never lengthens it; always-on workers and held probe entries are untouched; the hibernate, inflight and held-for-tick guards are unchanged). registry:Hibernate@12328,registry:Hibernate@21499,registry:Hibernate@5124,registry:Hibernate@6794,registry:Hibernate@9319,registry:Host@21568,registry:Host@21626,registry:Host@21998,registry:Host@22091,registry:Host@22826,registry:Host@22908,registry:Host@23973,registry:Host@24152,registry:Host@24201,registry:NextDue@22781,registry:Status@21515,whole:Route@12330,whole:Route@14263,whole:Route@15973,whole:Route@16163,whole:Route@16456,whole:Route@16628,whole:Route@16775,whole:Route@20986,whole:Route@22495,whole:Route@22835,whole:Route@22842,whole:Route@23788,whole:Route@24185,whole:Route@5126,whole:Route@6796,whole:Route@7230,whole:Route@9321 fa0ced864fd598142b3f93ecd05a8f28ea85da83a0e8f9f056f37f1a6def42fb |
| +internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative; PROPOSED-plans-2-box-limits adds the idleFor hook field, which only narrows idle windows 861dda6f11a641d800eadd5fbc368275ef7fe80e35e644dd01d4e0818c38f90e |
| +internal/console/accountplans.go Store.SetAccountPlan legacy accepted-legacy accepted-legacy;rev3=PROPOSED-design-plans-and-caps;basis=root-input; console SQL on its own account_plans table, as its store siblings (J4). file:QueryRow@442 36db76c1951df91bff3f8f5960434235b87b1e526c4ff91ade2c8cb8f4b01e06 |
| +internal/host/agent.go fastConvergeLoop authority B3 rev3=census-ruling-moves-offline;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body (census-ruling-carlos-run) with the offline keep view read before the first reconcile pass (refreshOfflineView); read sites unchanged, offsets moved; main's B3 authority retained whole:Route@8952,whole:Route@9515 ed8b3b75914b7cfaf62e694556f6cbcf0b85bbfb8a662b6da38cf1df6f94eafb |
| +internal/host/agent.go runAgent authority B,C,D,F rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with wireSeedPlacement called in the activator start's Configure step, before armMovesPass, and the startup sweep's result stored for the import rollback in Recover; read sites unchanged, offsets moved; main's B,C,D,F authority retained PROPOSED E46: captured claim selection and root journal mutation; root RPC outside service host exclusion; SQL/route data never grants root authority. Executing identity: service controller; root for inventory/retirement callbacks.;rev3=PROPOSED-325;identity=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos);basis=passes the agent's log sink to armRunsPass; reads unchanged file:Exec@13802,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@23351 49319d24c62ebd37444d3e7d9a2e78f2dab25ff803eeb10bfa88d4a8ca7775fd |
| +internal/host/plans_pass.go planIdleFor legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. whole:Route@97 eea0d90d956f443a16446d17ba76312f07a26a4f5421b33cbd9c0f385801e4cc |
| +internal/host/plans_pass.go plansPass legacy accepted-legacy accepted-legacy;rev3=PROPOSED-plans-2-box-limits;basis=root-route; the agent's plans pass, serialised by its own mutex, reads its own routes' accounts and two bucket objects (the plans marker, each account's limits record) into a cache; planIdleFor only narrows the activator's idle window. No root decision. registry:Kind@778,whole:Route@764,whole:Route@847 47aa10719db662b78dec476944ac91745bf61ab60cc24283531e61ae8bee87c3 |
| +@coverage cmd/console ae123181abe153851c1f1546e9b609f8502cbaa36d5ff64667fa158bc1480dad |
| +@coverage internal/activator 18c8b9ef915ac60ccb296a1b4f8fce5ce1c02ee2643b361a0db0a3020d68395b |
| +@coverage internal/console 40a44fc3a28c5769a84ca81d2916dd97685604ae04d780661a1dcd5aa1503401 |
| +@coverage internal/edge 62bb20932803aa2acdb45b2aab0db6540adc5a5a2f4dc954b1338c30fb73ce2b |
| +@coverage internal/host d5c86ad049089ddcdcb97685e185164d8012a1b8788734962eb7021a2c87c8dc |
| +@coverage internal/hostenv 3981869abecec66e019134cc6485b14282920929032271bc2012c6d582acf1b5 |
| +@coverage internal/hostops cff0ae84199421ad3e46eb1b853b47a6f8ec0005c250b3d17d21e154de0494e0 |
| +@coverage internal/plans 7de3345c04504076ff5ac23f823f17d5901db261ba52db269c08d4b8d51b0f00 |