carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// The CLI half of `carlos restart` (issue #113): the wire shape it sends, the
4// answers it can get, and the one refusal it has to explain rather than
5// repeat.
6
7import (
8 "context"
9 "encoding/json"
10 "net/http"
11 "net/http/httptest"
12 "os/exec"
13 "strings"
14 "testing"
15)
16
17// TestAPISessionRestartWireShape pins what handleAPIRestart requires: a bearer
18// POST to the app's /restart path, with the account segment carried verbatim
19// (the console resolves names against the caller's own memberships — this
20// client must never resolve one locally).
21func TestAPISessionRestartWireShape(t *testing.T) {
22 var gotPath, gotMethod, gotAuth string
23 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24 gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
25 json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"})
26 }))
27 defer ts.Close()
28
29 res, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail")
30 if err != nil {
31 t.Fatalf("restart: %v", err)
32 }
33 if gotMethod != http.MethodPost {
34 t.Fatalf("method = %s, want POST", gotMethod)
35 }
36 if gotPath != "/api/cli/apps/keymail-team/keymail/restart" {
37 t.Fatalf("path = %s", gotPath)
38 }
39 if gotAuth != "Bearer cli-token" {
40 t.Fatalf("auth = %q", gotAuth)
41 }
42 // The CANONICAL names come back from the console, not from what was typed.
43 if res.Account != "bab" || res.App != "keymail" {
44 t.Fatalf("result = %+v, want the console's canonical bab/keymail", res)
45 }
46}
47
48// TestAPISessionRestartAccountIDIsExact: -account-id travels as ?exact=sqid,
49// the console-side switch that matches the segment as a sqid ONLY — the
50// reference an ambiguity error tells you to reach for must not itself be
51// ambiguous.
52func TestAPISessionRestartAccountIDIsExact(t *testing.T) {
53 var gotPath string
54 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55 gotPath = r.URL.RequestURI()
56 json.NewEncoder(w).Encode(map[string]any{"requested": true, "account": "bab", "app": "keymail"})
57 }))
58 defer ts.Close()
59
60 if _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab", Exact: true}, "keymail"); err != nil {
61 t.Fatalf("restart: %v", err)
62 }
63 if !strings.HasSuffix(gotPath, "?exact=sqid") {
64 t.Fatalf("path = %s, want the exact=sqid switch", gotPath)
65 }
66}
67
68// TestAPISessionRestart404NamesBothCauses is the old-console case. net/http's
69// mux answers an unregistered route with the SAME byte-identical "404 page not
70// found" apiAccountApp gives a non-member, so there is nothing in the response
71// to tell them apart — and a message that claimed either one alone would send
72// somebody to fix the wrong thing.
73func TestAPISessionRestart404NamesBothCauses(t *testing.T) {
74 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 http.NotFound(w, r) // a console that predates this door, or a stranger's probe
76 }))
77 defer ts.Close()
78
79 _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail")
80 if err == nil {
81 t.Fatal("a 404 must error")
82 }
83 if !strings.Contains(err.Error(), "-account") {
84 t.Fatalf("error %q drops the membership explanation", err)
85 }
86 if !strings.Contains(err.Error(), "predates") || !strings.Contains(err.Error(), "redeploy the console") {
87 t.Fatalf("error %q does not name the old-console cause and its fix", err)
88 }
89}
90
91// TestAPISessionRestartServerErrorSurfacesVerbatim: a door that answered with
92// a real diagnosis (a 429 budget, a 503 with no bucket, a 502 that wrote
93// nothing) must reach the terminal in the console's own words — that text
94// already says whether a retry is safe.
95func TestAPISessionRestartServerErrorSurfacesVerbatim(t *testing.T) {
96 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
97 w.WriteHeader(http.StatusTooManyRequests)
98 json.NewEncoder(w).Encode(map[string]string{"error": "too many requests — wait a minute and try again"})
99 }))
100 defer ts.Close()
101
102 _, err := newTestSession(ts.URL).restart(context.Background(), apiAcct{Seg: "bab"}, "keymail")
103 if err == nil || !strings.Contains(err.Error(), "wait a minute") {
104 t.Fatalf("restart 429 = %v, want the console's own text", err)
105 }
106}
107
108// TestRestartVerbRefusals drives the real binary for the two ways the verb
109// stops before it ever dials anything: no -app, and direct bucket mode. Both
110// run through carlosTestEnv with a scratch cmd.Dir — the repo rule — so a
111// logged-in laptop's real credential and console can never be what this test
112// exercises.
113func TestRestartVerbRefusals(t *testing.T) {
114 bin := buildCarlos(t)
115 scratch := t.TempDir()
116 run := func(env []string, args ...string) (string, error) {
117 t.Helper()
118 cmd := exec.Command(bin, args...)
119 cmd.Env = carlosTestEnv(t, env...)
120 cmd.Dir = scratch
121 out, err := cmd.CombinedOutput()
122 return string(out), err
123 }
124
125 // -app is resolvable since the project-defaults spec (2026-08-13), so a
126 // bare `carlos restart` with nothing configured stops on the REAL
127 // blocker — no session — not on a usage line for a flag that is no
128 // longer required.
129 out, err := run(nil, "restart")
130 if err == nil {
131 t.Fatalf("restart with no --app exited 0:\n%s", out)
132 }
133 if !strings.Contains(out, "no deployment target configured") {
134 t.Fatalf("bare restart = %q, want the no-target refusal", out)
135 }
136
137 // Direct bucket mode: refused in restart's OWN words, naming the login
138 // that fixes it — never another verb's advice.
139 out, err = run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "restart", "-app", "keymail")
140 if err == nil {
141 t.Fatalf("restart in direct bucket mode exited 0:\n%s", out)
142 }
143 if !strings.Contains(out, "carlos restart works through a console") ||
144 !strings.Contains(out, "carlos auth login") {
145 t.Fatalf("direct-mode refusal = %q, want restart's own message and the login fix", out)
146 }
147
148 // A malshaped app name is refused at the terminal, before the console's
149 // shared write budget is spent on a request it is certain to reject.
150 out, err = run(nil, "restart", "-app", "Not An App")
151 if err == nil {
152 t.Fatalf("restart with a bad app name exited 0:\n%s", out)
153 }
154 if !strings.Contains(out, "not a usable app name") {
155 t.Fatalf("bad app name = %q, want the name gate", out)
156 }
157}
158
159// ---- the narrowed forms (discussion #16) ------------------------------------
160
161// TestAPISessionRestartScopedWireShape pins the SEPARATE path and the body it
162// carries. Separate is the safety property: the app-wide door reads no body at
163// all, so a narrowed request sent there would be answered by cycling every
164// instance the app owns. This client must never send one there.
165func TestAPISessionRestartScopedWireShape(t *testing.T) {
166 var gotPath, gotMethod string
167 var gotBody map[string]any
168 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169 gotPath, gotMethod = r.URL.RequestURI(), r.Method
170 json.NewDecoder(r.Body).Decode(&gotBody)
171 json.NewEncoder(w).Encode(map[string]any{
172 "account": "bab", "app": "titogo", "requested": true, "dry_run": false,
173 "scope": map[string]string{"kind": "environment", "value": "relay"},
174 "routes": []map[string]any{
175 {"host": "connect.go.tito.io", "environment": "relay", "hibernate": false},
176 {"host": "sleepy.go.tito.io", "environment": "relay", "hibernate": true},
177 },
178 })
179 }))
180 defer ts.Close()
181
182 res, err := newTestSession(ts.URL).restartScoped(context.Background(),
183 apiAcct{Seg: "bab"}, "titogo", "", "relay", false)
184 if err != nil {
185 t.Fatalf("restartScoped: %v", err)
186 }
187 if gotMethod != http.MethodPost {
188 t.Fatalf("method = %s, want POST", gotMethod)
189 }
190 if gotPath != "/api/cli/apps/bab/titogo/restart/scope" {
191 t.Fatalf("path = %s — a narrowed request must not reach the app-wide door", gotPath)
192 }
193 if gotBody["environment"] != "relay" || gotBody["host"] != "" || gotBody["dry_run"] != false {
194 t.Fatalf("body = %v, want the selector and no dry run", gotBody)
195 }
196 // The ADDRESSED SET comes back, which is the whole point of the flags: the
197 // complaint was that an operator could not tell what the blast radius was.
198 if len(res.Routes) != 2 || res.Routes[0].Host != "connect.go.tito.io" || !res.Routes[1].Hibernate {
199 t.Fatalf("routes = %+v, want both rows and the sleeping one flagged", res.Routes)
200 }
201 if !res.Requested || res.Scope.Kind != "environment" || res.Scope.Value != "relay" {
202 t.Fatalf("result = %+v, want the console's own account of what it did", res)
203 }
204}
205
206// TestAPISessionRestartScopedDryRunAsksForNothing: the flag travels, and the
207// console's answer — not this client's own flags — is what says whether
208// anything was restarted.
209func TestAPISessionRestartScopedDryRunAsksForNothing(t *testing.T) {
210 var gotBody map[string]any
211 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212 json.NewDecoder(r.Body).Decode(&gotBody)
213 json.NewEncoder(w).Encode(map[string]any{
214 "account": "bab", "app": "titogo", "requested": false, "dry_run": true,
215 "scope": map[string]string{"kind": "app"}, "routes": []map[string]any{},
216 })
217 }))
218 defer ts.Close()
219
220 res, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "", true)
221 if err != nil {
222 t.Fatalf("restartScoped dry run: %v", err)
223 }
224 if gotBody["dry_run"] != true {
225 t.Fatalf("body = %v, want the dry run asked for", gotBody)
226 }
227 if res.Requested {
228 t.Fatal("a dry run came back reporting a restart")
229 }
230}
231
232// TestAPISessionRestartScoped404NamesTheOldConsole: a console that predates
233// the narrowed door answers net/http's byte-identical "404 page not found",
234// exactly as the membership gate does. The message must name all three
235// possibilities — including the form that still works there, because telling
236// somebody to redeploy their console and nothing else leaves them stuck.
237func TestAPISessionRestartScoped404NamesTheOldConsole(t *testing.T) {
238 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239 http.NotFound(w, r)
240 }))
241 defer ts.Close()
242
243 _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "bill.go.tito.io", "", false)
244 if err == nil {
245 t.Fatal("a 404 must error")
246 }
247 for _, want := range []string{"-account", "predates", "redeploy the console", "carlos restart --app"} {
248 if !strings.Contains(err.Error(), want) {
249 t.Fatalf("error %q does not mention %q", err, want)
250 }
251 }
252}
253
254// TestAPISessionRestartScopedServerRefusalSurfacesVerbatim: the refusal that
255// names what exists ("no route of this app reads environment relayy — this
256// console's box has relay, router") is the console's own sentence, and it is
257// the sentence that lets somebody spot their typo. It must not be replaced
258// with a generic one here.
259func TestAPISessionRestartScopedServerRefusalSurfacesVerbatim(t *testing.T) {
260 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261 w.WriteHeader(http.StatusBadRequest)
262 json.NewEncoder(w).Encode(map[string]string{
263 "error": "no route of this app reads environment relayy — this console's box has relay, router",
264 })
265 }))
266 defer ts.Close()
267
268 _, err := newTestSession(ts.URL).restartScoped(context.Background(), apiAcct{Seg: "bab"}, "titogo", "", "relayy", false)
269 if err == nil || !strings.Contains(err.Error(), "relay, router") {
270 t.Fatalf("restart scope 400 = %v, want the console's own list of what exists", err)
271 }
272}
273
274// TestRestartSelectorRefusals drives the real binary for the selector gates it
275// applies before dialling anything — the client-side half of "a typo should
276// cost nothing".
277func TestRestartSelectorRefusals(t *testing.T) {
278 bin := buildCarlos(t)
279 scratch := t.TempDir()
280 run := func(args ...string) (string, error) {
281 t.Helper()
282 cmd := exec.Command(bin, args...)
283 cmd.Env = carlosTestEnv(t)
284 cmd.Dir = scratch
285 out, err := cmd.CombinedOutput()
286 return string(out), err
287 }
288
289 // "default" is the base bundle no route is BOUND to, so a restart of what
290 // reads it is a restart of the app — refused by name rather than resolved
291 // to an empty set. Same gate `carlos env` applies to the same flag.
292 out, err := run("restart", "-app", "titogo", "-environment", "default")
293 if err == nil {
294 t.Fatalf("restart --environment default exited 0:\n%s", out)
295 }
296 if !strings.Contains(out, "already names the base bundle") {
297 t.Fatalf("--environment default = %q, want the named refusal", out)
298 }
299
300 out, err = run("restart", "-app", "titogo", "-environment", "Not Valid")
301 if err == nil {
302 t.Fatalf("restart with a malformed environment exited 0:\n%s", out)
303 }
304 if !strings.Contains(out, "not a valid environment name") {
305 t.Fatalf("bad environment = %q, want the shape gate", out)
306 }
307
308 // A selector that was TYPED but empty — a shell variable that expanded to
309 // nothing — must not fall through to "no selector", which is an app-wide
310 // restart. Somebody asking to cycle one route and cycling all of them
311 // instead is the accident the flags exist to prevent, arriving through the
312 // flags themselves.
313 for _, args := range [][]string{
314 {"restart", "-app", "titogo", "-host", ""},
315 {"restart", "-app", "titogo", "-environment", ""},
316 } {
317 out, err := run(args...)
318 if err == nil {
319 t.Fatalf("%v exited 0:\n%s", args, out)
320 }
321 if !strings.Contains(out, "empty value") || !strings.Contains(out, "nothing was restarted") {
322 t.Fatalf("%v = %q, want the empty-selector refusal", args, out)
323 }
324 }
325
326 // The selectors do not change the mode refusal: a narrowed restart is
327 // still a console verb, and direct bucket mode still says so in restart's
328 // own words.
329 cmd := exec.Command(bin, "restart", "-app", "titogo", "-host", "bill.go.tito.io")
330 cmd.Env = carlosTestEnv(t, "CARLOS_DEPLOYMENT_DIR="+t.TempDir())
331 cmd.Dir = scratch
332 raw, err := cmd.CombinedOutput()
333 if err == nil {
334 t.Fatalf("a narrowed restart in direct bucket mode exited 0:\n%s", raw)
335 }
336 if !strings.Contains(string(raw), "carlos restart works through a console") {
337 t.Fatalf("direct-mode refusal = %q, want restart's own message", raw)
338 }
339}
340