console: publish the plans marker; plan writes need a live account
PublishPlansMarker writes control/plans.json enabled under the catalog's id once per start, turns a left-over marker off when the catalog is gone, and writes nothing on a deployment that never had one. hostops classifies the key (put only); fleet boxes may read it. SetAccountPlan refuses a deleted account (Astra plans-1 finding 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+212
−3
cmd/console/main.go+3 −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+38 −0internal/console/plansmarker_test.go+69 −0internal/console/testdata/claim-routes.txt+1 −0internal/edge/storedoor.go+2 −1internal/edge/storedoor_test.go+14 −0internal/hostops/limits_classifier_test.go+20 −0internal/hostops/mutation_classifier.go+1 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index bdee22837..acdb15919 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -526,6 +526,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/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 d09d1d291..fddb0b7c8 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1853,6 +1853,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 c75eb79e2..2fead5e23 100644 |
| --- a/internal/console/claim_invariant_probes_worker_test.go |
| +++ b/internal/console/claim_invariant_probes_worker_test.go |
| @@ -190,6 +190,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..494d21eab |
| --- /dev/null |
| +++ b/internal/console/plansmarker.go |
| @@ -0,0 +1,38 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// 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 |
| + } |
| + cur, etag, err := plans.GetMarker(ctx, s.cfg.Releases) |
| + if err != nil { |
| + log.Printf("console: plans marker: read: %v", err) |
| + return |
| + } |
| + 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 |
| + } |
| + want.Enabled, want.CatalogID = true, cat.ID() |
| + } else if cur == nil || !cur.Enabled { |
| + return |
| + } |
| + if _, err := plans.PutMarker(ctx, s.cfg.Releases, &want, etag); err != nil { |
| + log.Printf("console: plans marker: write: %v", err) |
| + } |
| +} |
diff --git a/internal/console/plansmarker_test.go b/internal/console/plansmarker_test.go| new file mode 100644 |
| index 000000000..7cea85954 |
| --- /dev/null |
| +++ b/internal/console/plansmarker_test.go |
| @@ -0,0 +1,69 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "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) |
| + } |
| +} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 932579962..874022711 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -283,6 +283,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/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"}, |