carlos / platform Public

console: no daemon releases under a plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev ac730f29f2cb7c6c36645688e767650ad7f620aa parent 6fa6bf0
5 files changed, +89 −2
  • internal/console/apicli.go +13 −0
  • internal/console/fleets.go +1 −1
  • internal/console/plancaps.go +11 −1
  • internal/console/plancaps_copy.go +4 −0
  • internal/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)
+ }
+}