carlos / platform Public

console: no always-on sidecars under a plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 192bed3d6c9421edea7e4632aaba7bbfbdc044be parent ac730f2
3 files changed, +69 −0
  • internal/console/apisidecars.go +14 −0
  • internal/console/plancaps.go +10 −0
  • internal/console/plancaps_doors_test.go +45 −0
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index 698b7daa4..480bffdf3 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -593,6 +593,20 @@ func (s *Server) handleAPISidecarEnable(w http.ResponseWriter, r *http.Request)
// swallow an operator's store-mode write landing in the same window,
// which is exactly the pool/regions policy this door promises not to
// touch.
+ // Plans 3b: no plan offers workers that never sleep. Only a request that
+ // asks for hibernate:false is refused; one that omits the field leaves an
+ // existing manifest as it was.
+ if req.Hibernate != nil && !*req.Hibernate {
+ if err := s.checkAlwaysOn(acct); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: api sidecars enable %s/%s: plan: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, planCheckFailedCopy)
+ return
+ }
+ }
m, etag, err := provision.GetSidecarManifestETag(ctx, s.cfg.Releases, acct.Sqid, app)
switch {
case errors.Is(err, os.ErrNotExist):
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
index 7f325982e..8a701751c 100644
--- a/internal/console/plancaps.go
+++ b/internal/console/plancaps.go
@@ -298,3 +298,13 @@ func (s *Server) checkDaemon(acct *Account) error {
}
return &planCapError{msg: capMessage("cli.cap.daemon")}
}
+
+// checkAlwaysOn refuses settings that would keep a sidecar awake for good
+// (plans 3b; spec §5.2: no plan offers always-on).
+func (s *Server) checkAlwaysOn(acct *Account) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil {
+ return err
+ }
+ return &planCapError{msg: capMessage("cli.cap.always_on")}
+}
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
index fdfe20d9a..314347077 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -432,3 +432,48 @@ func TestShipWithoutDaemonUnderAPlanUnchanged(t *testing.T) {
t.Fatalf("ship without daemon under a plan = %d %v", status, out)
}
}
+
+func sidecarHibernate(t *testing.T, fx *consoleFixture) *bool {
+ t.Helper()
+ m, err := provision.GetSidecarManifest(context.Background(), fx.rel, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m.Hibernate
+}
+
+func TestSidecarEnableRefusesHibernateFalseUnderAPlan(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-alwayson")
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`)
+ if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) {
+ t.Fatalf("hibernate:false under a plan = %d %s, want 409 cap", status, out)
+ }
+ if h := sidecarHibernate(t, fx); h != nil && !*h {
+ t.Fatal("the manifest was set to stay awake under a plan")
+ }
+}
+
+func TestSidecarEnableKeepsAnExistingSettingWhenOmitted(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-alwayson-keep")
+ m := titoSidecarManifest()
+ off := false
+ m.Hibernate = &off
+ enableSidecars(t, fx, "titogo", m)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"idle_seconds":120}`); status != http.StatusOK {
+ t.Fatalf("enable without a hibernate field = %d %s, want 200", status, out)
+ }
+ if h := sidecarHibernate(t, fx); h == nil || *h {
+ t.Fatal("an existing setting was changed by an enable that did not name it")
+ }
+}
+
+func TestSidecarEnableHibernateFalseWithoutACatalogUnchanged(t *testing.T) {
+ fx, token := instanceFixture(t, "code-cap-alwayson-nocat")
+ enableSidecars(t, fx, "titogo", titoSidecarManifest())
+ if status, _, out := rawReq(t, http.MethodPut, sidecarEnableURL(fx, "bab", "titogo"), token, `{"hibernate":false}`); status != http.StatusOK {
+ t.Fatalf("hibernate:false with no catalog = %d %s, want 200", status, out)
+ }
+}