console: the running-things cap on app creation and restore
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+215
−0
internal/console/apicli.go+14 −0internal/console/apitemporary.go+11 −0internal/console/claim_restore.go+7 −0internal/console/ownership.go+4 −0internal/console/plancaps.go+11 −0internal/console/plancaps_doors_test.go+150 −0internal/console/restore.go+7 −0internal/console/server.go+11 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index b5c75ea34..2fb2aa997 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -451,6 +451,16 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| // account role, and handing a CI credential the power to adopt release |
| // history is exactly the widening svcrole exists to prevent. |
| ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner |
| + defer s.planLock(acct)() |
| + if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api create %s/%s: plan cap: %v", acct.Sqid, name, err) |
| + jsonError(w, http.StatusInternalServerError, "could not create the app — try again") |
| + return |
| + } |
| outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq) |
| switch outcome { |
| case claimInvalid: |
| @@ -1584,6 +1594,7 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { |
| // that comes back and then vanishes again on the next pass. |
| // restoreApp (restore.go) is the one restore every door runs: it locks, |
| // settles, checks the temporary cap and orders the row and marker writes. |
| + var planCap *planCapError |
| var capErr *temporaryCapError |
| var rf *restoreFailure |
| switch err := s.restoreApp(ctx, acct, app, time.Now()); { |
| @@ -1591,6 +1602,9 @@ func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(err, errNotInTrash): |
| http.NotFound(w, r) |
| return |
| + case errors.As(err, &planCap): |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": planCap.Error(), "code": "cap"}) |
| + return |
| case errors.As(err, &capErr): |
| writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) |
| return |
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go| index dcfd00993..23166278d 100644 |
| --- a/internal/console/apitemporary.go |
| +++ b/internal/console/apitemporary.go |
| @@ -171,6 +171,17 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ |
| jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| return |
| } |
| + // A temporary app is a running thing too (plans 3a); the account lock is |
| + // already held. |
| + if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| + return |
| + } |
| switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); { |
| case err != nil: |
| log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err) |
diff --git a/internal/console/claim_restore.go b/internal/console/claim_restore.go| index 43a960fe5..cde0291bc 100644 |
| --- a/internal/console/claim_restore.go |
| +++ b/internal/console/claim_restore.go |
| @@ -116,6 +116,13 @@ func (s *Server) restoreClaimApp(ctx context.Context, acct *Account, app string, |
| if life == nil || !life.Trashed() { |
| return errNotInTrash |
| } |
| + // Plans 3a: restoring brings a running thing back, so it meets the cap. |
| + if err := s.checkRestore(ctx, acct, app); err != nil { |
| + if isPlanCap(err) { |
| + return err |
| + } |
| + return &restoreFailure{Marker: true, Err: err} |
| + } |
| if life.Temporary() { |
| if err := s.temporaryCapCheck(acct.ID); err != nil { |
| var capErr *temporaryCapError |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 72cc1eb9f..4868954cf 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -252,6 +252,7 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| } |
| // restoreApp (restore.go) is the one restore every door runs: it locks, |
| // settles, checks the temporary cap and orders the row and marker writes. |
| + var planCap *planCapError |
| var capErr *temporaryCapError |
| var rf *restoreFailure |
| switch err := s.restoreApp(r.Context(), acct, app, time.Now()); { |
| @@ -259,6 +260,9 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| case errors.Is(err, errNotInTrash): |
| fail("There is nothing called “" + app + "” in this account's trash.") |
| return |
| + case errors.As(err, &planCap): |
| + fail(planCap.Browser()) |
| + return |
| case errors.As(err, &capErr): |
| fail(capErr.Error()) |
| return |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index 64617218c..bbdf00202 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -190,3 +190,14 @@ func isPlanCap(err error) bool { |
| var c *planCapError |
| return errors.As(err, &c) |
| } |
| + |
| +// planLock takes the account lock around a capped door's count and write, |
| +// so two concurrent creates cannot both pass. With plans off it takes |
| +// nothing, leaving those deployments exactly as they were. Never call it |
| +// from a door that already holds lockAccount (/new-temporary, restore). |
| +func (s *Server) planLock(acct *Account) func() { |
| + if s.cfg.PlanCatalog == nil { |
| + return func() {} |
| + } |
| + return s.lockAccount(acct.Sqid) |
| +} |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| new file mode 100644 |
| index 000000000..91dce0b54 |
| --- /dev/null |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -0,0 +1,150 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "net/url" |
| + "sync" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// atRunningCap turns plans on and leaves the fixture's ops account with |
| +// exactly capN running things against a cap of capN (capN >= the free |
| +// plan's 3). |
| +func atRunningCap(t *testing.T, fx *consoleFixture, capN int) *Account { |
| + t.Helper() |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("ops account: %v", err) |
| + } |
| + for i := 0; ; i++ { |
| + n, err := fx.srv.runningThings(context.Background(), acct) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n > capN { |
| + capN = n |
| + } |
| + if n == capN { |
| + break |
| + } |
| + capsApp(t, fx, acct, fmt.Sprintf("filler%02d", i)) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: capN}, "test", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return acct |
| +} |
| + |
| +func raiseRunningCap(t *testing.T, fx *consoleFixture, acct *Account, by int) { |
| + t.Helper() |
| + n, err := fx.srv.runningThings(context.Background(), acct) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: n + by}, "test", "ops@example.com", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCreateAppRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-create") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "onemore"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" || out["error"] == nil { |
| + t.Fatalf("CLI create at the cap = %d %v, want 409 cap", status, out) |
| + } |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-create-web") |
| + if st, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"webmore"}}); st != http.StatusSeeOther { |
| + t.Fatalf("browser create at the cap = %d, want the flash redirect", st) |
| + } |
| + for _, app := range []string{"onemore", "webmore"} { |
| + if has, _ := fx.st.HasAppIn(acct.ID, app); has { |
| + t.Fatalf("%s was created past the cap", app) |
| + } |
| + } |
| +} |
| + |
| +func TestCreateAppBelowTheCapStillWorks(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + raiseRunningCap(t, fx, acct, 1) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-below") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "fits"}, token); status != http.StatusOK { |
| + t.Fatalf("create below the cap = %d %v", status, out) |
| + } |
| +} |
| + |
| +func TestTemporaryAppCountsTowardRunningThings(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.TemporaryApps = true |
| + atRunningCap(t, fx, 3) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-temp") |
| + status, out := createTemporary(t, fx, token, "tmp-capcheck", 3600) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("temporary create at the running cap = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestRestoreRefusedAtTheRunningThingsCap(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-restore") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); status != http.StatusOK { |
| + t.Fatalf("delete kass = %d %v", status, out) |
| + } |
| + atRunningCap(t, fx, 3) |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/restore", map[string]string{}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("CLI restore at the cap = %d %v, want 409 cap", status, out) |
| + } |
| + c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-restore-web") |
| + if st, _, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}); st != http.StatusSeeOther { |
| + t.Fatalf("browser restore at the cap = %d, want the flash redirect", st) |
| + } |
| + if markerOf(t, fx, "kass") == nil { |
| + t.Fatal("a refused restore removed the trash marker") |
| + } |
| +} |
| + |
| +func TestRunningThingsCapIsExactUnderConcurrentCreates(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := atRunningCap(t, fx, 3) |
| + raiseRunningCap(t, fx, acct, 1) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-race") |
| + var mu sync.Mutex |
| + created := 0 |
| + var wg sync.WaitGroup |
| + for i := range 8 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + status, _ := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": fmt.Sprintf("race%d", i)}, token) |
| + if status == http.StatusOK { |
| + mu.Lock() |
| + created++ |
| + mu.Unlock() |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if created != 1 { |
| + t.Fatalf("%d creates succeeded for one free slot, want exactly 1", created) |
| + } |
| +} |
| + |
| +func TestCreateAppWithoutACatalogUnchanged(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct, _ := fx.st.AccountBySqid(fx.sqid) |
| + for i := range 5 { |
| + capsApp(t, fx, acct, fmt.Sprintf("many%d", i)) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cap-nocat") |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "stillfine"}, token); status != http.StatusOK { |
| + t.Fatalf("create with no catalog = %d %v", status, out) |
| + } |
| +} |
diff --git a/internal/console/restore.go b/internal/console/restore.go| index 5887d32d5..325e225ed 100644 |
| --- a/internal/console/restore.go |
| +++ b/internal/console/restore.go |
| @@ -83,6 +83,13 @@ func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now |
| if life == nil || !life.Trashed() { |
| return errNotInTrash |
| } |
| + // Plans 3a: restoring brings a running thing back, so it meets the cap. |
| + if err := s.checkRestore(ctx, acct, app); err != nil { |
| + if isPlanCap(err) { |
| + return err |
| + } |
| + return &restoreFailure{Marker: true, Err: err} |
| + } |
| if life.Temporary() { |
| if err := s.temporaryCapCheck(acct.ID); err != nil { |
| var capErr *temporaryCapError |
diff --git a/internal/console/server.go b/internal/console/server.go| index e8f8d7e5f..7eb4cdbf7 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -4378,6 +4378,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| // The browser door never places at create time, so the second return |
| // (what actually stuck) is always "" here and there is nothing to echo |
| // it to — this form has no placement concept at all. |
| + defer s.planLock(acct)() |
| + if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| + var planCap *planCapError |
| + if errors.As(err, &planCap) { |
| + fail(planCap.Browser()) |
| + return |
| + } |
| + log.Printf("console: create %s/%s: plan cap: %v", acct.Sqid, name, err) |
| + fail("Could not create the app. Please try again.") |
| + return |
| + } |
| outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| switch outcome { |
| case claimInvalid: |