console: fleets and custom domains follow the plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+121
−0
internal/console/apidomains.go+6 −0internal/console/domainattach.go+4 −0internal/console/domaincatchall.go+4 −0internal/console/fleets.go+10 −0internal/console/plancaps.go+40 −0internal/console/plancaps_doors_test.go+57 −0
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index c97618b7d..a44711821 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -522,6 +522,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // can refuse first, the two 409s are indistinguishable in status, body |
| // AND reachability. Anything added to this branch later belongs above |
| // this line, not below it. |
| + // Plans 3a: custom domains need a plan that includes them (above |
| + // heldElsewhere, per the rule). |
| + if ref := s.customDomainRefusal(acct, host); ref != nil { |
| + writeJSON(w, ref.Status, capOrError(ref)) |
| + return |
| + } |
| if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil { |
| jsonError(w, ref.Status, ref.Msg) |
| return |
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go| index 888dcb090..34da425d3 100644 |
| --- a/internal/console/domainattach.go |
| +++ b/internal/console/domainattach.go |
| @@ -137,6 +137,10 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k |
| return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, |
| "that hostname is platform-managed"} |
| } |
| + // Plans 3a: custom domains need a plan that includes them. |
| + if ref := s.customDomainRefusal(acct, host); ref != nil { |
| + return attachResult{}, ref |
| + } |
| // The reserved sidecar suffix, refused at the same policy stage and with |
| // the same status as the two checks above: this door must never mint a |
| // claim (let alone a route record) under a name provision.SidecarSuffix |
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go| index 578a96ced..e6a97a7c9 100644 |
| --- a/internal/console/domaincatchall.go |
| +++ b/internal/console/domaincatchall.go |
| @@ -92,6 +92,10 @@ func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app, |
| if s.catchallParentRefused(parent, acct.Sqid) { |
| return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"} |
| } |
| + // Plans 3a: a catch-all is a custom domain too. |
| + if ref := s.customDomainRefusal(acct, parent); ref != nil { |
| + return false, ref |
| + } |
| created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app, |
| time.Now().UTC().Format(time.RFC3339), principal) |
| switch { |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index dd445ddbc..39310ba62 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -172,6 +172,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| "fleet creation limit reached for today — try again tomorrow") |
| return |
| } |
| + // Plans 3a: fleets need a plan that includes them. |
| + if err := s.checkFleets(acct); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusBadGateway, "could not check this account's plan. Try again") |
| + return |
| + } |
| var req struct { |
| Name string `json:"name"` |
| Bucket string `json:"bucket"` |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index bbdf00202..2b002e036 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "net/http" |
| "strings" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -201,3 +202,42 @@ func (s *Server) planLock(acct *Account) func() { |
| } |
| return s.lockAccount(acct.Sqid) |
| } |
| + |
| +// customDomain reports whether host is a customer's own domain, the kind a |
| +// free plan may not attach: an open host outside the deployment's apps |
| +// domain. The account's own <sqid>.<apps-domain> addresses and the |
| +// operator's platform zones are not custom domains. |
| +func (s *Server) customDomain(host, account string) bool { |
| + if s.domainHostPolicy(host, account) != hostOpen { |
| + return false |
| + } |
| + if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) { |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// customDomainRefusal is checkCustomDomains as a door refusal, for the |
| +// shared attach paths; nil when the attach may go ahead. |
| +func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal { |
| + if !s.customDomain(host, acct.Sqid) { |
| + return nil |
| + } |
| + if err := s.checkCustomDomains(acct); err != nil { |
| + if isPlanCap(err) { |
| + return &doorRefusal{http.StatusConflict, err.Error()} |
| + } |
| + return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"} |
| + } |
| + return nil |
| +} |
| + |
| +// capOrError is a door refusal as a JSON body, with "code":"cap" when it is |
| +// a plan cap (the only 409 customDomainRefusal returns). |
| +func capOrError(ref *doorRefusal) map[string]any { |
| + body := map[string]any{"error": ref.Msg} |
| + if ref.Status == http.StatusConflict { |
| + body["code"] = "cap" |
| + } |
| + return body |
| +} |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| index 15e887bf7..fd7f68bf4 100644 |
| --- a/internal/console/plancaps_doors_test.go |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -284,3 +284,60 @@ func TestNewAccountWithoutACatalogUnchanged(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestFleetCreateRefusedOnAPlanWithoutFleets(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-cap-fleet") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token) |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("fleet create on free = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestCustomDomainRefusedOnAFreePlan(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-domain") |
| + shipVersion(t, fx, "titogo", "v1") |
| + seedChannel(t, fx, "titogo", "canary/live", "v1") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + for name, body := range map[string]string{ |
| + "alias": `{"host":"tickets.example.com","route":"go.tito.io"}`, |
| + "first route": `{"host":"fresh.example.com","kind":"static"}`, |
| + } { |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| + if code != http.StatusConflict { |
| + t.Errorf("%s on free = %d %s, want 409", name, code, out) |
| + } |
| + } |
| + claimAbsent(t, fx, "tickets.example.com") |
| + claimAbsent(t, fx, "fresh.example.com") |
| +} |
| + |
| +func TestFleetsAndDomainsWithoutACatalogUnchanged(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-domain-nocat") |
| + if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"tickets.example.com","route":"go.tito.io"}`); code != http.StatusOK { |
| + t.Fatalf("alias attach with no catalog = %d %s", code, out) |
| + } |
| + fx2, token := fleetFixture(t, "code-cap-fleet-nocat") |
| + if status, out := postJSON(t, fleetsURL(fx2, fx2.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK { |
| + t.Fatalf("fleet create with no catalog = %d %v", status, out) |
| + } |
| +} |
| + |
| +// Review Focus 5: an address under the deployment's apps domain is the |
| +// account's own platform address, not a custom domain, so free plans keep it. |
| +func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-cap-platform") |
| + fx.srv.cfg.AppsDomain = "apps.example.com" |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct, _ := fx.st.AccountBySqid("bab") |
| + if fx.srv.customDomain("shop.bab.apps.example.com", acct.Sqid) { |
| + t.Fatal("an address under the apps domain was treated as a custom domain") |
| + } |
| + if !fx.srv.customDomain("shop.example.com", acct.Sqid) { |
| + t.Fatal("a customer domain was not treated as a custom domain") |
| + } |
| + if ref := fx.srv.customDomainRefusal(acct, "shop.bab.apps.example.com"); ref != nil { |
| + t.Fatalf("free plan refused its own platform address: %+v", ref) |
| + } |
| + _ = member |
| +} |