console: plan creation checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+413
−0
internal/console/accountplans.go+26 −0internal/console/plancaps.go+192 −0internal/console/plancaps_copy.go+6 −0internal/console/plancaps_test.go+189 −0
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go| index 2a2cac308..4917eb1b7 100644 |
| --- a/internal/console/accountplans.go |
| +++ b/internal/console/accountplans.go |
| @@ -156,3 +156,29 @@ func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) e |
| WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision) |
| return err |
| } |
| + |
| +// LiveAppCount counts the account's running apps for the creation caps: |
| +// live rows and rows being restored (temporary apps included). |
| +func (s *Store) LiveAppCount(accountID int64) (int, error) { |
| + var n int |
| + err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND (deleted_at = 0 OR restoring = 1)`, accountID).Scan(&n) |
| + return n, err |
| +} |
| + |
| +// RestoringAppsIn names the account's apps that are mid-restore. |
| +func (s *Store) RestoringAppsIn(accountID int64) ([]string, error) { |
| + rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at != 0 AND restoring = 1 ORDER BY app`, accountID) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []string |
| + for rows.Next() { |
| + var a string |
| + if err := rows.Scan(&a); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| new file mode 100644 |
| index 000000000..64617218c |
| --- /dev/null |
| +++ b/internal/console/plancaps.go |
| @@ -0,0 +1,192 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every |
| +// check passes when plans are off (EffectiveLimits nil), so a deployment |
| +// without a catalog sees no change. A refusal is a *planCapError carrying |
| +// approved copy; JSON doors answer it 409 {"code":"cap"} and browser doors |
| +// flash it. Creation refusals have no grace: the cap refuses what is new and |
| +// never touches what already runs. |
| + |
| +// planCapError carries the CLI wording (Error) and, where a browser door can |
| +// meet it, the browser wording (Browser). |
| +type planCapError struct{ msg, browser string } |
| + |
| +func (e *planCapError) Error() string { return e.msg } |
| + |
| +// Browser is the sentence-case wording for a browser door. |
| +func (e *planCapError) Browser() string { |
| + if e.browser != "" { |
| + return e.browser |
| + } |
| + return e.msg |
| +} |
| + |
| +// capMessage formats approved copy by its review id (plancaps_copy.go). |
| +func capMessage(id string, args ...any) string { |
| + format, ok := capCopy[id] |
| + if !ok { |
| + format = id |
| + } |
| + return fmt.Sprintf(format, args...) |
| +} |
| + |
| +// runningThings is the account's live apps (temporary and restoring |
| +// included) plus the instance records of those apps. Trashed apps and their |
| +// instances do not count. One key-only list of the account's prefix. |
| +func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) { |
| + apps, err := s.store.LiveAppCount(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + live, err := s.store.AppsIn(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + restoring, err := s.store.RestoringAppsIn(acct.ID) |
| + if err != nil { |
| + return 0, err |
| + } |
| + liveSet := map[string]bool{} |
| + for _, a := range append(live, restoring...) { |
| + liveSet[a] = true |
| + } |
| + if s.cfg.Releases == nil { |
| + return apps, nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, "apps/"+acct.Sqid+"/") |
| + if err != nil { |
| + return 0, err |
| + } |
| + known := map[string]bool{acct.Sqid: true} |
| + instances := 0 |
| + for _, k := range keys { |
| + if _, app, _, ok := provision.ParseRecordKey(k, known); ok && liveSet[app] { |
| + instances++ |
| + } |
| + } |
| + return apps + instances, nil |
| +} |
| + |
| +// checkRunningThings refuses when adding more running things would pass |
| +// the account's cap. |
| +func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + n, err := s.runningThings(ctx, acct) |
| + if err != nil { |
| + return err |
| + } |
| + if n+adding > l.RunningThings { |
| + return &planCapError{capMessage("cli.cap.running_things", l.RunningThings), capMessage("browser.cap.running_things", l.RunningThings)} |
| + } |
| + return nil |
| +} |
| + |
| +// checkRestore is checkRunningThings for restoring app, with the restore |
| +// wording. |
| +func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error { |
| + err := s.checkRunningThings(ctx, acct, 1) |
| + if !isPlanCap(err) { |
| + return err |
| + } |
| + l, _ := s.EffectiveLimits(acct) |
| + return &planCapError{capMessage("cli.cap.restore", app, l.RunningThings), capMessage("browser.cap.restore", app, l.RunningThings)} |
| +} |
| + |
| +// checkSidecars refuses a new worker when app already has as many as the |
| +// plan allows. |
| +func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + if s.cfg.Releases == nil { |
| + return nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, provision.SidecarsPrefix(acct.Sqid, app)) |
| + if err != nil { |
| + return err |
| + } |
| + n := 0 |
| + for _, k := range keys { |
| + if strings.HasSuffix(k, ".json") { |
| + n++ |
| + } |
| + } |
| + if n >= l.SidecarsPerApp { |
| + return &planCapError{msg: capMessage("cli.cap.sidecars", app, l.SidecarsPerApp)} |
| + } |
| + return nil |
| +} |
| + |
| +// checkNewFreeAccount refuses a person a further account on a free plan once |
| +// they own the catalog's allowance of them (spec §8.1; creation only). |
| +func (s *Server) checkNewFreeAccount(addr string) error { |
| + cat := s.cfg.PlanCatalog |
| + if cat == nil || cat.FreeAccountsPerPerson <= 0 { |
| + return nil |
| + } |
| + if p, ok := cat.Plans[cat.DefaultPlan]; ok && p.Paid { |
| + return nil // new accounts start on a paid plan here: nothing to cap |
| + } |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + return err |
| + } |
| + free := 0 |
| + for _, a := range accts { |
| + if a.Role != RoleOwner { |
| + continue |
| + } |
| + plan := cat.DefaultPlan |
| + row, err := s.store.AccountPlan(a.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if row != nil { |
| + plan = row.Plan |
| + } |
| + if p, ok := cat.Plans[plan]; !ok || !p.Paid { |
| + free++ |
| + } |
| + } |
| + if free >= cat.FreeAccountsPerPerson { |
| + return &planCapError{capMessage("cli.cap.free_account"), capMessage("browser.cap.free_account")} |
| + } |
| + return nil |
| +} |
| + |
| +// checkFleets refuses a fleet on a plan without fleets. |
| +func (s *Server) checkFleets(acct *Account) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.Fleets { |
| + return err |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.fleets")} |
| +} |
| + |
| +// checkCustomDomains refuses a custom domain on a plan without them. |
| +// Platform hostnames are not custom domains; callers check those first. |
| +func (s *Server) checkCustomDomains(acct *Account) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil || l.CustomDomains { |
| + return err |
| + } |
| + return &planCapError{capMessage("cli.cap.custom_domain"), capMessage("browser.cap.custom_domain")} |
| +} |
| + |
| +func isPlanCap(err error) bool { |
| + var c *planCapError |
| + return errors.As(err, &c) |
| +} |
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go| new file mode 100644 |
| index 000000000..d5550283f |
| --- /dev/null |
| +++ b/internal/console/plancaps_copy.go |
| @@ -0,0 +1,6 @@ |
| +package console |
| + |
| +// capCopy holds the creation-cap refusals by copy-review id. It is |
| +// generated from the approved review (plans 3a); until then it is empty and |
| +// capMessage falls back to the id. |
| +var capCopy = map[string]string{} |
diff --git a/internal/console/plancaps_test.go b/internal/console/plancaps_test.go| new file mode 100644 |
| index 000000000..ca7b920e5 |
| --- /dev/null |
| +++ b/internal/console/plancaps_test.go |
| @@ -0,0 +1,189 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func capsApp(t *testing.T, fx *consoleFixture, acct *Account, app string) { |
| + t.Helper() |
| + if _, err := fx.st.CreateAppIn(acct.ID, app, "owner@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func capsInstance(t *testing.T, fx *consoleFixture, acct *Account, app, host string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), provision.RecordKey(acct.Sqid, app, host), []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func capsSidecar(t *testing.T, fx *consoleFixture, acct *Account, app, name string) { |
| + t.Helper() |
| + if _, err := fx.rel.Put(context.Background(), provision.SidecarRecordKey(acct.Sqid, app, name), []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func isCap(err error) bool { |
| + var c *planCapError |
| + return errors.As(err, &c) |
| +} |
| + |
| +func TestPlanChecksPassWithoutACatalog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + for i := range 5 { |
| + capsApp(t, fx, acct, "app"+string(rune('a'+i))) |
| + } |
| + capsSidecar(t, fx, acct, "appa", "w1") |
| + capsSidecar(t, fx, acct, "appa", "w2") |
| + ctx := context.Background() |
| + for name, err := range map[string]error{ |
| + "running things": fx.srv.checkRunningThings(ctx, acct, 1), |
| + "sidecars": fx.srv.checkSidecars(ctx, acct, "appa"), |
| + "free account": fx.srv.checkNewFreeAccount("owner" + acct.Sqid + "@example.com"), |
| + "fleets": fx.srv.checkFleets(acct), |
| + "custom domains": fx.srv.checkCustomDomains(acct), |
| + } { |
| + if err != nil { |
| + t.Errorf("%s with no catalog: %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +func TestLiveAppCountCountsRestoringNotTrashed(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + for _, app := range []string{"live", "gone", "coming-back"} { |
| + capsApp(t, fx, acct, app) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE apps SET deleted_at = ? WHERE account_id = ? AND app IN ('gone', 'coming-back')`, planNow.Unix(), acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.db.Exec(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = 'coming-back'`, acct.ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := fx.st.LiveAppCount(acct.ID); err != nil || n != 2 { |
| + t.Fatalf("LiveAppCount = %d, %v; want 2 (live + restoring)", n, err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCountsAppsAndInstances(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "shop") |
| + capsApp(t, fx, acct, "blog") |
| + capsApp(t, fx, acct, "old") |
| + capsInstance(t, fx, acct, "shop", "a.shop.example.com") |
| + capsInstance(t, fx, acct, "shop", "b.shop.example.com") |
| + capsInstance(t, fx, acct, "blog", "c.blog.example.com") |
| + capsInstance(t, fx, acct, "old", "d.old.example.com") |
| + if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n, err := fx.srv.runningThings(context.Background(), acct); err != nil || n != 5 { |
| + t.Fatalf("runningThings = %d, %v; want 5 (2 apps + 3 instances; the trashed app and its instance do not count)", n, err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCapAtAndBelow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "one") |
| + capsApp(t, fx, acct, "two") |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil { |
| + t.Fatalf("at 2 of 3: %v", err) |
| + } |
| + capsApp(t, fx, acct, "three") |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); !isCap(err) { |
| + t.Fatalf("at 3 of 3: %v, want a plan cap", err) |
| + } |
| +} |
| + |
| +func TestRunningThingsCapUsesEffectiveLimits(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: 300}, "existing account", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := range 4 { |
| + capsApp(t, fx, acct, "app"+string(rune('a'+i))) |
| + } |
| + if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil { |
| + t.Fatalf("sponsored 300, running 4: %v", err) |
| + } |
| +} |
| + |
| +func TestSidecarCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + capsApp(t, fx, acct, "shop") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil { |
| + t.Fatalf("free, no sidecars: %v", err) |
| + } |
| + capsSidecar(t, fx, acct, "shop", "w1") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) { |
| + t.Fatalf("free, one sidecar: %v, want a plan cap", err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, n := range []string{"w2", "w3", "w4"} { |
| + capsSidecar(t, fx, acct, "shop", n) |
| + } |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil { |
| + t.Fatalf("personal, four sidecars: %v", err) |
| + } |
| + capsSidecar(t, fx, acct, "shop", "w5") |
| + if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) { |
| + t.Fatalf("personal, five sidecars: %v, want a plan cap", err) |
| + } |
| +} |
| + |
| +func TestNewFreeAccountCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + owner := "owner" + acct.Sqid + "@example.com" |
| + if err := fx.srv.checkNewFreeAccount(owner); !isCap(err) { |
| + t.Fatalf("owner of one free account: %v, want a plan cap", err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.srv.checkNewFreeAccount(owner); err != nil { |
| + t.Fatalf("owner of one paid account: %v", err) |
| + } |
| + member := "member@example.com" |
| + other := planAccount(t, fx, 502) |
| + if err := fx.st.AddMember(other.ID, member, RoleMember, planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.srv.checkNewFreeAccount(member); err != nil { |
| + t.Fatalf("member, not owner, of a free account: %v", err) |
| + } |
| +} |
| + |
| +func TestFleetsAndCustomDomainsFollowThePlan(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + if !isCap(fx.srv.checkFleets(acct)) || !isCap(fx.srv.checkCustomDomains(acct)) { |
| + t.Fatal("free plan allowed fleets or custom domains") |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + if fx.srv.checkFleets(acct) != nil || fx.srv.checkCustomDomains(acct) != nil { |
| + t.Fatal("personal plan refused fleets or custom domains") |
| + } |
| +} |