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
3import (
4 "context"
5 "flag"
6 "fmt"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os/exec"
11 "reflect"
12 "strings"
13 "sync"
14 "testing"
15
16 "github.com/carlosframework/platform/internal/updatewords"
17)
18
19// updatesConsole is a console for the instance update verbs: it answers the
20// settings door (GET and PUT), the install door and the channel door, and
21// records every request body and the order the doors were called in.
22type updatesConsole struct {
23 mu sync.Mutex
24
25 // getView is what the GET answers; putView is what the PUT answers (the
26 // GET's, when empty).
27 getView, putView string
28 // putStatus/putBody make the PUT refuse; installStatus/installBody the
29 // install door; channelStatus/channelBody the channel door.
30 putStatus, installStatus, channelStatus int
31 putBody, installBody, channelBody string
32
33 puts, installs, channelPosts []string
34 order []string
35}
36
37func (c *updatesConsole) session(t *testing.T) *apiSession {
38 t.Helper()
39 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 c.mu.Lock()
41 defer c.mu.Unlock()
42 b, _ := io.ReadAll(r.Body)
43 w.Header().Set("Content-Type", "application/json")
44 answer := func(status int, body string) {
45 if status != 0 {
46 w.WriteHeader(status)
47 }
48 io.WriteString(w, body)
49 }
50 switch {
51 case r.Method == http.MethodGet && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates":
52 c.order = append(c.order, "get")
53 answer(0, c.getView)
54 case r.Method == http.MethodPut && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates":
55 c.order = append(c.order, "updates")
56 c.puts = append(c.puts, string(b))
57 if c.putStatus != 0 {
58 answer(c.putStatus, c.putBody)
59 return
60 }
61 view := c.putView
62 if view == "" {
63 view = c.getView
64 }
65 answer(0, view)
66 case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/updates/install":
67 c.order = append(c.order, "install")
68 c.installs = append(c.installs, string(b))
69 if c.installStatus != 0 {
70 answer(c.installStatus, c.installBody)
71 return
72 }
73 answer(0, `{"request":4}`)
74 case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances/a.shop.example.com/channel":
75 c.order = append(c.order, "channel")
76 c.channelPosts = append(c.channelPosts, string(b))
77 if c.channelStatus != 0 {
78 answer(c.channelStatus, c.channelBody)
79 return
80 }
81 answer(0, `{"host":"a.shop.example.com","channel":"beta","kind":"instance"}`)
82 default:
83 c.order = append(c.order, "unexpected "+r.Method+" "+r.URL.Path)
84 w.WriteHeader(http.StatusNotFound)
85 }
86 }))
87 t.Cleanup(ts.Close)
88 return newTestSession(ts.URL)
89}
90
91func (c *updatesConsole) orderStr() string { return strings.Join(c.order, ",") }
92
93const (
94 testHost = "a.shop.example.com"
95 // the record's own values (explicit) differ from the effective ones when
96 // the app's policy fills a gap.
97 viewAuto = `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"","channel":"stable","major":null,"pin":""},"enforced":true}`
98 viewManual = `{"host":"a.shop.example.com","settings":{"updates":"manual","channel":"stable","major":null,"pin":""},"explicit":{"updates":"manual","channel":"stable","major":null,"pin":""},"enforced":true}`
99)
100
101func setRun(t *testing.T, c *updatesConsole, req map[string]any, channel *string) (string, error) {
102 t.Helper()
103 out := captureStdout(t)
104 err := instancesSetRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, req, channel)
105 return out.String(), err
106}
107
108func TestInstancesSetSendsOnlyTheFlagsGiven(t *testing.T) {
109 c := &updatesConsole{getView: viewAuto, putView: viewManual}
110 out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil)
111 if err != nil {
112 t.Fatal(err)
113 }
114 if len(c.puts) != 1 || c.puts[0] != `{"updates":"manual"}` {
115 t.Fatalf("PUT bodies = %q", c.puts)
116 }
117 if len(c.channelPosts) != 0 {
118 t.Fatalf("the channel door was called: %q", c.channelPosts)
119 }
120 want := fmt.Sprintf(updatewords.Text("settings.cli.set-done")+"\n", testHost, "manual", "stable", "none", "none")
121 if out != want {
122 t.Fatalf("output = %q, want %q", out, want)
123 }
124}
125
126func TestInstancesSetRequestSendsOnlyWhatWasGiven(t *testing.T) {
127 parse := func(args ...string) (*flag.FlagSet, *string, *string, *string) {
128 fs := flag.NewFlagSet("t", flag.ContinueOnError)
129 u := fs.String("updates", "", "")
130 m := fs.String("major", "", "")
131 p := fs.String("pin", "", "")
132 if err := fs.Parse(args); err != nil {
133 t.Fatal(err)
134 }
135 return fs, u, m, p
136 }
137 fs, u, m, p := parse("--updates", " manual ", "--major", "2", "--pin", "")
138 req, err := instancesSetRequest(fs, *u, *m, *p)
139 if err != nil || !reflect.DeepEqual(req, map[string]any{"updates": "manual", "major": 2, "pin": ""}) {
140 t.Fatalf("req = %#v, %v", req, err)
141 }
142 fs, u, m, p = parse("--major", "")
143 req, err = instancesSetRequest(fs, *u, *m, *p)
144 if err != nil || !reflect.DeepEqual(req, map[string]any{"major": nil}) {
145 t.Fatalf("req = %#v, %v", req, err)
146 }
147 fs, u, m, p = parse()
148 if req, err = instancesSetRequest(fs, *u, *m, *p); err != nil || len(req) != 0 {
149 t.Fatalf("no flags: req = %#v, %v", req, err)
150 }
151 for _, bad := range []string{"two", "-1", "1.5"} {
152 fs, u, m, p = parse("--major", bad)
153 if _, err := instancesSetRequest(fs, *u, *m, *p); err == nil || err.Error() != updatewords.Text("settings.cli.bad-major-flag") {
154 t.Fatalf("--major %q: err = %v", bad, err)
155 }
156 }
157}
158
159// Review Focus 1: "nothing changed" is the record's own values not moving,
160// not the effective ones.
161func TestInstancesSetNoChange(t *testing.T) {
162 c := &updatesConsole{getView: viewAuto}
163 out, err := setRun(t, c, map[string]any{"pin": ""}, nil)
164 if err != nil {
165 t.Fatal(err)
166 }
167 if len(c.puts) != 1 || c.puts[0] != `{"pin":""}` {
168 t.Fatalf("PUT bodies = %q", c.puts)
169 }
170 if want := fmt.Sprintf(updatewords.Text("settings.cli.set-unchanged")+"\n", testHost); out != want {
171 t.Fatalf("output = %q, want %q", out, want)
172 }
173}
174
175// A change in a field the effective view does not show (a record's updates
176// value that the app policy already supplies) is still a change.
177func TestInstancesSetExplicitChangeWithSameEffective(t *testing.T) {
178 same := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":""},"explicit":{"updates":"auto","channel":"stable","major":null,"pin":""},"enforced":true}`
179 c := &updatesConsole{getView: viewAuto, putView: same}
180 out, err := setRun(t, c, map[string]any{"updates": "auto"}, nil)
181 if err != nil {
182 t.Fatal(err)
183 }
184 if strings.Contains(out, "nothing changed") {
185 t.Fatalf("output = %q", out)
186 }
187}
188
189func TestInstancesSetMajorComparesByValue(t *testing.T) {
190 a := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":3,"pin":""},"explicit":{"updates":"","channel":"stable","major":3,"pin":""},"enforced":true}`
191 c := &updatesConsole{getView: a}
192 out, err := setRun(t, c, map[string]any{"major": 3}, nil)
193 if err != nil {
194 t.Fatal(err)
195 }
196 if !strings.Contains(out, "nothing changed") {
197 t.Fatalf("a major set to what it was reads as a change: %q", out)
198 }
199}
200
201// Review Focus 2: the settings go first, a gated channel move is not a
202// failure, and it does not undo the settings that were applied.
203func TestInstancesSetChannelProposedStillAppliesPin(t *testing.T) {
204 pinned := `{"host":"a.shop.example.com","settings":{"updates":"auto","channel":"stable","major":null,"pin":"v3"},"explicit":{"updates":"","channel":"stable","major":null,"pin":"v3"},"enforced":true}`
205 c := &updatesConsole{getView: viewAuto, putView: pinned, channelStatus: http.StatusAccepted,
206 channelBody: `{"proposed":true,"needed":2,"console_url":"https://console.example/p/9"}`}
207 beta := "beta"
208 out, err := setRun(t, c, map[string]any{"pin": "v3"}, &beta)
209 if err != nil {
210 t.Fatalf("a proposed move is not a failure: %v", err)
211 }
212 if len(c.puts) != 1 || len(c.channelPosts) != 1 || c.orderStr() != "get,updates,channel" {
213 t.Fatalf("puts %q, channel %q, order %s", c.puts, c.channelPosts, c.orderStr())
214 }
215 if c.channelPosts[0] != `{"channel":"beta"}` {
216 t.Fatalf("channel body = %q", c.channelPosts[0])
217 }
218 if !strings.Contains(out, "is proposed and needs 2 confirmation(s) at https://console.example/p/9") {
219 t.Fatalf("output:\n%s", out)
220 }
221 if !strings.Contains(out, "pin v3") {
222 t.Fatalf("the settings line is missing:\n%s", out)
223 }
224}
225
226// --channel alone is the channel door and nothing else.
227func TestInstancesSetChannelOnly(t *testing.T) {
228 c := &updatesConsole{}
229 beta := "beta"
230 out, err := setRun(t, c, nil, &beta)
231 if err != nil {
232 t.Fatal(err)
233 }
234 if c.orderStr() != "channel" {
235 t.Fatalf("order = %s", c.orderStr())
236 }
237 if !strings.Contains(out, "moved a.shop.example.com to beta") {
238 t.Fatalf("output:\n%s", out)
239 }
240}
241
242// A refused settings write stops the run: the channel door is not tried.
243func TestInstancesSetPrintsTheConsolesRefusal(t *testing.T) {
244 c := &updatesConsole{getView: viewAuto, putStatus: http.StatusConflict,
245 putBody: `{"error":"v9 cannot be pinned: no promotion record."}`}
246 beta := "beta"
247 _, err := setRun(t, c, map[string]any{"pin": "v9"}, &beta)
248 if err == nil || !strings.Contains(err.Error(), "v9 cannot be pinned: no promotion record.") {
249 t.Fatalf("err = %v", err)
250 }
251 if len(c.channelPosts) != 0 {
252 t.Fatalf("the channel door was called after a refused write: %q", c.channelPosts)
253 }
254}
255
256// A refused channel move is the console's words too, after the settings line.
257func TestInstancesSetChannelRefusalAfterSettings(t *testing.T) {
258 c := &updatesConsole{getView: viewAuto, putView: viewManual, channelStatus: http.StatusConflict,
259 channelBody: `{"error":"no such channel"}`}
260 beta := "beta"
261 out, err := setRun(t, c, map[string]any{"updates": "manual"}, &beta)
262 if err == nil || !strings.Contains(err.Error(), "no such channel") {
263 t.Fatalf("err = %v", err)
264 }
265 if !strings.Contains(out, "updates manual") {
266 t.Fatalf("the settings line is missing:\n%s", out)
267 }
268}
269
270// R8: the "not every box applies" line is for settings that need enforcing.
271func TestInstancesSetNotEnforcedOnlyForNonDefault(t *testing.T) {
272 notEnforced := func(view string) string {
273 return strings.Replace(view, `"enforced":true`, `"enforced":false`, 1)
274 }
275 line := fmt.Sprintf(updatewords.Text("settings.cli.set-not-enforced"), testHost)
276 cases := []struct {
277 name string
278 view string
279 want bool
280 }{
281 {"manual", notEnforced(viewManual), true},
282 {"auto and stable", notEnforced(viewAuto), false},
283 {"a major", strings.Replace(notEnforced(viewAuto), `"major":null`, `"major":2`, 1), true},
284 {"a pin", strings.Replace(notEnforced(viewAuto), `"pin":""`, `"pin":"v3"`, 1), true},
285 {"enforced", viewManual, false},
286 }
287 for _, tc := range cases {
288 t.Run(tc.name, func(t *testing.T) {
289 c := &updatesConsole{getView: viewAuto, putView: tc.view}
290 out, err := setRun(t, c, map[string]any{"updates": "manual"}, nil)
291 if err != nil {
292 t.Fatal(err)
293 }
294 if got := strings.Contains(out, line); got != tc.want {
295 t.Fatalf("not-enforced line present = %v, want %v:\n%s", got, tc.want, out)
296 }
297 })
298 }
299 // It only follows settings that were sent.
300 c := &updatesConsole{}
301 beta := "beta"
302 out, err := setRun(t, c, nil, &beta)
303 if err != nil || strings.Contains(out, line) {
304 t.Fatalf("channel only: err %v, output:\n%s", err, out)
305 }
306}
307
308func TestInstancesUpdatePrintsTheRequest(t *testing.T) {
309 c := &updatesConsole{}
310 out := captureStdout(t)
311 err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, true)
312 if err != nil {
313 t.Fatal(err)
314 }
315 if len(c.installs) != 1 || c.installs[0] != `{"fast_track":true}` {
316 t.Fatalf("installs %q", c.installs)
317 }
318 if want := fmt.Sprintf(updatewords.Text("settings.cli.update-done")+"\n", testHost, 4); out.String() != want {
319 t.Fatalf("output = %q, want %q", out.String(), want)
320 }
321 c2 := &updatesConsole{}
322 if err := instancesUpdateRun(context.Background(), c2.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false); err != nil {
323 t.Fatal(err)
324 }
325 if c2.installs[0] != `{"fast_track":false}` {
326 t.Fatalf("installs %q", c2.installs)
327 }
328}
329
330func TestInstancesUpdateRefusalsAreTheConsolesWords(t *testing.T) {
331 for _, tc := range []struct {
332 status int
333 body string
334 want string
335 }{
336 {http.StatusConflict, `{"error":"a.shop.example.com is pinned to v3."}`, "a.shop.example.com is pinned to v3."},
337 {http.StatusForbidden, `{"error":"only an owner can fast track."}`, "only an owner can fast track."},
338 } {
339 c := &updatesConsole{installStatus: tc.status, installBody: tc.body}
340 out := captureStdout(t)
341 err := instancesUpdateRun(context.Background(), c.session(t), apiAcct{Seg: "bab"}, "shop", testHost, false)
342 if err == nil || !strings.Contains(err.Error(), tc.want) {
343 t.Fatalf("status %d: err = %v", tc.status, err)
344 }
345 if out.Len() != 0 {
346 t.Fatalf("status %d: printed %q", tc.status, out.String())
347 }
348 }
349}
350
351// The verbs that end in log.Fatal run as the real binary.
352func TestInstancesSetAndUpdateRefuseDirectMode(t *testing.T) {
353 store := t.TempDir()
354 for _, tc := range []struct {
355 verb string
356 args []string
357 }{
358 {"set", []string{"instances", "set", testHost, "--updates", "manual"}},
359 {"update", []string{"instances", "update", testHost}},
360 } {
361 bin := buildCarlos(t)
362 out, err := runCarlosEnv(t, bin, tc.args, "CARLOS_DEPLOYMENT_DIR="+store)
363 if err == nil {
364 t.Fatalf("%s exited 0 in direct mode:\n%s", tc.verb, out)
365 }
366 want := fmt.Sprintf(updatewords.Text("settings.cli.direct-mode"), tc.verb)
367 if !strings.Contains(out, want) {
368 t.Fatalf("%s: output\n%s\nwant %q", tc.verb, out, want)
369 }
370 }
371}
372
373func TestInstancesSetRefusesWhatItCannotSendBeforeAnyConsole(t *testing.T) {
374 bin := buildCarlos(t)
375 for _, args := range [][]string{
376 {"instances", "set", testHost}, // nothing to set
377 {"instances", "set", testHost, "--channel", ""}, // a channel cannot be cleared
378 {"instances", "set", "--updates", "manual"}, // no host
379 {"instances", "update"}, // no host
380 } {
381 out, err := runCarlosEnv(t, bin, args)
382 if err == nil {
383 t.Fatalf("%v exited 0:\n%s", args, out)
384 }
385 usage := updatewords.Text("settings.cli.set-usage")
386 if args[1] == "update" {
387 usage = updatewords.Text("settings.cli.update-usage")
388 }
389 if !strings.Contains(out, usage) {
390 t.Fatalf("%v: output\n%s\nwant the usage %q", args, out, usage)
391 }
392 }
393 out, err := runCarlosEnv(t, bin, []string{"instances", "set", testHost, "--major", "two"})
394 if err == nil || !strings.Contains(out, updatewords.Text("settings.cli.bad-major-flag")) {
395 t.Fatalf("bad major: err %v, output\n%s", err, out)
396 }
397}
398
399func TestInstancesUsageNamesTheNewVerbs(t *testing.T) {
400 for _, id := range []string{"settings.cli.instances-usage-lines", "settings.cli.instances-usage-para"} {
401 if !strings.Contains(instancesRecordsUsage, updatewords.Text(id)) {
402 t.Fatalf("the instances usage lacks %s", id)
403 }
404 }
405 for _, line := range []string{"carlos instances retry --app <a> <host>\n", "does the same on every box.\n"} {
406 if !strings.Contains(instancesRecordsUsage, line) {
407 t.Fatalf("the instances usage lost %q", line)
408 }
409 }
410}
411
412// runCarlosEnv runs the built binary with the test environment (no console,
413// scratch credentials) plus extra variables.
414func runCarlosEnv(t *testing.T, bin string, args []string, extra ...string) (string, error) {
415 t.Helper()
416 cmd := exec.Command(bin, args...)
417 cmd.Env = carlosTestEnv(t, extra...)
418 cmd.Dir = t.TempDir()
419 out, err := cmd.CombinedOutput()
420 return string(out), err
421}
422
423// Astra r1 (R21): both verbs' help is the reviewed copy, with no em dash.
424// --account-id reads the shared draft (apps_swap.go keeps its pinned
425// literal).
426func TestInstancesSetAndUpdateHelpHasNoEmDash(t *testing.T) {
427 bin := buildCarlos(t)
428 for _, verb := range []string{"set", "update"} {
429 out, _ := runCarlosEnv(t, bin, []string{"instances", verb, "-h"})
430 if !strings.Contains(out, updatewords.Text("settings.cli.flag-account-id")) {
431 t.Errorf("instances %s -h lacks the --account-id draft:\n%s", verb, out)
432 }
433 if strings.ContainsRune(out, '\u2014') {
434 t.Errorf("instances %s -h has an em dash:\n%s", verb, out)
435 }
436 }
437}
438