carlos / platform Public

cli: carlos restart, the wedged instance you cannot ssh to

API mode only, refused in its own words in direct bucket mode (a bucket-mode operator already has the box; the honest answer there is systemctl, not a bucket write no console would announce). One line out, saying REQUESTED rather than restarted, because that is what happened — the boxes converge on their own and this terminal is not watching them.

The 404 is genuinely ambiguous — net/http answers an unregistered route with the same bytes apiAccountApp gives a non-member — so the message names both causes, shortest fix first, the way restoreApp already widens its own.

Issue #113.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ab7a90b0221be60cfd90d21b01f29e2c4cae167a parent 7dacc71
4 files changed, +273 −0
  • apimode.go +39 −0
  • main.go +3 −0
  • restart.go +78 −0
  • restart_test.go +153 −0
diff --git a/apimode.go b/apimode.go
index d309e3759..ad4daf82e 100644
--- a/apimode.go
+++ b/apimode.go
@@ -414,6 +414,45 @@ func (s *apiSession) rollback(ctx context.Context, a apiAcct, app, channel strin
return apiErrorFromBody(status, body)
}
+// apiRestartResult is what `carlos restart` prints: the console's CANONICAL
+// account sqid and app name, since the segment the caller typed may have been
+// a name resolved server-side.
+type apiRestartResult struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+}
+
+// restart asks the console to request a process cycle for app on every box
+// that serves it (issue #113). Mirrors trashApp's shape — postJSON, a 200
+// decodes, anything else is apiErrorFromBody's job — with ONE addition, the
+// 404.
+//
+// A 404 here is genuinely ambiguous, and pretending otherwise would be a lie
+// in one direction or the other. An old console has no /restart route at all,
+// so net/http's mux answers exactly the same byte-identical "404 page not
+// found" apiAccountApp gives an unknown account, someone else's account, or an
+// app that isn't there — there is nothing in the response to tell them apart.
+// So the message names BOTH, shortest fix first, the same way restoreApp
+// widens its own 404 rather than inventing certainty it doesn't have.
+func (s *apiSession) restart(ctx context.Context, a apiAcct, app string) (apiRestartResult, error) {
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/restart"), struct{}{})
+ if err != nil {
+ return apiRestartResult{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiRestartResult{}, fmt.Errorf("%w — or this console predates `carlos restart` "+
+ "(no restart door on it at all), in which case redeploy the console first", notFoundErr())
+ }
+ return apiRestartResult{}, apiErrorFromBody(status, body)
+ }
+ var res apiRestartResult
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiRestartResult{}, fmt.Errorf("restart: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
// channels reads one app's channel ladder through the console.
func (s *apiSession) channels(ctx context.Context, a apiAcct, app string) ([]apiChannel, error) {
status, body, err := s.getJSON(ctx, apiPath(a, app, "/channels"))
diff --git a/main.go b/main.go
index 18735f150..58b38f113 100644
--- a/main.go
+++ b/main.go
@@ -59,6 +59,8 @@ func main() {
runPromote(args)
case "deploy":
runDeploy(args)
+ case "restart":
+ runRestart(args)
case "channels":
runChannels(args)
case "releases":
@@ -119,6 +121,7 @@ Your apps — ship and run software on a CARLOS deployment
promote move a version up the ladder: canary → edge → beta → stable
(reaching stable cuts the release's semver tag)
deploy ship + promote + wait until the URL serves the new version
+ restart cycle an app's instances — no new version, no config change
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
releases every version shipped, newest first, with labels and tags
diff --git a/restart.go b/restart.go
new file mode 100644
index 000000000..6cd3fe318
--- /dev/null
+++ b/restart.go
@@ -0,0 +1,78 @@
+// restart.go is `carlos restart`: cycle an app's processes with no version
+// and no config change (issue #113). It is the member-facing answer to a
+// wedged instance, which until now meant `systemctl` on a box — and every
+// carlos command is designed for someone with no box access at all.
+//
+// Pull-based, like everything else here: the console touches one small object,
+// every box serving the app notices within seconds and cycles its own routes.
+// Nothing is pushed at a box, nothing wakes a sleeping instance, and no
+// release ladder or bake window is involved — there is no new version for one
+// to gate.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "strings"
+)
+
+// runRestart is the verb. API mode ONLY, like deploy: the request is a console
+// write, and the object it touches means nothing without a fleet reading it.
+func runRestart(args []string) {
+ fs := flag.NewFlagSet("restart", flag.ExitOnError)
+ app := fs.String("app", "", "app whose instances to cycle")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to restart through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ if name == "" {
+ log.Fatal("usage: carlos restart -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+ // Client-side gate before the server's, the same one `carlos apps create`
+ // applies: a name the console is certain to refuse should cost a typo
+ // nothing, and the shared write budget is spent per attempt.
+ if err := validAppName(name); err != nil {
+ log.Fatalf("restart: %v", err)
+ }
+
+ sess := requireRestartAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ res, err := sess.restart(context.Background(), acct, name)
+ if err != nil {
+ log.Fatalf("restart: %v", err)
+ }
+ // One line, and it says REQUESTED rather than restarted, because that is
+ // what actually happened: the boxes converge on their own within seconds,
+ // and this terminal is not watching them do it. Claiming a restart here
+ // would be the same lie issue #110 was made of, told by the CLI instead of
+ // a header.
+ fmt.Fprintf(stdout, "%s for %s/%s — instances cycle within seconds "+
+ "(hibernating instances stay asleep and pick it up on wake)\n",
+ colorFor(stdout).ok("restart requested"), res.Account, res.App)
+}
+
+// requireRestartAPISession resolves signed-in mode for restart, refusing
+// direct-bucket mode with restart's OWN message — the pattern
+// requireDeployAPISession sets, and for its reason: a refusal that names
+// another verb's advice sends people somewhere that cannot help them.
+//
+// There is deliberately no headless equivalent to fall back to. A bucket-mode
+// operator asking for a restart already has the box (that is what bucket mode
+// means), so the honest answer is the box's own systemctl — not a bucket write
+// this CLI could make and no console would announce.
+func requireRestartAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos restart works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). restart is a member verb with no\n" +
+ "direct-bucket equivalent — the request is a console write the fleet listens for. Unset the\n" +
+ "store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
diff --git a/restart_test.go b/restart_test.go
new file mode 100644
index 000000000..8232da794
--- /dev/null
+++ b/restart_test.go
@@ -0,0 +1,153 @@
+package main
+
+// The CLI half of `carlos restart` (issue #113): the wire shape it sends, the
+// answers it can get, and the one refusal it has to explain rather than
+// repeat.
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// TestAPISessionRestartWireShape pins what handleAPIRestart requires: a bearer
+// POST to the app's /restart path, with the account segment carried verbatim
+// (the console resolves names against the caller's own memberships — this
+// client must never resolve one locally).
+func TestAPISessionRestartWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"})
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail")
+ if err != nil {
+ t.Fatalf("restart: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/keymail-team/keymail/restart" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ // The CANONICAL names come back from the console, not from what was typed.
+ if res.Account != "bab" || res.App != "keymail" {
+ t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
+ }
+}
+
+// TestAPISessionRestartAccountIDIsExact: -account-id travels as ?exact=sqid,
+// the console-side switch that matches the segment as a sqid ONLY — the
+// reference an ambiguity error tells you to reach for must not itself be
+// ambiguous.
+func TestAPISessionRestartAccountIDIsExact(t *testing.T) {
+ var gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.RequestURI()
+ json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"})
+ }))
+ defer ts.Close()
+
+ if _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab", Exact: true}, "keymail"); err != nil {
+ t.Fatalf("restart: %v", err)
+ }
+ if !strings.HasSuffix(gotPath, "?exact=sqid") {
+ t.Fatalf("path = %s, want the exact=sqid switch", gotPath)
+ }
+}
+
+// TestAPISessionRestart404NamesBothCauses is the old-console case. net/http's
+// mux answers an unregistered route with the SAME byte-identical "404 page not
+// found" apiAccountApp gives a non-member, so there is nothing in the response
+// to tell them apart — and a message that claimed either one alone would send
+// somebody to fix the wrong thing.
+func TestAPISessionRestart404NamesBothCauses(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r) // a console that predates this door, or a stranger's probe
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail")
+ if err == nil {
+ t.Fatal("a 404 must error")
+ }
+ if !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("error %q drops the membership explanation", err)
+ }
+ if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "redeploy the console") {
+ t.Fatalf("error %q does not name the old-console cause and its fix", err)
+ }
+}
+
+// TestAPISessionRestartServerErrorSurfacesVerbatim: a door that answered with
+// a real diagnosis (a 429 budget, a 503 with no bucket, a 502 that wrote
+// nothing) must reach the terminal in the console's own words — that text
+// already says whether a retry is safe.
+func TestAPISessionRestartServerErrorSurfacesVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusTooManyRequests)
+ json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail")
+ if err == nil || !strings.Contains(err.Error(), "wait a minute") {
+ t.Fatalf("restart 429 = %v, want the console's own text", err)
+ }
+}
+
+// TestRestartVerbRefusals drives the real binary for the two ways the verb
+// stops before it ever dials anything: no -app, and direct bucket mode. Both
+// run through carlosTestEnv with a scratch cmd.Dir — the repo rule — so a
+// logged-in laptop's real credential and console can never be what this test
+// exercises.
+func TestRestartVerbRefusals(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ run := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, env...)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ out, err := run(nil, "restart")
+ if err == nil {
+ t.Fatalf("restart with no -app exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "usage: carlos restart -app") {
+ t.Fatalf("restart with no -app = %q, want the usage line", out)
+ }
+
+ // Direct bucket mode: refused in restart's OWN words, naming the login
+ // that fixes it — never another verb's advice.
+ out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "restart", "-app", "keymail")
+ if err == nil {
+ t.Fatalf("restart in direct bucket mode exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos restart works through a console") ||
+ !strings.Contains(out, "carlos auth login") {
+ t.Fatalf("direct-mode refusal = %q, want restart's own message and the login fix", out)
+ }
+
+ // A malshaped app name is refused at the terminal, before the console's
+ // shared write budget is spent on a request it is certain to reject.
+ out, err = run(nil, "restart", "-app", "Not An App")
+ if err == nil {
+ t.Fatalf("restart with a bad app name exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "not a usable app name") {
+ t.Fatalf("bad app name = %q, want the name gate", out)
+ }
+}