carlos / platform Public

console: a member may ask for a restart without touching a box

POST /api/cli/apps/{account}/{app}/restart, with handleAPITrashApp's gate order byte for byte: token, the account+app 404 a stranger cannot learn anything from, the address-keyed write budget (this door's whole effect is stopping and starting processes, so an unbudgeted one is a restart loop), then the bucket check. It touches the restart generation, audits, and bumps the serial — the touch without the announcement would leave the fleet waiting out the hourly floor for the one verb people type when something is stuck.

Issue #113.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7dacc71bec72b97536b6a46acde4460909d9f679 parent e8c594a
3 files changed, +259 −0
  • internal/console/apicli.go +77 −0
  • internal/console/apirestart_test.go +177 −0
  • internal/console/authcli.go +5 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index e9b0d65d1..dd633b61a 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -469,6 +469,83 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
}
+// handleAPIRestart answers `carlos restart`: touch the app's restart
+// generation so every box serving it cycles that app's processes within
+// seconds (issue #113). No version moves, no config is written, no bake window
+// applies — this is the member-facing way to cycle a wedged instance, which
+// until now meant a box login the platform's own design rule forbids ("every
+// carlos command works for a user with zero infra access").
+//
+// The doors, in order, and why this order — handleAPITrashApp's exactly:
+//
+// token — cliUser's one 401, before any name is resolved.
+// account+app 404 — apiAccountApp: an unknown account, someone else's
+// account and an app that isn't there are one answer, so
+// probing this door teaches a stranger nothing. It also
+// means a TRASHED app cannot be restarted: the live-apps
+// lookup simply doesn't answer for it.
+// rate limit — the shared `write` budget, keyed on the ADDRESS like
+// promote's: a budget belongs to the person, not the
+// terminal. This door needs it more than most — its whole
+// effect is to make processes stop and start, so an
+// unbudgeted one would be a restart loop anyone with a
+// token could aim at their own app.
+// Releases nil — 503. The request IS a bucket object; a console with no
+// bucket has nowhere to put it and must not answer
+// "requested".
+// touch + bump — the object, then the announcement, in that order (a bump
+// for a request that failed to land would wake the fleet
+// for nothing).
+//
+// Audited, unlike a read: this stops and starts customer processes, which
+// anyone reading audit rows must be able to see.
+//
+// No body is read. There is nothing to say beyond the app already named in the
+// path — there is no restart "options" — and a bearer request carries no
+// cookie for a cross-site form to forge.
+//
+// No per-host scoping. The generation is per APP, so every box serving it
+// cycles its own routes for that app; a -host flag would need a per-host
+// object and a way to say what happens to the hosts it doesn't name, and
+// "restart my app" is the request people actually have. Boxes skip their
+// hibernating routes on their own (internal/host/restart.go) — nothing here
+// wakes anything.
+func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — this console cannot request a restart")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
+ // Logged, not returned: a store error carries bucket names, endpoints
+ // and request ids. The caller gets the fixed sentence every other
+ // bucket failure here gets, and a retry is safe — the touch is
+ // idempotent in the only sense that matters (one more request, one
+ // more cycle).
+ log.Printf("console: api restart %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again")
+ return
+ }
+ s.audit(acct, app, addr, "restart", "requested a restart (cli)")
+ // The request is in the bucket; this is what makes boxes look NOW rather
+ // than on the hourly floor — the difference between "seconds" and "up to
+ // an hour" for the one verb people type when something is stuck.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "requested": true, "account": acct.Sqid, "app": app,
+ })
+}
+
// ---- trash and restore ------------------------------------------------------
// handleAPITrashApp answers `carlos apps delete`: it moves an app to the
diff --git a/internal/console/apirestart_test.go b/internal/console/apirestart_test.go
new file mode 100644
index 000000000..b099d58ba
--- /dev/null
+++ b/internal/console/apirestart_test.go
@@ -0,0 +1,177 @@
+package console
+
+// The `carlos restart` door (issue #113). What is pinned here, in the order it
+// would hurt if lost: the request actually lands in the bucket AND is
+// announced (a request nobody hears is a restart that never happens); a
+// stranger learns nothing from probing it; and the budget that keeps it from
+// becoming a restart loop is spent per person.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// restartGenEtag is what every box watches: "" when nobody has ever asked.
+func restartGenEtag(t *testing.T, fx *consoleFixture, app string) string {
+ t.Helper()
+ _, etag, err := fx.rel.Get(context.Background(), release.RestartGenKey(release.OpsAccount, app))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return ""
+ }
+ t.Fatalf("read restart generation: %v", err)
+ }
+ return etag
+}
+
+func restartFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ // A MEMBER, not an owner: cycling your own app's processes is a member
+ // action, like promote below stable.
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// TestAPIRestartTouchesTheGenerationAndAnnouncesIt is the whole door: the
+// object a box watches moves, the serial says "look now", and the answer names
+// the canonical account (the caller may have typed a name).
+func TestAPIRestartTouchesTheGenerationAndAnnouncesIt(t *testing.T) {
+ fx, token := restartFixture(t, "code-restart-roundtrip")
+ if got := restartGenEtag(t, fx, "titogo"); got != "" {
+ t.Fatalf("restart generation exists before any request: %q", got)
+ }
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("restart = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Requested bool `json:"requested"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatalf("restart body %s: %v", out, err)
+ }
+ if !res.Requested || res.Account != "bab" || res.App != "titogo" {
+ t.Fatalf("restart body = %+v, want requested for bab/titogo", res)
+ }
+ first := restartGenEtag(t, fx, "titogo")
+ if first == "" {
+ t.Fatal("no restart generation in the bucket after a 200 — the boxes will never hear about it")
+ }
+ if got := serial(t, fx); got != 1 {
+ t.Fatalf("serial after a restart request = %d, want 1 (otherwise boxes wait out the hourly floor)", got)
+ }
+
+ // A SECOND request must move the etag again: change detection is by etag,
+ // so a byte-identical rewrite would be a restart nobody performs.
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("second restart = %d %s", status, out)
+ }
+ if second := restartGenEtag(t, fx, "titogo"); second == first {
+ t.Fatalf("etag %q unchanged after a second request", second)
+ }
+ if got := serial(t, fx); got != 2 {
+ t.Fatalf("serial after two requests = %d, want 2", got)
+ }
+}
+
+// TestAPIRestartMemberGate: a non-member's refusal is byte-identical with an
+// unknown account's and an unknown app's — the same gate, in the same place,
+// as every other door here. Probing this one must not reveal that an app
+// exists, let alone restart it.
+func TestAPIRestartMemberGate(t *testing.T) {
+ fx, _ := restartFixture(t, "code-restart-gate-member")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-restart-gate-eve")
+
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", map[string]string{}, eve)
+ if status != http.StatusNotFound {
+ t.Fatalf("restart as non-member = %d, want 404", status)
+ }
+ if body != "404 page not found\n" {
+ t.Fatalf("restart as non-member body = %q, want the stdlib http.NotFound output", body)
+ }
+ // An unknown app in an account eve IS a member of would have to answer the
+ // same; here the account itself is not hers, and neither answer moved
+ // anything.
+ if got := restartGenEtag(t, fx, "titogo"); got != "" {
+ t.Fatal("a refused request touched the restart generation")
+ }
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a refused request = %d, want 0", got)
+ }
+}
+
+// TestAPIRestartNoTokenIs401: the token gate is first, before any name is
+// resolved.
+func TestAPIRestartNoTokenIs401(t *testing.T) {
+ fx, _ := restartFixture(t, "code-restart-notoken")
+ if status, _ := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", "", ""); status != http.StatusUnauthorized {
+ t.Fatalf("restart with no token = %d, want 401", status)
+ }
+}
+
+// TestAPIRestartIsRateLimited: this door's whole effect is to stop and start
+// processes, so the budget is what stands between a token and a restart loop
+// aimed at a live app. Keyed on the address, like every other write here.
+func TestAPIRestartIsRateLimited(t *testing.T) {
+ fx, token := restartFixture(t, "code-restart-limit")
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusOK {
+ t.Fatalf("first restart = %d %s, want 200", status, out)
+ }
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "")
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("second restart on an exhausted budget = %d %s, want 429", status, out)
+ }
+ // A second TOKEN for the same person shares the budget: the principal is
+ // the person, not the terminal.
+ other := cliToken(t, fx, "alice@keymail.dev", "code-restart-limit-2")
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", other, ""); status != http.StatusTooManyRequests {
+ t.Fatalf("restart from a second token = %d %s, want 429", status, out)
+ }
+}
+
+// TestAPIRestartRefusesATrashedApp: the live-apps resolver is the gate, so an
+// app in the trash answers 404 like anything else that isn't there — there are
+// no processes to cycle, and this door must not become the way to ask whether
+// somebody's app was deleted.
+func TestAPIRestartRefusesATrashedApp(t *testing.T) {
+ fx, token := restartFixture(t, "code-restart-trashed")
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, ""); status != http.StatusOK {
+ t.Fatalf("trash = %d %s", status, out)
+ }
+ if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, ""); status != http.StatusNotFound {
+ t.Fatalf("restart of a trashed app = %d %s, want 404", status, body)
+ }
+}
+
+// TestAPIRestartWithoutABucketIs503: the request IS a bucket object. A console
+// with nowhere to put it must say so rather than answer "requested" for
+// something no box will ever see.
+func TestAPIRestartWithoutABucketIs503(t *testing.T) {
+ fx, token := restartFixture(t, "code-restart-nobucket")
+ fx.srv.cfg.Releases = nil
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, "")
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("restart with no bucket = %d %s, want 503", status, out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 0f3351d02..29b0facbb 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -225,6 +225,11 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
+ // `carlos restart` (issue #113): a member's "cycle this app's processes"
+ // with no version and no config to push — the same write-door class as
+ // promote and rollback, and the only one whose subject is a running
+ // process rather than a pointer.
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restart", s.handleAPIRestart)
// The version-target, tags and releases-list API (apiversion.go,
// release versioning spec 2026-08-08). GET .../releases coexists with
// the POST above on the SAME path — a distinct method route, not a