| 1 | package main |
| 2 | |
| 3 | import ( |
| 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. |
| 22 | type 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 | |
| 37 | func (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 | |
| 91 | func (c *updatesConsole) orderStr() string { return strings.Join(c.order, ",") } |
| 92 | |
| 93 | const ( |
| 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 | |
| 101 | func 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 | |
| 108 | func 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 | |
| 126 | func 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. |
| 161 | func 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. |
| 177 | func 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 | |
| 189 | func 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. |
| 203 | func 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. |
| 227 | func 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. |
| 243 | func 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. |
| 257 | func 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. |
| 271 | func 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 | |
| 308 | func 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 | |
| 330 | func 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. |
| 352 | func 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 | |
| 373 | func 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 | |
| 399 | func 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. |
| 414 | func 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). |
| 426 | func 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 | |