console: no daemon releases under a plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+89
−2
internal/console/apicli.go+13 −0internal/console/fleets.go+1 −1internal/console/plancaps.go+11 −1internal/console/plancaps_copy.go+4 −0internal/console/plancaps_doors_test.go+60 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 69e5f77f4..95c30cd1f 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -2008,6 +2008,19 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, capsRefusal(err)) |
| return |
| } |
| + // Plans 3b: a daemon never sleeps, and every plan's apps do. Before |
| + // the identical-reship short-circuit, so a re-ship cannot slip past. |
| + if declared.Daemon { |
| + if err := s.checkDaemon(acct); err != nil { |
| + if isPlanCap(err) { |
| + writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| + return |
| + } |
| + log.Printf("console: api ship %s/%s: plan: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| + return |
| + } |
| + } |
| } |
| if refuseBadSwap(w, meta.Swap) { |
| return |
diff --git a/internal/console/fleets.go b/internal/console/fleets.go| index 39310ba62..0b5ad3acb 100644 |
| --- a/internal/console/fleets.go |
| +++ b/internal/console/fleets.go |
| @@ -179,7 +179,7 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) { |
| 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") |
| + jsonError(w, http.StatusBadGateway, planCheckFailedCopy) |
| return |
| } |
| var req struct { |
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go| index f775fcf54..7f325982e 100644 |
| --- a/internal/console/plancaps.go |
| +++ b/internal/console/plancaps.go |
| @@ -233,7 +233,7 @@ func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal { |
| if isPlanCap(err) { |
| return &doorRefusal{http.StatusConflict, err.Error()} |
| } |
| - return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"} |
| + return &doorRefusal{http.StatusBadGateway, planCheckFailedCopy} |
| } |
| return nil |
| } |
| @@ -288,3 +288,13 @@ func (s *Server) planPersonLock(addr string) func() { |
| } |
| return s.acctLocks.lock("person:" + addr) |
| } |
| + |
| +// checkDaemon refuses a release that declares the daemon capability, which |
| +// keeps an app running all the time (plans 3b; spec §5.2). |
| +func (s *Server) checkDaemon(acct *Account) error { |
| + l, err := s.EffectiveLimits(acct) |
| + if err != nil || l == nil { |
| + return err |
| + } |
| + return &planCapError{msg: capMessage("cli.cap.daemon")} |
| +} |
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go| index 6c43a14ef..8f7abf24b 100644 |
| --- a/internal/console/plancaps_copy.go |
| +++ b/internal/console/plancaps_copy.go |
| @@ -16,3 +16,7 @@ var capCopy = map[string]string{ |
| "cli.cap.fleets": "this account's plan does not include its own boxes (fleets). move to a plan that does to add one", |
| "cli.cap.custom_domain": "custom domains need a paid plan. on this plan the app keeps its platform address", |
| } |
| + |
| +// planCheckFailedCopy is the approved answer when an account's plan could |
| +// not be read (copy review 2026-10-22, cli.err.plan_check_failed). |
| +const planCheckFailedCopy = "could not check this account's plan. Try again" |
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go| index 78345a212..fdfe20d9a 100644 |
| --- a/internal/console/plancaps_doors_test.go |
| +++ b/internal/console/plancaps_doors_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // atRunningCap turns plans on and leaves the fixture's ops account with |
| @@ -372,3 +373,62 @@ func TestFreeAccountCapIsExactUnderConcurrentCreates(t *testing.T) { |
| t.Fatalf("%d free accounts minted concurrently, want exactly 1", created) |
| } |
| } |
| + |
| +// ---- Plans 3b: things that never sleep ---- |
| + |
| +const daemonBlock = `{"v":1,"command":"relay","daemon":true}` |
| + |
| +func shipDaemon(t *testing.T, fx *consoleFixture, token, version string) (int, map[string]any) { |
| + t.Helper() |
| + return postShip(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/releases", token, |
| + []shipPart{metaPartCaps(t, version, "binary", daemonBlock), {name: "linux-arm64", body: []byte("daemon-binary-bytes")}}) |
| +} |
| + |
| +func daemonFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "relayapp", "paul@keymail.dev", planNow); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "paul@keymail.dev", code) |
| +} |
| + |
| +func TestShipRefusesDaemonUnderAPlan(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := shipDaemon(t, fx, token, "dmn00001") |
| + if status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("daemon ship under a plan = %d %v, want 409 cap", status, out) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "relayapp", "dmn00001"); err == nil { |
| + t.Fatal("a daemon release was written under a plan") |
| + } |
| +} |
| + |
| +func TestShipRefusesDaemonEvenOnReship(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-reship") |
| + if status, out := shipDaemon(t, fx, token, "dmn00002"); status != http.StatusCreated { |
| + t.Fatalf("setup ship with no catalog = %d %v", status, out) |
| + } |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + if status, out := shipDaemon(t, fx, token, "dmn00002"); status != http.StatusConflict || out["code"] != "cap" { |
| + t.Fatalf("identical daemon re-ship under a plan = %d %v, want 409 cap", status, out) |
| + } |
| +} |
| + |
| +func TestShipDaemonWithoutACatalogUnchanged(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-daemon-nocat") |
| + if status, out := shipDaemon(t, fx, token, "dmn00003"); status != http.StatusCreated { |
| + t.Fatalf("daemon ship with no catalog = %d %v", status, out) |
| + } |
| +} |
| + |
| +func TestShipWithoutDaemonUnderAPlanUnchanged(t *testing.T) { |
| + fx, token := daemonFixture(t, "code-cap-nodaemon") |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/relayapp/releases", token, |
| + []shipPart{metaPartCaps(t, "plain001", "binary", `{"v":1,"command":"relay"}`), {name: "linux-arm64", body: []byte("plain-binary-bytes")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship without daemon under a plan = %d %v", status, out) |
| + } |
| +} |