carlos / platform Public

console: caps on instances, first routes and sidecars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 982ce55fe6bf130f03c4b1475bbc61cc6e9d42ca parent e8a4f54
4 files changed, +107 −0
  • internal/console/apiinstances.go +12 −0
  • internal/console/apisidecars.go +11 −0
  • internal/console/domainattach.go +9 −0
  • internal/console/plancaps_doors_test.go +75 −0
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 1aed8a1d5..110867b64 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -1576,6 +1576,18 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, ref.Status, ref.Msg)
return
}
+ // Plans 3a: an instance is a running thing. Checked once here, so plain,
+ // seeded and imported creates all meet the cap, under the account lock.
+ 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 instances create %s/%s: plan cap: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not create the instance — try again")
+ return
+ }
// Every ordinary check has passed: a seed door takes over from here, and
// writes its own record (apiinstanceseed.go).
if req.From != nil {
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index b16346bc7..698b7daa4 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -311,6 +311,17 @@ func (s *Server) handleAPISidecarCreate(w http.ResponseWriter, r *http.Request)
jsonError(w, http.StatusBadRequest, err.Error())
return
}
+ // Plans 3a: the plan caps workers per app, under the account lock.
+ defer s.planLock(acct)()
+ if err := s.checkSidecars(r.Context(), acct, app); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api sidecars create %s/%s: plan cap: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not create the sidecar — try again")
+ return
+ }
// The leftover-replica rail (#199, createleftovers.go): a sidecar's host
// is derived from its name, so a name reused after a delete lands on the
// deleted sidecar's replica. Refused, after every validation above.
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 36769f333..888dcb090 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -205,6 +205,15 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
if ref != nil {
return attachResult{}, ref
}
+ // Plans 3a: a first route starts a running thing, so it meets the cap,
+ // above heldElsewhere per the ordering rule.
+ defer s.planLock(acct)()
+ if err := s.checkRunningThings(ctx, acct, 1); err != nil {
+ if isPlanCap(err) {
+ return attachResult{}, &doorRefusal{http.StatusConflict, err.Error()}
+ }
+ return attachResult{}, &doorRefusal{http.StatusBadGateway, "could not create the instance — try again"}
+ }
if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
return attachResult{}, ref
}
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
index 91dce0b54..24ddf82aa 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -5,10 +5,12 @@ import (
"fmt"
"net/http"
"net/url"
+ "strings"
"sync"
"testing"
"github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/provision"
)
// atRunningCap turns plans on and leaves the fixture's ops account with
@@ -148,3 +150,76 @@ func TestCreateAppWithoutACatalogUnchanged(t *testing.T) {
t.Fatalf("create with no catalog = %d %v", status, out)
}
}
+
+// sponsorCustomDomains keeps the cap as atRunningCap left it and adds custom
+// domains, so a first-route attach meets the running-things cap rather than
+// the custom-domain gate.
+func sponsorCustomDomains(t *testing.T, fx *consoleFixture, acct *Account) {
+ t.Helper()
+ row, err := fx.st.AccountPlan(acct.ID)
+ if err != nil || row == nil || row.Sponsored == nil {
+ t.Fatalf("plan row: %+v, %v", row, err)
+ }
+ sp := *row.Sponsored
+ sp.CustomDomains = true
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &sp, "test", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestInstanceCreateRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-instance")
+ enableInstances(t, fx, "titogo", titoManifest())
+ atRunningCap(t, fx, 3)
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`)
+ if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) {
+ t.Fatalf("instance create at the cap = %d %s, want 409 cap", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); err == nil {
+ t.Fatal("an instance record was written past the cap")
+ }
+}
+
+func TestFirstRouteAttachRefusedAtTheRunningThingsCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-firstroute")
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ acct := atRunningCap(t, fx, 3)
+ sponsorCustomDomains(t, fx, acct)
+ status, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, `{"host":"shop.example.com","kind":"static"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("first-route attach at the cap = %d %s, want 409", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "shop.example.com"); err == nil {
+ t.Fatal("a first-route record was written past the cap")
+ }
+}
+
+func TestSidecarCreateRefusedAtTheSidecarCap(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-sidecar")
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct, _ := fx.st.AccountBySqid("bab")
+ capsSidecar(t, fx, acct, "titogo", "first")
+ status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`)
+ if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) {
+ t.Fatalf("second sidecar on free = %d %s, want 409 cap", status, out)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil {
+ t.Fatal("a sidecar record was written past the cap")
+ }
+}
+
+func TestInstanceAndSidecarCreateWithoutACatalogUnchanged(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-inst-nocat")
+ enableInstances(t, fx, "titogo", titoManifest())
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("instance create with no catalog = %d %s", status, out)
+ }
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ acct, _ := fx.st.AccountBySqid("bab")
+ capsSidecar(t, fx, acct, "titogo", "first")
+ if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("sidecar create with no catalog = %d %s", status, out)
+ }
+}