console: no always-on sidecars under a plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+69
−0
internal/console/apisidecars.go+14 −0internal/console/plancaps.go+10 −0internal/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) |
| + } |
| +} |