carlos / platform Public

console: the running-things cap on app creation and restore

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e8a4f546ebf9a93a5848e97f9ec7bf1789f35699 parent 149076d
8 files changed, +215 −0
  • internal/console/apicli.go +14 −0
  • internal/console/apitemporary.go +11 −0
  • internal/console/claim_restore.go +7 −0
  • internal/console/ownership.go +4 −0
  • internal/console/plancaps.go +11 −0
  • internal/console/plancaps_doors_test.go +150 −0
  • internal/console/restore.go +7 −0
  • internal/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: