| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "net/url" |
| 13 | "os" |
| 14 | "path/filepath" |
| 15 | "strings" |
| 16 | "sync" |
| 17 | "sync/atomic" |
| 18 | "syscall" |
| 19 | "testing" |
| 20 | "time" |
| 21 | ) |
| 22 | |
| 23 | func TestImportVerifyRetriesThroughTheWake(t *testing.T) { |
| 24 | var calls atomic.Int32 |
| 25 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 26 | if r.Header.Get("Carlos-Import-Verify") != "tok" { |
| 27 | w.WriteHeader(http.StatusForbidden) |
| 28 | return |
| 29 | } |
| 30 | if calls.Add(1) < 3 { |
| 31 | w.Header().Set("Retry-After", "0") |
| 32 | w.WriteHeader(http.StatusServiceUnavailable) |
| 33 | return |
| 34 | } |
| 35 | w.WriteHeader(http.StatusNoContent) |
| 36 | })) |
| 37 | defer ts.Close() |
| 38 | code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok") |
| 39 | if err != nil || code != http.StatusNoContent || calls.Load() != 3 { |
| 40 | t.Fatalf("code %d err %v calls %d", code, err, calls.Load()) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestImportVerifyStopsOnAFailure(t *testing.T) { |
| 45 | var calls atomic.Int32 |
| 46 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 47 | calls.Add(1) |
| 48 | w.WriteHeader(http.StatusInternalServerError) |
| 49 | })) |
| 50 | defer ts.Close() |
| 51 | code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok") |
| 52 | if err != nil || code != http.StatusInternalServerError || calls.Load() != 1 { |
| 53 | t.Fatalf("code %d err %v calls %d", code, err, calls.Load()) |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // fakeSeedWait replaces the poll seam for one test: no real sleeps, and |
| 58 | // every wait is recorded. |
| 59 | func fakeSeedWait(t *testing.T, every time.Duration) *[]time.Duration { |
| 60 | t.Helper() |
| 61 | var mu sync.Mutex |
| 62 | waits := &[]time.Duration{} |
| 63 | oldWait, oldEvery := seedWait, seedPollEvery |
| 64 | seedWait = func(ctx context.Context, d time.Duration) error { |
| 65 | mu.Lock() |
| 66 | *waits = append(*waits, d) |
| 67 | mu.Unlock() |
| 68 | return ctx.Err() |
| 69 | } |
| 70 | seedPollEvery = every |
| 71 | t.Cleanup(func() { seedWait, seedPollEvery = oldWait, oldEvery }) |
| 72 | return waits |
| 73 | } |
| 74 | |
| 75 | // A gateway answer (502, 504) while the instance wakes is retried as a 503 |
| 76 | // is, honouring Retry-After (M16). |
| 77 | func TestImportVerifyRetriesGatewayAnswers(t *testing.T) { |
| 78 | waits := fakeSeedWait(t, 2*time.Second) |
| 79 | var calls atomic.Int32 |
| 80 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 81 | switch calls.Add(1) { |
| 82 | case 1: |
| 83 | w.WriteHeader(http.StatusBadGateway) |
| 84 | case 2: |
| 85 | w.Header().Set("Retry-After", "7") |
| 86 | w.WriteHeader(http.StatusGatewayTimeout) |
| 87 | default: |
| 88 | w.WriteHeader(http.StatusOK) |
| 89 | } |
| 90 | })) |
| 91 | defer ts.Close() |
| 92 | code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok") |
| 93 | if err != nil || code != http.StatusOK || calls.Load() != 3 { |
| 94 | t.Fatalf("code %d err %v calls %d", code, err, calls.Load()) |
| 95 | } |
| 96 | if len(*waits) != 2 || (*waits)[0] != 3*time.Second || (*waits)[1] != 7*time.Second { |
| 97 | t.Fatalf("waits = %v, want 3s (no Retry-After) then 7s", *waits) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestImportVerifyGivesUpAfterItsTries(t *testing.T) { |
| 102 | fakeSeedWait(t, 2*time.Second) |
| 103 | var calls atomic.Int32 |
| 104 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 105 | calls.Add(1) |
| 106 | w.WriteHeader(http.StatusServiceUnavailable) |
| 107 | })) |
| 108 | defer ts.Close() |
| 109 | code, err := verifyImportURL(context.Background(), ts.Client(), ts.URL+"/up", "tok") |
| 110 | if err != nil || code != http.StatusServiceUnavailable || calls.Load() != importVerifyTries+1 { |
| 111 | t.Fatalf("code %d err %v calls %d", code, err, calls.Load()) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // A redirect is the verify's answer, never followed: /up answering 302 to a |
| 116 | // login page that says 200 must not commit (M16). |
| 117 | func TestImportVerifyDoesNotFollowARedirect(t *testing.T) { |
| 118 | var landed atomic.Bool |
| 119 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 120 | if r.URL.Path == "/login" { |
| 121 | landed.Store(true) |
| 122 | w.WriteHeader(http.StatusOK) |
| 123 | return |
| 124 | } |
| 125 | http.Redirect(w, r, "/login", http.StatusFound) |
| 126 | })) |
| 127 | defer ts.Close() |
| 128 | client := ts.Client() // follows redirects by default |
| 129 | code, err := verifyImportURL(context.Background(), client, ts.URL+"/up", "tok") |
| 130 | if err != nil || code != http.StatusFound || landed.Load() { |
| 131 | t.Fatalf("code %d err %v followed %v", code, err, landed.Load()) |
| 132 | } |
| 133 | if client.CheckRedirect != nil { |
| 134 | t.Fatal("verifyImportURL changed the caller's client") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestImportVerifyBuildsTheHostURL(t *testing.T) { |
| 139 | var path, tok string |
| 140 | ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 141 | path, tok = r.URL.Path, r.Header.Get("Carlos-Import-Verify") |
| 142 | w.WriteHeader(http.StatusOK) |
| 143 | })) |
| 144 | defer ts.Close() |
| 145 | host := strings.TrimPrefix(ts.URL, "https://") |
| 146 | code, err := verifyImport(context.Background(), ts.Client(), host, "up", "tok") |
| 147 | if err != nil || code != http.StatusOK || path != "/up" || tok != "tok" { |
| 148 | t.Fatalf("code %d err %v path %q token %q", code, err, path, tok) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestImportClientCallsTheDoor(t *testing.T) { |
| 153 | var seen []string |
| 154 | var begin map[string]any |
| 155 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 156 | seen = append(seen, r.Method+" "+r.URL.RequestURI()) |
| 157 | switch { |
| 158 | case r.Method == http.MethodPost && r.URL.Path == "/api/cli/apps/bab/shop/instances": |
| 159 | raw, _ := io.ReadAll(r.Body) |
| 160 | _ = json.Unmarshal(raw, &begin) |
| 161 | w.WriteHeader(http.StatusCreated) |
| 162 | w.Write([]byte(`{"host":"old.shop.example.com","verify_token":"tok","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}`)) |
| 163 | default: |
| 164 | w.WriteHeader(http.StatusOK) |
| 165 | w.Write([]byte(`{"state":"ok"}`)) |
| 166 | } |
| 167 | })) |
| 168 | defer ts.Close() |
| 169 | s := newTestSession(ts.URL) |
| 170 | a := apiAcct{Seg: "bab"} |
| 171 | rec, tok, err := s.importBegin(context.Background(), a, "shop", "old.shop.example.com", "staging", "aa", 3, "127.0.0.1:8401", "moving the shop") |
| 172 | if err != nil || tok != "tok" || rec.Seed == nil || rec.Seed.ID != "sd_imp" { |
| 173 | t.Fatalf("begin: %q %+v %v", tok, rec, err) |
| 174 | } |
| 175 | imp, _ := begin["import"].(map[string]any) |
| 176 | if begin["host"] != "old.shop.example.com" || begin["environment"] != "staging" || begin["message"] != "moving the shop" || |
| 177 | imp["sha256"] != "aa" || imp["bytes"] != float64(3) || imp["replaces"] != "127.0.0.1:8401" { |
| 178 | t.Fatalf("begin body = %v", begin) |
| 179 | } |
| 180 | for _, verb := range []string{"stopped", "commit", "abort"} { |
| 181 | if err := s.importVerb(context.Background(), a, "shop", "old.shop.example.com", "sd_imp", verb); err != nil { |
| 182 | t.Fatalf("%s: %v", verb, err) |
| 183 | } |
| 184 | } |
| 185 | // Every verb names the import it means (final review F1). |
| 186 | want := []string{"POST /api/cli/apps/bab/shop/instances", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/stopped?seed=sd_imp", |
| 187 | "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/commit?seed=sd_imp", "POST /api/cli/apps/bab/shop/instances/old.shop.example.com/import/abort?seed=sd_imp"} |
| 188 | if len(seen) != len(want) { |
| 189 | t.Fatalf("calls = %q", seen) |
| 190 | } |
| 191 | for i := range want { |
| 192 | if seen[i] != want[i] { |
| 193 | t.Fatalf("call %d = %q, want %q", i, seen[i], want[i]) |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestImportBeginRefusalAndUnreadableAnswer(t *testing.T) { |
| 199 | for _, c := range []struct { |
| 200 | status int |
| 201 | body string |
| 202 | wantErr string |
| 203 | }{ |
| 204 | {http.StatusBadRequest, `{"error":"the sha256 is not 64 hex characters"}`, "the sha256 is not 64 hex characters"}, |
| 205 | {http.StatusCreated, `{"host":"old.shop.example.com"}`, "instances import: unreadable response"}, |
| 206 | {http.StatusCreated, `not json`, "instances import: unreadable response"}, |
| 207 | } { |
| 208 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 209 | w.WriteHeader(c.status) |
| 210 | io.WriteString(w, c.body) |
| 211 | })) |
| 212 | _, tok, err := newTestSession(ts.URL).importBegin(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "", "aa", 3, "127.0.0.1:8401") |
| 213 | ts.Close() |
| 214 | if err == nil || !strings.HasPrefix(err.Error(), c.wantErr) || tok != "" { |
| 215 | t.Errorf("%d %s: token %q err %v", c.status, c.body, tok, err) |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // Begin can provision a private store (up to about 75 s), so it must not |
| 221 | // ride the session's 30 s JSON client (M4). The session's client is shrunk |
| 222 | // below the server's delay to show begin does not use it, and is left as |
| 223 | // it was. |
| 224 | func TestImportBeginOutlastsTheJSONClient(t *testing.T) { |
| 225 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 226 | time.Sleep(150 * time.Millisecond) |
| 227 | w.WriteHeader(http.StatusCreated) |
| 228 | io.WriteString(w, `{"host":"old.shop.example.com","verify_token":"tok"}`) |
| 229 | })) |
| 230 | defer ts.Close() |
| 231 | s := newTestSession(ts.URL) |
| 232 | s.c.httpc.Timeout = 20 * time.Millisecond |
| 233 | if _, tok, err := s.importBegin(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "", "aa", 3, "127.0.0.1:8401"); err != nil || tok != "tok" { |
| 234 | t.Fatalf("token %q err %v", tok, err) |
| 235 | } |
| 236 | if s.c.httpc.Timeout != 20*time.Millisecond { |
| 237 | t.Fatalf("the session's client timeout became %v", s.c.httpc.Timeout) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func snapshotFile(t *testing.T, body string) *os.File { |
| 242 | t.Helper() |
| 243 | p := filepath.Join(t.TempDir(), "main.db") |
| 244 | if err := os.WriteFile(p, []byte(body), 0o600); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | f, err := os.Open(p) |
| 248 | if err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | t.Cleanup(func() { f.Close() }) |
| 252 | return f |
| 253 | } |
| 254 | |
| 255 | func TestImportUploadStreamsTheFile(t *testing.T) { |
| 256 | var got struct { |
| 257 | method, path, seed, expect, ctype string |
| 258 | length int64 |
| 259 | body string |
| 260 | } |
| 261 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 262 | raw, _ := io.ReadAll(r.Body) |
| 263 | got.method, got.path, got.seed, got.expect, got.ctype = r.Method, r.URL.Path, r.URL.Query().Get("seed"), r.Header.Get("Expect"), r.Header.Get("Content-Type") |
| 264 | got.length, got.body = r.ContentLength, string(raw) |
| 265 | time.Sleep(150 * time.Millisecond) |
| 266 | w.WriteHeader(http.StatusAccepted) |
| 267 | io.WriteString(w, `{"state":"checking"}`) |
| 268 | })) |
| 269 | defer ts.Close() |
| 270 | s := newTestSession(ts.URL) |
| 271 | s.c.httpc.Timeout = 20 * time.Millisecond // the upload never rides the JSON client |
| 272 | if err := s.importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "sd_imp", snapshotFile(t, "abc"), 3); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | if got.method != http.MethodPut || got.path != "/api/cli/apps/bab/shop/instances/old.shop.example.com/import" || got.seed != "sd_imp" || |
| 276 | got.expect != "100-continue" || got.ctype != "application/octet-stream" || got.length != 3 || got.body != "abc" { |
| 277 | t.Fatalf("request = %+v", got) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func TestImportUploadRefusal(t *testing.T) { |
| 282 | const sdN51 = "the upload does not match the length and sha256 this import began with, so it was not kept." |
| 283 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 284 | io.Copy(io.Discard, r.Body) |
| 285 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 286 | fmt.Fprintf(w, `{"error":%q}`, sdN51) |
| 287 | })) |
| 288 | defer ts.Close() |
| 289 | err := newTestSession(ts.URL).importUpload(context.Background(), apiAcct{Seg: "bab"}, "shop", "old.shop.example.com", "sd_imp", snapshotFile(t, "abc"), 3) |
| 290 | if err == nil || err.Error() != sdN51 { |
| 291 | t.Fatalf("err = %v", err) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // importConsole is a console for one import: begin, upload and the verbs |
| 296 | // answer as configured, and the record walks through gets, one per look |
| 297 | // (the last one repeats). |
| 298 | type importConsole struct { |
| 299 | uploadHold func(r *http.Request) // runs before the upload answers, outside mu |
| 300 | mu sync.Mutex |
| 301 | calls []string |
| 302 | beginStatus int |
| 303 | beginBody string |
| 304 | upload func(w http.ResponseWriter) |
| 305 | verbs map[string]func(w http.ResponseWriter) |
| 306 | gets []func(w http.ResponseWriter) |
| 307 | looks int |
| 308 | seeds []string // the seed each upload and verb named, in order |
| 309 | } |
| 310 | |
| 311 | const importBeginBody = `{"host":"main.shop.example.com","verify_token":"s3cret-token","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}` |
| 312 | |
| 313 | func newImportConsole(gets ...func(w http.ResponseWriter)) *importConsole { |
| 314 | return &importConsole{beginStatus: http.StatusCreated, beginBody: importBeginBody, gets: gets, verbs: map[string]func(http.ResponseWriter){}} |
| 315 | } |
| 316 | |
| 317 | func (c *importConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 318 | if r.Method == http.MethodPut && c.uploadHold != nil { |
| 319 | io.Copy(io.Discard, r.Body) |
| 320 | c.uploadHold(r) |
| 321 | } |
| 322 | c.mu.Lock() |
| 323 | defer c.mu.Unlock() |
| 324 | io.Copy(io.Discard, r.Body) |
| 325 | path := r.URL.Path |
| 326 | if i := strings.Index(path, "/instances"); i >= 0 { |
| 327 | path = path[i:] |
| 328 | } |
| 329 | switch { |
| 330 | case r.Method == http.MethodPost && path == "/instances": |
| 331 | c.calls = append(c.calls, "begin") |
| 332 | w.WriteHeader(c.beginStatus) |
| 333 | io.WriteString(w, c.beginBody) |
| 334 | case r.Method == http.MethodPut: |
| 335 | c.calls = append(c.calls, "upload") |
| 336 | c.seeds = append(c.seeds, r.URL.Query().Get("seed")) |
| 337 | if c.upload != nil { |
| 338 | c.upload(w) |
| 339 | return |
| 340 | } |
| 341 | w.WriteHeader(http.StatusAccepted) |
| 342 | io.WriteString(w, `{"state":"checking"}`) |
| 343 | case r.Method == http.MethodPost: |
| 344 | verb := path[strings.LastIndex(path, "/")+1:] |
| 345 | c.calls = append(c.calls, verb) |
| 346 | c.seeds = append(c.seeds, r.URL.Query().Get("seed")) |
| 347 | if f := c.verbs[verb]; f != nil { |
| 348 | f(w) |
| 349 | return |
| 350 | } |
| 351 | fmt.Fprintf(w, `{"state":%q}`, verb) |
| 352 | case r.Method == http.MethodGet: |
| 353 | c.calls = append(c.calls, "get") |
| 354 | i := c.looks |
| 355 | if c.looks++; i >= len(c.gets) { |
| 356 | i = len(c.gets) - 1 |
| 357 | } |
| 358 | c.gets[i](w) |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | func (c *importConsole) called() []string { |
| 363 | c.mu.Lock() |
| 364 | defer c.mu.Unlock() |
| 365 | return append([]string(nil), c.calls...) |
| 366 | } |
| 367 | |
| 368 | // called reports whether the console saw verb. |
| 369 | func (c *importConsole) saw(verb string) bool { |
| 370 | for _, v := range c.called() { |
| 371 | if v == verb { |
| 372 | return true |
| 373 | } |
| 374 | } |
| 375 | return false |
| 376 | } |
| 377 | |
| 378 | func importRecord(state, reason string) func(w http.ResponseWriter) { |
| 379 | return importRecordID("sd_imp", state, reason) |
| 380 | } |
| 381 | |
| 382 | func importRecordID(id, state, reason string) func(w http.ResponseWriter) { |
| 383 | return func(w http.ResponseWriter) { |
| 384 | fmt.Fprintf(w, `{"host":"main.shop.example.com","seed":{"id":%q,"kind":"import","sha256":"ab","bytes":3,"state":%q,"reason":%q}}`, id, state, reason) |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // importRun runs an import of a three-byte file against c, with verify |
| 389 | // (when not empty) answered by verifyStatus through a TLS server standing |
| 390 | // in for the instance's host. It returns the output and the error. |
| 391 | func importRun(t *testing.T, c *importConsole, verify string, verifyStatus ...int) (string, error) { |
| 392 | t.Helper() |
| 393 | return importRunCtx(t, context.Background(), c, verify, verifyStatus...) |
| 394 | } |
| 395 | |
| 396 | func importRunCtx(t *testing.T, ctx context.Context, c *importConsole, verify string, verifyStatus ...int) (string, error) { |
| 397 | t.Helper() |
| 398 | ts := httptest.NewServer(c) |
| 399 | defer ts.Close() |
| 400 | j := importJob{acct: apiAcct{Seg: "bab"}, app: "shop", host: "main.shop.example.com", file: filepath.Join(t.TempDir(), "main.db"), |
| 401 | replaces: "127.0.0.1:8401", verify: verify} |
| 402 | if err := os.WriteFile(j.file, []byte("abc"), 0o600); err != nil { |
| 403 | t.Fatal(err) |
| 404 | } |
| 405 | if verify != "" { |
| 406 | var n atomic.Int32 |
| 407 | vs := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 408 | if r.Header.Get("Carlos-Import-Verify") != "s3cret-token" { |
| 409 | w.WriteHeader(http.StatusForbidden) |
| 410 | return |
| 411 | } |
| 412 | i := int(n.Add(1)) - 1 |
| 413 | if i >= len(verifyStatus) { |
| 414 | i = len(verifyStatus) - 1 |
| 415 | } |
| 416 | w.WriteHeader(verifyStatus[i]) |
| 417 | })) |
| 418 | defer vs.Close() |
| 419 | j.host, j.verifyClient = strings.TrimPrefix(vs.URL, "https://"), vs.Client() |
| 420 | } |
| 421 | var out bytes.Buffer |
| 422 | err := newTestSession(ts.URL).runImport(ctx, &out, j) |
| 423 | // The output names the file and the host as the tests know them. |
| 424 | return strings.NewReplacer(j.file, "main.db", j.host, "main.shop.example.com").Replace(out.String()), err |
| 425 | } |
| 426 | |
| 427 | func wantExactLines(t *testing.T, out string, want ...string) { |
| 428 | t.Helper() |
| 429 | got := strings.Split(strings.TrimSuffix(out, "\n"), "\n") |
| 430 | if len(got) != len(want) { |
| 431 | t.Fatalf("output:\n%s\nwant:\n%s", out, strings.Join(want, "\n")) |
| 432 | } |
| 433 | for i := range want { |
| 434 | if got[i] != want[i] { |
| 435 | t.Fatalf("line %d = %q, want %q\noutput:\n%s", i, got[i], want[i], out) |
| 436 | } |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | const ( |
| 441 | importCommitLine = "commit with: carlos instances import commit --app shop --host main.shop.example.com. Abort with: carlos instances import abort --app shop --host main.shop.example.com." |
| 442 | importTokenLine = "verify token: s3cret-token. It is shown only this once." |
| 443 | ) |
| 444 | |
| 445 | func TestImportWithoutVerifyPrintsTheTokenAndTheCommands(t *testing.T) { |
| 446 | waits := fakeSeedWait(t, 2*time.Second) |
| 447 | c := newImportConsole(importRecord("awaiting-upload", ""), importRecord("uploaded", ""), |
| 448 | importRecord("ready", ""), importRecord("swapped", "")) |
| 449 | out, err := importRun(t, c, "") |
| 450 | if err != nil { |
| 451 | t.Fatal(err) |
| 452 | } |
| 453 | wantExactLines(t, out, |
| 454 | "uploading main.db (3B)", |
| 455 | importTokenLine, |
| 456 | "uploaded. The old process of main.shop.example.com is marked stopped, and the box swaps it on its next pass.", |
| 457 | "waiting for the box to swap main.shop.example.com", |
| 458 | "swapped. Until it is committed, main.shop.example.com answers only requests that carry the verify token.", |
| 459 | importCommitLine) |
| 460 | if got := strings.Join(c.called(), " "); got != "begin upload get get stopped get get" { |
| 461 | t.Fatalf("calls = %s", got) |
| 462 | } |
| 463 | for _, d := range *waits { |
| 464 | if d != 2*time.Second { |
| 465 | t.Fatalf("waits = %v", *waits) |
| 466 | } |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | func TestImportWithVerifyCommits(t *testing.T) { |
| 471 | fakeSeedWait(t, 2*time.Second) |
| 472 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), importRecord("swapped", ""), importRecord("placed", "")) |
| 473 | out, err := importRun(t, c, "/up", http.StatusServiceUnavailable, http.StatusNoContent) |
| 474 | if err != nil { |
| 475 | t.Fatal(err) |
| 476 | } |
| 477 | if strings.Contains(out, "s3cret-token") { |
| 478 | t.Fatalf("a verified import never shows the token:\n%s", out) |
| 479 | } |
| 480 | lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") |
| 481 | wantExactLines(t, strings.Join(lines[len(lines)-3:], "\n"), |
| 482 | "verify passed (204). Committing.", |
| 483 | "waiting for the box to finish placing the snapshot", |
| 484 | "committed. main.shop.example.com is served by the platform now.") |
| 485 | if !c.saw("commit") || c.saw("abort") { |
| 486 | t.Fatalf("calls = %v", c.called()) |
| 487 | } |
| 488 | c.requireSeeds(t, "sd_imp", 3) // upload, stopped, commit |
| 489 | } |
| 490 | |
| 491 | // requireSeeds: the console saw n uploads and verbs, and each named the |
| 492 | // seed begin answered (final review F1). |
| 493 | func (c *importConsole) requireSeeds(t *testing.T, id string, n int) { |
| 494 | t.Helper() |
| 495 | c.mu.Lock() |
| 496 | defer c.mu.Unlock() |
| 497 | if len(c.seeds) != n { |
| 498 | t.Fatalf("seeds named = %q, want %d of %q", c.seeds, n, id) |
| 499 | } |
| 500 | for _, got := range c.seeds { |
| 501 | if got != id { |
| 502 | t.Fatalf("seeds named = %q, want every one %q", c.seeds, id) |
| 503 | } |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | func TestImportWithVerifyAbortsOnAFailure(t *testing.T) { |
| 508 | fakeSeedWait(t, 2*time.Second) |
| 509 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", "")) |
| 510 | out, err := importRun(t, c, "/up", http.StatusInternalServerError) |
| 511 | if !errors.Is(err, errImportFailed) { |
| 512 | t.Fatalf("err = %v", err) |
| 513 | } |
| 514 | lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n") |
| 515 | wantExactLines(t, strings.Join(lines[len(lines)-2:], "\n"), |
| 516 | "verify failed (500). Aborting the import.", |
| 517 | "aborted. The box puts the old route of main.shop.example.com back on its next pass.") |
| 518 | c.requireSeeds(t, "sd_imp", 3) // upload, stopped, abort |
| 519 | if strings.Contains(out, "s3cret-token") || !c.saw("abort") || c.saw("commit") { |
| 520 | t.Fatalf("calls = %v, output:\n%s", c.called(), out) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | func TestImportRefusedByTheBox(t *testing.T) { |
| 525 | fakeSeedWait(t, 2*time.Second) |
| 526 | c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("refused", "the route belongs to app kass")) |
| 527 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 528 | if !errors.Is(err, errImportFailed) { |
| 529 | t.Fatalf("err = %v", err) |
| 530 | } |
| 531 | if !strings.HasSuffix(out, "waiting for the box to swap "+hostOf(out)+"\nthe box refused the import: the route belongs to app kass\n") { |
| 532 | t.Fatalf("output:\n%s", out) |
| 533 | } |
| 534 | if strings.Contains(out, "s3cret-token") || c.saw("commit") || c.saw("abort") { |
| 535 | t.Fatalf("calls = %v, output:\n%s", c.called(), out) |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | // hostOf reads the host back out of the "waiting for the box to swap" line, |
| 540 | // for the tests whose host is a TLS server's address. |
| 541 | func hostOf(out string) string { |
| 542 | const p = "waiting for the box to swap " |
| 543 | i := strings.Index(out, p) |
| 544 | if i < 0 { |
| 545 | return "" |
| 546 | } |
| 547 | rest := out[i+len(p):] |
| 548 | return rest[:strings.Index(rest, "\n")] |
| 549 | } |
| 550 | |
| 551 | // The swap wait is ten minutes of looks; at its end the token is shown |
| 552 | // (with --verify) and SD-N79 names the way on (M17). |
| 553 | func TestImportSwapDeadline(t *testing.T) { |
| 554 | waits := fakeSeedWait(t, time.Minute) |
| 555 | c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", "")) |
| 556 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 557 | if !errors.Is(err, errImportFailed) { |
| 558 | t.Fatalf("err = %v", err) |
| 559 | } |
| 560 | host := hostOf(out) |
| 561 | if !strings.HasSuffix(out, "waiting for the box to swap "+host+"\n"+importTokenLine+"\n"+ |
| 562 | "the box has not swapped "+host+" after 10 minutes. Check carlos instances list, then commit or abort.\n") { |
| 563 | t.Fatalf("output:\n%s", out) |
| 564 | } |
| 565 | // One wait before the upload's look, then ten minutes of one-minute waits. |
| 566 | if len(*waits) != 11 { |
| 567 | t.Fatalf("waits = %v", *waits) |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | func TestImportPlaceDeadline(t *testing.T) { |
| 572 | fakeSeedWait(t, time.Minute) |
| 573 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", "")) |
| 574 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 575 | if !errors.Is(err, errImportFailed) { |
| 576 | t.Fatalf("err = %v", err) |
| 577 | } |
| 578 | host := hostOf(out) |
| 579 | if !strings.HasSuffix(out, "waiting for the box to finish placing the snapshot\n"+importTokenLine+"\n"+ |
| 580 | "commit with: carlos instances import commit --app shop --host "+host+". Abort with: carlos instances import abort --app shop --host "+host+".\n") { |
| 581 | t.Fatalf("output:\n%s", out) |
| 582 | } |
| 583 | if c.saw("commit") || c.saw("abort") { |
| 584 | t.Fatalf("calls = %v", c.called()) |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | func TestImportAbortedPrintsItsReason(t *testing.T) { |
| 589 | const sdN52 = "the upload is not a database that passes an integrity check, so it was not kept." |
| 590 | for _, c := range []struct { |
| 591 | name, reason, want string |
| 592 | }{ |
| 593 | {"with a reason", sdN52, sdN52}, |
| 594 | {"without one", "", "aborting"}, |
| 595 | } { |
| 596 | t.Run(c.name, func(t *testing.T) { |
| 597 | fakeSeedWait(t, 2*time.Second) |
| 598 | ic := newImportConsole(importRecord("awaiting-upload", ""), importRecord("aborted", c.reason)) |
| 599 | out, err := importRun(t, ic, "/up", http.StatusOK) |
| 600 | if !errors.Is(err, errImportFailed) { |
| 601 | t.Fatalf("err = %v", err) |
| 602 | } |
| 603 | if !strings.HasSuffix(out, "\n"+c.want+"\n") || strings.Contains(out, "s3cret-token") || ic.saw("stopped") { |
| 604 | t.Fatalf("calls = %v, output:\n%s", ic.called(), out) |
| 605 | } |
| 606 | }) |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | func TestImportRolledBackWhileWaitingForTheSwap(t *testing.T) { |
| 611 | fakeSeedWait(t, 2*time.Second) |
| 612 | c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("rolled-back", "")) |
| 613 | out, err := importRun(t, c, "") |
| 614 | if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "waiting for the box to swap main.shop.example.com\naborting\n") { |
| 615 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | const importEnded = "the import of main.shop.example.com has ended. It was deleted, or another import of main.shop.example.com began. Check carlos instances list." |
| 620 | |
| 621 | func TestImportAnotherImportOnTheHostEndsTheWait(t *testing.T) { |
| 622 | fakeSeedWait(t, 2*time.Second) |
| 623 | c := newImportConsole(importRecord("uploaded", ""), importRecordID("sd_other", "ready", "")) |
| 624 | out, err := importRun(t, c, "") |
| 625 | if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "\n"+importEnded+"\n") || strings.Contains(out, "aborting") { |
| 626 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | // A record that goes while the CLI waits for the check, the swap or the |
| 631 | // placing says the import ended, not the membership sentence. |
| 632 | func TestImportRecordGoneEndsTheWait(t *testing.T) { |
| 633 | gone := answerWith(http.StatusNotFound, "") |
| 634 | for _, c := range []struct { |
| 635 | name string |
| 636 | gets []func(http.ResponseWriter) |
| 637 | }{ |
| 638 | {"waiting for the check", []func(http.ResponseWriter){importRecord("awaiting-upload", ""), gone}}, |
| 639 | {"waiting for the swap", []func(http.ResponseWriter){importRecord("uploaded", ""), importRecord("ready", ""), gone}}, |
| 640 | {"waiting for the placing", []func(http.ResponseWriter){importRecord("uploaded", ""), importRecord("swapped", ""), gone}}, |
| 641 | } { |
| 642 | t.Run(c.name, func(t *testing.T) { |
| 643 | fakeSeedWait(t, 2*time.Second) |
| 644 | out, err := importRun(t, newImportConsole(c.gets...), "/up", http.StatusOK) |
| 645 | if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "\n"+importEnded+"\n") || strings.Contains(out, "not found") { |
| 646 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 647 | } |
| 648 | }) |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | // The check wait is 55 minutes of looks; at its end the token is shown and |
| 653 | // the line names the way on, not Go's "context deadline exceeded". |
| 654 | func TestImportCheckDeadline(t *testing.T) { |
| 655 | waits := fakeSeedWait(t, time.Minute) |
| 656 | c := newImportConsole(importRecord("awaiting-upload", "")) |
| 657 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 658 | if !errors.Is(err, errImportFailed) { |
| 659 | t.Fatalf("err = %v", err) |
| 660 | } |
| 661 | const want = "the console has not kept the upload of main.shop.example.com after 55 minutes. Check carlos instances list. To start over, run carlos instances import abort --app shop --host main.shop.example.com, then run the import again." |
| 662 | if !strings.HasSuffix(out, importTokenLine+"\n"+want+"\n") || strings.Contains(out, "deadline") { |
| 663 | t.Fatalf("output:\n%s", out) |
| 664 | } |
| 665 | if c.saw("stopped") || len(*waits) != 55 { |
| 666 | t.Fatalf("calls = %v, waits = %d", c.called(), len(*waits)) |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | // The usage block of carlos instances import, byte for byte. |
| 671 | func TestImportUsageIsTheApprovedBlock(t *testing.T) { |
| 672 | const want = "usage: carlos instances import --app <a> --host <h> --file <snapshot> --replaces <host:port>\n" + |
| 673 | " [--verify <path>] [--environment <e>]\n" + |
| 674 | " carlos instances import commit --app <a> --host <h>\n" + |
| 675 | " carlos instances import abort --app <a> --host <h>\n" + |
| 676 | "\n" + |
| 677 | "carlos instances import moves an instance the platform never ran onto the platform." |
| 678 | if got := importUsage(); got != want { |
| 679 | t.Fatalf("usage = %q, want %q", got, want) |
| 680 | } |
| 681 | } |
| 682 | |
| 683 | // A stopped whose first answer was lost answers 409 SD-N54 naming a state |
| 684 | // past it; the record says the stop landed, so the import goes on. |
| 685 | func TestImportStoppedThatAlreadyLanded(t *testing.T) { |
| 686 | fakeSeedWait(t, 2*time.Second) |
| 687 | c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", ""), importRecord("swapped", "")) |
| 688 | c.verbs["stopped"] = answerWith(http.StatusConflict, `{"error":"main.shop.example.com is ready. Mark it stopped once, after its upload."}`) |
| 689 | out, err := importRun(t, c, "") |
| 690 | if err != nil || !strings.HasSuffix(out, importCommitLine+"\n") { |
| 691 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 692 | } |
| 693 | } |
| 694 | |
| 695 | func TestImportStoppedRefusedForReal(t *testing.T) { |
| 696 | fakeSeedWait(t, 2*time.Second) |
| 697 | const refusal = "carlos run is busy on main.shop.example.com. Wait for it to finish, then abort." |
| 698 | c := newImportConsole(importRecord("uploaded", "")) |
| 699 | c.verbs["stopped"] = answerWith(http.StatusConflict, fmt.Sprintf(`{"error":%q}`, refusal)) |
| 700 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 701 | if err == nil || err.Error() != refusal { |
| 702 | t.Fatalf("err = %v", err) |
| 703 | } |
| 704 | // With --verify, an exit that leaves the import live shows the token (M17). |
| 705 | if !strings.HasSuffix(out, importTokenLine+"\n") { |
| 706 | t.Fatalf("output:\n%s", out) |
| 707 | } |
| 708 | } |
| 709 | |
| 710 | func TestImportUploadRefusedShowsTheTokenWithVerify(t *testing.T) { |
| 711 | fakeSeedWait(t, 2*time.Second) |
| 712 | const sdN51 = "the upload does not match the length and sha256 this import began with, so it was not kept." |
| 713 | c := newImportConsole(importRecord("awaiting-upload", "")) |
| 714 | c.upload = answerWith(http.StatusUnprocessableEntity, fmt.Sprintf(`{"error":%q}`, sdN51)) |
| 715 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 716 | if err == nil || err.Error() != sdN51 || strings.Contains(err.Error(), "s3cret-token") { |
| 717 | t.Fatalf("err = %v", err) |
| 718 | } |
| 719 | if !strings.HasSuffix(out, importTokenLine+"\n") || c.saw("get") { |
| 720 | t.Fatalf("calls = %v, output:\n%s", c.called(), out) |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | func TestImportBeginRefusedShowsNothingMore(t *testing.T) { |
| 725 | fakeSeedWait(t, 2*time.Second) |
| 726 | c := newImportConsole() |
| 727 | c.beginStatus, c.beginBody = http.StatusConflict, `{"error":"instance main.shop.example.com already exists"}` |
| 728 | out, err := importRun(t, c, "") |
| 729 | if err == nil || err.Error() != "instance main.shop.example.com already exists" { |
| 730 | t.Fatalf("err = %v", err) |
| 731 | } |
| 732 | wantExactLines(t, out, "uploading main.db (3B)") |
| 733 | if got := c.called(); len(got) != 1 { |
| 734 | t.Fatalf("calls = %v", got) |
| 735 | } |
| 736 | } |
| 737 | |
| 738 | // A commit that lost its answer is settled by the record (an overlapping |
| 739 | // commit answers SD-N60; the record is committed). |
| 740 | func TestImportCommitSettledByTheRecord(t *testing.T) { |
| 741 | fakeSeedWait(t, 2*time.Second) |
| 742 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), importRecord("placed", ""), importRecord("committed", "")) |
| 743 | c.verbs["commit"] = answerWith(http.StatusConflict, `{"error":"main.shop.example.com changed while this request was made. Read its state with carlos instances list and try again."}`) |
| 744 | out, err := importRun(t, c, "/up", http.StatusOK) |
| 745 | if err != nil || !strings.HasSuffix(out, " is served by the platform now.\n") { |
| 746 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 747 | } |
| 748 | } |
| 749 | |
| 750 | // An abort that answers 404 after the import's own record has gone is done. |
| 751 | func TestImportAbortOfAFinishedImport(t *testing.T) { |
| 752 | fakeSeedWait(t, 2*time.Second) |
| 753 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", ""), answerWith(http.StatusNotFound, `{"error":"x is not an instance of shop"}`)) |
| 754 | c.verbs["abort"] = answerWith(http.StatusNotFound, `{"error":"x is not an instance of shop"}`) |
| 755 | out, err := importRun(t, c, "/up", http.StatusBadGateway, http.StatusForbidden) |
| 756 | if !errors.Is(err, errImportFailed) || !strings.HasSuffix(out, "Aborting the import.\naborted. The box puts the old route of "+hostOf(out)+" back on its next pass.\n") { |
| 757 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 758 | } |
| 759 | } |
| 760 | |
| 761 | // The verb alone acts on the import the host holds now: it reads the |
| 762 | // record first and names that seed (final review F1). A host with no |
| 763 | // record names none, and the console's answer is the error. |
| 764 | func TestImportVerbAlone(t *testing.T) { |
| 765 | for _, c := range []struct { |
| 766 | name, verb string |
| 767 | answer func(http.ResponseWriter) |
| 768 | record func(http.ResponseWriter) |
| 769 | seed string // the seed the verb named |
| 770 | want string |
| 771 | wantErr string |
| 772 | }{ |
| 773 | {"commit", "commit", nil, importRecord("placed", ""), "sd_imp", "committed. main.shop.example.com is served by the platform now.", ""}, |
| 774 | {"abort", "abort", nil, importRecord("swapped", ""), "sd_imp", "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| 775 | {"abort of a re-begun import", "abort", nil, importRecordID("sd_other", "uploaded", ""), "sd_other", |
| 776 | "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| 777 | {"commit refused", "commit", answerWith(http.StatusConflict, `{"error":"main.shop.example.com is swapped. Commit it once it is placed."}`), |
| 778 | importRecord("swapped", ""), "sd_imp", "", "main.shop.example.com is swapped. Commit it once it is placed."}, |
| 779 | {"abort that lost to its own retry", "abort", answerWith(http.StatusConflict, `{"error":"changed"}`), |
| 780 | importRecord("aborted", ""), "sd_imp", "aborted. The box puts the old route of main.shop.example.com back on its next pass.", ""}, |
| 781 | {"abort of a host with no record", "abort", answerWith(http.StatusNotFound, `{"error":"main.shop.example.com is not an instance of shop"}`), |
| 782 | answerWith(http.StatusNotFound, ""), "", "", "main.shop.example.com is not an instance of shop, so there is no import to abort. An import that was aborted and cleaned up leaves no instance behind."}, |
| 783 | {"commit of a host with no record", "commit", answerWith(http.StatusNotFound, `{"error":"main.shop.example.com is not an instance of shop"}`), |
| 784 | answerWith(http.StatusNotFound, ""), "", "", "main.shop.example.com is not an instance of shop"}, |
| 785 | } { |
| 786 | t.Run(c.name, func(t *testing.T) { |
| 787 | ic := newImportConsole(c.record) |
| 788 | if c.answer != nil { |
| 789 | ic.verbs[c.verb] = c.answer |
| 790 | } |
| 791 | ts := httptest.NewServer(ic) |
| 792 | defer ts.Close() |
| 793 | var out bytes.Buffer |
| 794 | err := newTestSession(ts.URL).runImportVerb(context.Background(), &out, apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", c.verb) |
| 795 | if calls := ic.called(); len(calls) < 2 || calls[0] != "get" || calls[1] != c.verb { |
| 796 | t.Fatalf("calls = %q, want the record read before the %s", calls, c.verb) |
| 797 | } |
| 798 | ic.requireSeeds(t, c.seed, 1) |
| 799 | if c.wantErr != "" { |
| 800 | if err == nil || err.Error() != c.wantErr || out.Len() != 0 { |
| 801 | t.Fatalf("err = %v, output %q", err, out.String()) |
| 802 | } |
| 803 | return |
| 804 | } |
| 805 | if err != nil || out.String() != c.want+"\n" { |
| 806 | t.Fatalf("err = %v, output %q", err, out.String()) |
| 807 | } |
| 808 | }) |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | // The shared poller (M18): a bounded wait ends with DeadlineExceeded after |
| 813 | // its share of looks; failed looks count as Plan 5's do. |
| 814 | func TestPollSeedLimitAndFailures(t *testing.T) { |
| 815 | waits := fakeSeedWait(t, 2*time.Second) |
| 816 | c := newImportConsole(importRecord("ready", "")) |
| 817 | ts := httptest.NewServer(c) |
| 818 | defer ts.Close() |
| 819 | s := newTestSession(ts.URL) |
| 820 | _, err := s.pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 10*time.Second, importLook("main.shop.example.com", "sd_imp", "swapped")) |
| 821 | if !errors.Is(err, context.DeadlineExceeded) || len(*waits) != 5 { |
| 822 | t.Fatalf("err = %v, waits = %v", err, *waits) |
| 823 | } |
| 824 | bad := newImportConsole(answerWith(http.StatusBadGateway, `{"error":"could not read the instance"}`)) |
| 825 | ts2 := httptest.NewServer(bad) |
| 826 | defer ts2.Close() |
| 827 | _, err = newTestSession(ts2.URL).pollSeed(context.Background(), apiAcct{Seg: "bab"}, "shop", "main.shop.example.com", 0, importLook("main.shop.example.com", "sd_imp", "swapped")) |
| 828 | bad.mu.Lock() |
| 829 | looks := bad.looks |
| 830 | bad.mu.Unlock() |
| 831 | if err == nil || err.Error() != "could not read the instance" || looks != seedPollFailures { |
| 832 | t.Fatalf("err = %v, looks = %d", err, looks) |
| 833 | } |
| 834 | } |
| 835 | |
| 836 | // cancelOnWait makes the seedWait seam cancel ctx on the first wait that |
| 837 | // matches, as Ctrl-C would mid-wait; earlier waits pass. |
| 838 | func cancelOnWait(t *testing.T, cancel context.CancelFunc, match func(n int, d time.Duration) bool) { |
| 839 | t.Helper() |
| 840 | oldWait, oldEvery := seedWait, seedPollEvery |
| 841 | n := 0 |
| 842 | seedWait = func(ctx context.Context, d time.Duration) error { |
| 843 | if n++; match(n, d) { |
| 844 | cancel() |
| 845 | } |
| 846 | return ctx.Err() |
| 847 | } |
| 848 | seedPollEvery = 2 * time.Second |
| 849 | t.Cleanup(func() { seedWait, seedPollEvery = oldWait, oldEvery }) |
| 850 | } |
| 851 | |
| 852 | // wantCancelledLive is an interrupted import that is left live: the token |
| 853 | // and the commands to finish by hand, no commit or abort, exit non-zero. |
| 854 | func wantCancelledLive(t *testing.T, c *importConsole, out string, err error) { |
| 855 | t.Helper() |
| 856 | if !errors.Is(err, errImportFailed) { |
| 857 | t.Fatalf("err = %v", err) |
| 858 | } |
| 859 | // Without --verify the token was shown at begin; with it, just before |
| 860 | // the commands. Either way once, and the commands last. |
| 861 | if !strings.HasSuffix(out, "\n"+importCommitLine+"\n") || strings.Count(out, importTokenLine) != 1 || |
| 862 | strings.Contains(out, "verify failed") { |
| 863 | t.Fatalf("output:\n%s", out) |
| 864 | } |
| 865 | if c.saw("commit") || c.saw("abort") { |
| 866 | t.Fatalf("calls = %v", c.called()) |
| 867 | } |
| 868 | } |
| 869 | |
| 870 | func TestImportInterruptedDuringTheSwapWait(t *testing.T) { |
| 871 | for _, verify := range []string{"", "/up"} { |
| 872 | t.Run("verify="+verify, func(t *testing.T) { |
| 873 | ctx, cancel := context.WithCancel(context.Background()) |
| 874 | defer cancel() |
| 875 | cancelOnWait(t, cancel, func(n int, _ time.Duration) bool { return n == 3 }) // the upload's look, then two swap looks |
| 876 | c := newImportConsole(importRecord("uploaded", ""), importRecord("ready", "")) |
| 877 | out, err := importRunCtx(t, ctx, c, verify, http.StatusOK) |
| 878 | wantCancelledLive(t, c, out, err) |
| 879 | if !strings.Contains(out, "waiting for the box to swap main.shop.example.com\n") { |
| 880 | t.Fatalf("output:\n%s", out) |
| 881 | } |
| 882 | }) |
| 883 | } |
| 884 | } |
| 885 | |
| 886 | // A verify cut short is not a verdict: no "verify failed", no abort. |
| 887 | func TestImportInterruptedDuringTheVerifyRetries(t *testing.T) { |
| 888 | ctx, cancel := context.WithCancel(context.Background()) |
| 889 | defer cancel() |
| 890 | cancelOnWait(t, cancel, func(_ int, d time.Duration) bool { return d == importVerifyRetry }) |
| 891 | c := newImportConsole(importRecord("uploaded", ""), importRecord("swapped", "")) |
| 892 | out, err := importRunCtx(t, ctx, c, "/up", http.StatusServiceUnavailable) |
| 893 | wantCancelledLive(t, c, out, err) |
| 894 | } |
| 895 | |
| 896 | func TestImportInterruptedDuringTheUpload(t *testing.T) { |
| 897 | ctx, cancel := context.WithCancel(context.Background()) |
| 898 | defer cancel() |
| 899 | fakeSeedWait(t, 2*time.Second) |
| 900 | c := newImportConsole(importRecord("uploaded", "")) |
| 901 | c.uploadHold = func(r *http.Request) { |
| 902 | cancel() |
| 903 | <-r.Context().Done() // the client gives up the request |
| 904 | } |
| 905 | out, err := importRunCtx(t, ctx, c, "/up", http.StatusOK) |
| 906 | wantCancelledLive(t, c, out, err) |
| 907 | if c.saw("stopped") || c.saw("get") { |
| 908 | t.Fatalf("calls = %v", c.called()) |
| 909 | } |
| 910 | } |
| 911 | |
| 912 | func TestImportCancelledBeforeBeginJustExits(t *testing.T) { |
| 913 | fakeSeedWait(t, 2*time.Second) |
| 914 | ctx, cancel := context.WithCancel(context.Background()) |
| 915 | cancel() |
| 916 | c := newImportConsole() |
| 917 | out, err := importRunCtx(t, ctx, c, "/up", http.StatusOK) |
| 918 | if !errors.Is(err, context.Canceled) || errors.Is(err, errImportFailed) { |
| 919 | t.Fatalf("err = %v", err) |
| 920 | } |
| 921 | wantExactLines(t, out, "uploading main.db (3B)") |
| 922 | if len(c.called()) != 0 { |
| 923 | t.Fatalf("calls = %v", c.called()) |
| 924 | } |
| 925 | } |
| 926 | |
| 927 | // An import committed by hand between two looks ends the swap wait with |
| 928 | // SD-N75 rather than running to SD-N79 (review minor 3). |
| 929 | func TestImportCommittedByHandDuringTheSwapWait(t *testing.T) { |
| 930 | fakeSeedWait(t, 2*time.Second) |
| 931 | c := newImportConsole(importRecord("uploaded", ""), importRecord("committed", "")) |
| 932 | out, err := importRun(t, c, "") |
| 933 | if err != nil || !strings.HasSuffix(out, "waiting for the box to swap main.shop.example.com\ncommitted. main.shop.example.com is served by the platform now.\n") { |
| 934 | t.Fatalf("err = %v, output:\n%s", err, out) |
| 935 | } |
| 936 | } |
| 937 | |
| 938 | func TestImportVerifyTarget(t *testing.T) { |
| 939 | for _, c := range []struct{ path, want string }{ |
| 940 | {"/up", "https://main.shop.example.com/up"}, |
| 941 | {"up", "https://main.shop.example.com/up"}, |
| 942 | } { |
| 943 | if got := importVerifyTarget("main.shop.example.com", c.path); got != c.want { |
| 944 | t.Errorf("importVerifyTarget(%q) = %q, want %q", c.path, got, c.want) |
| 945 | } |
| 946 | } |
| 947 | // The CLI refuses a --verify that cannot become a request before it |
| 948 | // begins (review minor 2), with the parse the request itself uses. |
| 949 | if _, err := url.Parse(importVerifyTarget("main.shop.example.com", "/%zz")); err == nil { |
| 950 | t.Fatal("a path with a bad escape parsed") |
| 951 | } |
| 952 | if _, err := http.NewRequest(http.MethodGet, importVerifyTarget("main.shop.example.com", "/%zz"), nil); err == nil { |
| 953 | t.Fatal("the request would have been made") |
| 954 | } |
| 955 | } |
| 956 | |
| 957 | // The import's own signal set: Ctrl-C, SIGTERM and SIGHUP cancel it, and |
| 958 | // SIGPIPE never does, since a reset connection raises it mid-upload. |
| 959 | func TestImportStopSignalsLeaveOutSIGPIPE(t *testing.T) { |
| 960 | want := map[os.Signal]bool{os.Interrupt: true, syscall.SIGTERM: true, syscall.SIGHUP: true} |
| 961 | if len(importStopSignals) != len(want) { |
| 962 | t.Fatalf("importStopSignals = %v", importStopSignals) |
| 963 | } |
| 964 | for _, sig := range importStopSignals { |
| 965 | if sig == syscall.SIGPIPE || !want[sig] { |
| 966 | t.Fatalf("importStopSignals = %v, want exactly Interrupt, SIGTERM and SIGHUP", importStopSignals) |
| 967 | } |
| 968 | } |
| 969 | } |
| 970 | |