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 "strings"
13 "sync/atomic"
14 "testing"
15 "time"
16)
17
18func TestSeedCell(t *testing.T) {
19 for _, c := range []struct {
20 seed *apiSeed
21 want string
22 }{
23 {nil, "-"},
24 {&apiSeed{Kind: "replica", State: "reserving", From: "www.example.com"}, "copying from www.example.com"},
25 {&apiSeed{Kind: "replica", State: "ready", From: "www.example.com", TXID: "4211"}, "seeded from www.example.com at txid 4211"},
26 {&apiSeed{Kind: "replica", State: "placed", From: "www.example.com", TXID: "4211"}, "seeded from www.example.com at txid 4211"},
27 {&apiSeed{Kind: "import", State: "awaiting-upload"}, "import awaiting upload"},
28 {&apiSeed{Kind: "import", State: "uploaded"}, "awaiting stop"},
29 {&apiSeed{Kind: "import", State: "ready"}, "awaiting stop"},
30 {&apiSeed{Kind: "import", State: "swapped"}, "swapped, awaiting verify"},
31 {&apiSeed{Kind: "import", State: "committed", SHA256: "0123456789abcdef0123"}, "imported 0123456789ab"},
32 {&apiSeed{Kind: "import", State: "refused", Reason: "the route belongs to app kass"}, "refused: the route belongs to app kass"},
33 {&apiSeed{Kind: "import", State: "aborted"}, "aborting"},
34 {&apiSeed{Kind: "replica", State: "rolled-back"}, "aborting"},
35 } {
36 if got := seedCell(apiInstanceRecord{Seed: c.seed}); got != c.want {
37 t.Errorf("seedCell(%+v) = %q, want %q", c.seed, got, c.want)
38 }
39 }
40}
41
42func TestInstanceCreateFromSendsTheSource(t *testing.T) {
43 var body map[string]any
44 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45 raw, _ := io.ReadAll(r.Body)
46 _ = json.Unmarshal(raw, &body)
47 w.WriteHeader(http.StatusAccepted)
48 w.Write([]byte(`{"host":"canary.example.com","channel":"stable","seed":{"id":"sd_x","kind":"replica","from":"www.example.com","state":"reserving"}}`))
49 }))
50 defer ts.Close()
51 rec, accepted, err := newTestSession(ts.URL).instanceCreateFrom(context.Background(), apiAcct{Seg: "bab"}, "shop",
52 "canary.example.com", "", "", "", "www.example.com", 4200)
53 if err != nil {
54 t.Fatal(err)
55 }
56 from, _ := body["from"].(map[string]any)
57 if from["host"] != "www.example.com" || from["txid"] != float64(4200) || body["host"] != "canary.example.com" {
58 t.Fatalf("body = %v", body)
59 }
60 if !accepted || rec.Seed == nil || rec.Seed.State != "reserving" {
61 t.Fatalf("accepted = %v, record = %+v", accepted, rec)
62 }
63}
64
65// seedConsole is a console whose create answers 202 and whose record then
66// walks through the given GET answers, one per look.
67type seedConsole struct {
68 createStatus int
69 createBody string
70 gets []func(w http.ResponseWriter)
71 looks atomic.Int32
72}
73
74func (c *seedConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
75 if r.Method == http.MethodPost {
76 w.WriteHeader(c.createStatus)
77 io.WriteString(w, c.createBody)
78 return
79 }
80 i := int(c.looks.Add(1)) - 1
81 if i >= len(c.gets) {
82 i = len(c.gets) - 1
83 }
84 c.gets[i](w)
85}
86
87func seedRecord(state, reason string) func(w http.ResponseWriter) {
88 return func(w http.ResponseWriter) {
89 fmt.Fprintf(w, `{"host":"canary.example.com","channel":"stable","environment":"canary-env","seed":{"id":"sd_x","kind":"replica","from":"www.example.com","txid":"4211","state":%q,"reason":%q}}`, state, reason)
90 }
91}
92
93func answerWith(code int, body string) func(w http.ResponseWriter) {
94 return func(w http.ResponseWriter) {
95 w.WriteHeader(code)
96 io.WriteString(w, body)
97 }
98}
99
100const reservingBody = `{"host":"canary.example.com","channel":"stable","region_name":"Ireland","seed":{"id":"sd_x","kind":"replica","from":"www.example.com","state":"reserving"}}`
101
102// runSeeded runs a --from create against c with the wait faked, returning
103// the output, the error and the sleeps taken.
104func runSeeded(t *testing.T, c *seedConsole) (string, error, []time.Duration) {
105 t.Helper()
106 ts := httptest.NewServer(c)
107 defer ts.Close()
108 var waits []time.Duration
109 oldWait, oldEvery := seedWait, seedPollEvery
110 seedWait = func(ctx context.Context, d time.Duration) error { waits = append(waits, d); return ctx.Err() }
111 seedPollEvery = 2 * time.Second
112 defer func() { seedWait, seedPollEvery = oldWait, oldEvery }()
113 var out bytes.Buffer
114 err := newTestSession(ts.URL).runSeededCreate(context.Background(), &out, apiAcct{Seg: "bab"}, "shop",
115 "canary.example.com", "", "", "", "www.example.com", 0, "")
116 return out.String(), err, waits
117}
118
119func TestSeededCreateFollowsTheRecordToReady(t *testing.T) {
120 out, err, waits := runSeeded(t, &seedConsole{createStatus: 202, createBody: reservingBody,
121 gets: []func(http.ResponseWriter){seedRecord("reserving", ""), seedRecord("reserving", ""), seedRecord("ready", "")}})
122 if err != nil {
123 t.Fatal(err)
124 }
125 if !strings.HasPrefix(out, "copying www.example.com into canary.example.com. A large database can take several minutes.\ncreated instance canary.example.com") ||
126 !strings.Contains(out, "seeded from www.example.com at txid 4211") ||
127 !strings.Contains(out, "the host comes live on the next reconcile pass on its box") {
128 t.Fatalf("output = %q", out)
129 }
130 if len(waits) != 3 || waits[0] != 2*time.Second {
131 t.Fatalf("waits = %v, want three waits of 2s", waits)
132 }
133}
134
135func TestSeededCreateAbortedReturnsTheReason(t *testing.T) {
136 const reason = "copying www.example.com took longer than 30 minutes, so no instance was created."
137 out, err, _ := runSeeded(t, &seedConsole{createStatus: 202, createBody: reservingBody,
138 gets: []func(http.ResponseWriter){seedRecord("reserving", ""), seedRecord("aborted", reason)}})
139 if err == nil || err.Error() != reason {
140 t.Fatalf("err = %v, want exactly %q", err, reason)
141 }
142 if strings.Contains(out, "created instance") {
143 t.Fatalf("output = %q", out)
144 }
145}
146
147func TestSeededCreateAbortedWithoutAReasonNamesTheSource(t *testing.T) {
148 _, err, _ := runSeeded(t, &seedConsole{createStatus: 202, createBody: reservingBody,
149 gets: []func(http.ResponseWriter){seedRecord("aborted", "")}})
150 if err == nil || err.Error() != "could not copy www.example.com. Nothing was created. Try again." {
151 t.Fatalf("err = %v", err)
152 }
153}
154
155func TestSeededCreateRefusalPrintsLikeAnyCreateRefusal(t *testing.T) {
156 out, err, waits := runSeeded(t, &seedConsole{createStatus: 409, createBody: `{"error":"instance canary.example.com already exists"}`})
157 if err == nil || err.Error() != "instance canary.example.com already exists" {
158 t.Fatalf("err = %v", err)
159 }
160 if out != "" || len(waits) != 0 {
161 t.Fatalf("a refusal says nothing and waits for nothing: out = %q, waits = %v", out, waits)
162 }
163}
164
165func TestSeededCreateRetriesATransientPollError(t *testing.T) {
166 out, err, waits := runSeeded(t, &seedConsole{createStatus: 202, createBody: reservingBody,
167 gets: []func(http.ResponseWriter){answerWith(502, `{"error":"could not read the instance"}`), answerWith(503, ""),
168 seedRecord("reserving", ""), answerWith(502, ""), seedRecord("placed", "")}})
169 if err != nil {
170 t.Fatal(err)
171 }
172 if !strings.Contains(out, "created instance canary.example.com") || len(waits) != 5 {
173 t.Fatalf("out = %q, waits = %v", out, waits)
174 }
175}
176
177func TestSeededCreateGivesUpAfterFiveFailedLooks(t *testing.T) {
178 c := &seedConsole{createStatus: 202, createBody: reservingBody,
179 gets: []func(http.ResponseWriter){answerWith(502, `{"error":"could not read the instance"}`)}}
180 _, err, _ := runSeeded(t, c)
181 if err == nil || err.Error() != "could not read the instance" {
182 t.Fatalf("err = %v", err)
183 }
184 if n := c.looks.Load(); n != 5 {
185 t.Fatalf("looks = %d, want 5", n)
186 }
187}
188
189func TestSeededCreateFailedLooksMustBeConsecutive(t *testing.T) {
190 bad := answerWith(502, "")
191 _, err, _ := runSeeded(t, &seedConsole{createStatus: 202, createBody: reservingBody,
192 gets: []func(http.ResponseWriter){bad, bad, bad, bad, seedRecord("reserving", ""), bad, bad, bad, bad, seedRecord("ready", "")}})
193 if err != nil {
194 t.Fatalf("four failures, a good look, four more: err = %v", err)
195 }
196}
197
198func TestSeededCreateAMissingRecordEndsAtOnce(t *testing.T) {
199 c := &seedConsole{createStatus: 202, createBody: reservingBody, gets: []func(http.ResponseWriter){answerWith(404, "")}}
200 _, err, _ := runSeeded(t, c)
201 if err == nil || err.Error() != notFoundErr().Error() || c.looks.Load() != 1 {
202 t.Fatalf("err = %v, looks = %d", err, c.looks.Load())
203 }
204}
205
206func TestSeededCreateStopsWhenTheContextEnds(t *testing.T) {
207 ts := httptest.NewServer(&seedConsole{createStatus: 202, createBody: reservingBody, gets: []func(http.ResponseWriter){seedRecord("reserving", "")}})
208 defer ts.Close()
209 ctx, cancel := context.WithCancel(context.Background())
210 oldWait := seedWait
211 seedWait = func(ctx context.Context, d time.Duration) error { cancel(); return ctx.Err() }
212 defer func() { seedWait = oldWait }()
213 err := newTestSession(ts.URL).runSeededCreate(ctx, io.Discard, apiAcct{Seg: "bab"}, "shop", "canary.example.com", "", "", "", "www.example.com", 0, "")
214 if !errors.Is(err, context.Canceled) {
215 t.Fatalf("err = %v", err)
216 }
217}
218