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>
4 files changed,
+273
−0
apimode.go+39 −0main.go+3 −0restart.go+78 −0restart_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) |
| + } |
| +} |