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 "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
23func 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
44func 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.
59func 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).
77func 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
101func 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).
117func 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
138func 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
152func 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
198func 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.
224func 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
241func 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
255func 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
281func 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).
298type 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
311const importBeginBody = `{"host":"main.shop.example.com","verify_token":"s3cret-token","seed":{"id":"sd_imp","kind":"import","state":"awaiting-upload"}}`
312
313func 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
317func (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
362func (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.
369func (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
378func importRecord(state, reason string) func(w http.ResponseWriter) {
379 return importRecordID("sd_imp", state, reason)
380}
381
382func 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.
391func 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
396func 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
427func 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
440const (
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
445func 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
470func 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).
493func (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
507func 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
524func 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.
541func 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).
553func 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
571func 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
588func 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
610func 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
619const 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
621func 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.
632func 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".
654func 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.
671func 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.
685func 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
695func 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
710func 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
724func 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).
740func 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.
751func 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.
764func 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.
814func 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.
838func 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.
854func 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
870func 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.
887func 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
896func 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
912func 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).
929func 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
938func 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.
959func 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