carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "sync"
8 "testing"
9
10 "github.com/carlosframework/platform/internal/registry"
11 "github.com/carlosframework/platform/internal/seeds"
12)
13
14// The import gate (seeded-instances spec §5.4). Neutral fixtures: app shop,
15// hosts under example.com.
16const (
17 gateHost = "old.example.com"
18 gateAliasHost = "www.example.com"
19 gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa"
20 gateRefusal = "this instance is being imported. Try again in a few seconds.\n"
21)
22
23// gateBackend is an upstream that counts its requests and keeps the verify
24// header of the last one, so a test can say whether the app was reached and
25// whether the token reached it.
26type gateBackend struct {
27 mu sync.Mutex
28 n int
29 sawVerify []string
30}
31
32func newGateBackend(t *testing.T, body, cacheControl string) (*httptest.Server, *gateBackend) {
33 t.Helper()
34 b := &gateBackend{}
35 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36 b.mu.Lock()
37 b.n++
38 b.sawVerify = append(b.sawVerify, r.Header.Values(ImportVerifyHeader)...)
39 b.mu.Unlock()
40 if cacheControl != "" {
41 w.Header().Set("Cache-Control", cacheControl)
42 }
43 w.Write([]byte(body))
44 }))
45 t.Cleanup(srv.Close)
46 return srv, b
47}
48
49func (b *gateBackend) seen() (int, []string) {
50 b.mu.Lock()
51 defer b.mu.Unlock()
52 return b.n, append([]string(nil), b.sawVerify...)
53}
54
55// gateForImport swaps host's stored row for a gated import row backed by
56// addr, the way the swap does (one journal transaction), and returns the
57// row it wrote. The import row keeps the stored row's account and app.
58func gateForImport(t *testing.T, reg *registry.Registry, host, addr, gate string, hibernate bool) registry.Route {
59 t.Helper()
60 stored, err := reg.Get(host)
61 if err != nil || stored == nil {
62 t.Fatalf("get %s: %v %v", host, stored, err)
63 }
64 next := registry.Route{Host: host, Kind: "instance", Addr: addr, Status: "ready", App: stored.App,
65 Account: stored.Account, Hibernate: hibernate, Provisioned: true, ImportGate: gate,
66 SeedSource: `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}`}
67 j := registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1,
68 RecordKey: "apps/bbw/shop/instances/" + host + ".json"}
69 if err := reg.SwapForImport(j, next); err != nil {
70 t.Fatalf("swap %s: %v", host, err)
71 }
72 return next
73}
74
75// gateScene is an edge with a legacy row at gateHost (and an alias of it),
76// swapped for a gated import row whose instance hibernates behind a fake
77// activator.
78type gateScene struct {
79 reg *registry.Registry
80 e *Edge
81 act *fakeActivator
82 legacy *gateBackend
83 imported *gateBackend
84 token string
85}
86
87func newGateScene(t *testing.T, importCacheControl string) *gateScene {
88 t.Helper()
89 legacySrv, legacy := newGateBackend(t, "legacy", "public, max-age=60")
90 importSrv, imported := newGateBackend(t, "imported", importCacheControl)
91 reg := openReg(t)
92 if err := reg.Upsert(registry.Route{Host: gateHost, Kind: "service", Addr: legacySrv.Listener.Addr().String(),
93 Status: "ready", App: "shop", Account: "bbw"}); err != nil {
94 t.Fatal(err)
95 }
96 upsertAlias(t, reg, gateAliasHost, gateHost)
97 token, hash, err := seeds.NewVerifyToken()
98 if err != nil {
99 t.Fatal(err)
100 }
101 act := &fakeActivator{}
102 e, err := New(reg, act)
103 if err != nil {
104 t.Fatal(err)
105 }
106 t.Cleanup(e.probers.Stop)
107 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
108 // Before the swap the legacy app answers and its response is cached.
109 if rec := gateDo(e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" {
110 t.Fatalf("before the swap: %d %q", rec.Code, rec.Body.String())
111 }
112 gateForImport(t, reg, gateHost, importSrv.Listener.Addr().String(), hash, true)
113 if err := e.Reload(); err != nil {
114 t.Fatal(err)
115 }
116 return &gateScene{reg: reg, e: e, act: act, legacy: legacy, imported: imported, token: token}
117}
118
119func gateDo(h http.Handler, method, host, target, token string) *httptest.ResponseRecorder {
120 req := httptest.NewRequest(method, "http://"+host+target, nil)
121 req.Host = host
122 if token != "" {
123 req.Header.Set(ImportVerifyHeader, token)
124 }
125 rec := httptest.NewRecorder()
126 h.ServeHTTP(rec, req)
127 return rec
128}
129
130// wantGateRefusal is the gate's answer: 503, Retry-After 5, no-store, the
131// deck's sentence, and nothing of the token.
132func wantGateRefusal(t *testing.T, rec *httptest.ResponseRecorder, token, what string) {
133 t.Helper()
134 if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" ||
135 rec.Header().Get("Cache-Control") != "no-store" || rec.Body.String() != gateRefusal {
136 t.Fatalf("%s: %d retry %q cache %q body %q, want the gate's 503", what, rec.Code,
137 rec.Header().Get("Retry-After"), rec.Header().Get("Cache-Control"), rec.Body.String())
138 }
139 if token != "" && strings.Contains(rec.Body.String()+strings.Join(headerValues(rec.Header()), " "), token) {
140 t.Fatalf("%s: the refusal echoed the token", what)
141 }
142}
143
144func headerValues(h http.Header) []string {
145 var out []string
146 for k, vs := range h {
147 out = append(out, k)
148 out = append(out, vs...)
149 }
150 return out
151}
152
153// Every ordinary request to a gated row is refused before the cache and the
154// activator; only a GET carrying the right token reaches the instance, and
155// the app never sees the header. A closed gate refuses even the token, and
156// commit opens the row to everyone.
157func TestImportGateAtTheEdge(t *testing.T) {
158 s := newGateScene(t, "")
159 wrong, _, err := seeds.NewVerifyToken()
160 if err != nil {
161 t.Fatal(err)
162 }
163 refused := []struct {
164 name, method, token string
165 }{
166 {"no token", http.MethodGet, ""},
167 {"a wrong token", http.MethodGet, wrong},
168 {"the hash as the token", http.MethodGet, seeds.HashToken(s.token)},
169 {"a POST with the token", http.MethodPost, s.token},
170 {"a HEAD with the token", http.MethodHead, s.token},
171 {"a PUT with the token", http.MethodPut, s.token},
172 }
173 for _, c := range refused {
174 rec := gateDo(s.e, c.method, gateHost, "/up", c.token)
175 if c.method == http.MethodHead {
176 // A HEAD answer has no body to compare.
177 if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") != "5" {
178 t.Fatalf("%s: %d, want the gate's 503", c.name, rec.Code)
179 }
180 continue
181 }
182 wantGateRefusal(t, rec, s.token, c.name)
183 }
184 if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
185 t.Fatalf("the gate let an ordinary request through: app %d, wakes %d", n, s.act.served)
186 }
187 if n, _ := s.legacy.seen(); n != 1 {
188 t.Fatalf("the legacy app was reached %d times, want only the request before the swap", n)
189 }
190
191 // The verify request: GET with the token, on any path of the host. The
192 // legacy response cached before the swap is not what it gets.
193 rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token)
194 if rec.Code != http.StatusOK || rec.Body.String() != "imported" {
195 t.Fatalf("verify = %d %q, want the imported app", rec.Code, rec.Body.String())
196 }
197 if _, saw := s.imported.seen(); len(saw) != 0 {
198 t.Fatalf("the app saw the verify header: %q", saw)
199 }
200 if s.act.served != 1 {
201 t.Fatalf("the verify request woke the instance %d times, want 1", s.act.served)
202 }
203
204 // A rollback closes the gate: even the token is refused.
205 if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil {
206 t.Fatal(err)
207 }
208 if err := s.e.Reload(); err != nil {
209 t.Fatal(err)
210 }
211 wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, "/up", s.token), s.token, "a closed gate")
212 if n, _ := s.imported.seen(); n != 1 {
213 t.Fatalf("a closed gate let the token through: app reached %d times", n)
214 }
215}
216
217// Commit clears the gate: ordinary requests reach the instance, and a stray
218// verify header is still stripped before the app.
219func TestImportGateOpensOnCommit(t *testing.T) {
220 s := newGateScene(t, "")
221 if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil {
222 t.Fatal(err)
223 }
224 if err := s.e.Reload(); err != nil {
225 t.Fatal(err)
226 }
227 if rec := gateDo(s.e, http.MethodPost, gateHost, "/orders", ""); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
228 t.Fatalf("after commit = %d %q, want the imported app", rec.Code, rec.Body.String())
229 }
230 if rec := gateDo(s.e, http.MethodGet, gateHost, "/orders", s.token); rec.Code != http.StatusOK {
231 t.Fatalf("after commit with a stray token = %d", rec.Code)
232 }
233 if _, saw := s.imported.seen(); len(saw) != 0 {
234 t.Fatalf("the app saw the verify header after commit: %q", saw)
235 }
236}
237
238// An alias of a gated host is the gated row under another name: refused
239// without the token, and the token admits a GET.
240func TestImportGateCoversAnAlias(t *testing.T) {
241 s := newGateScene(t, "")
242 wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/up", ""), s.token, "an alias with no token")
243 if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
244 t.Fatalf("an alias reached a gated row: app %d, wakes %d", n, s.act.served)
245 }
246 if rec := gateDo(s.e, http.MethodGet, gateAliasHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
247 t.Fatalf("verify through an alias = %d %q", rec.Code, rec.Body.String())
248 }
249}
250
251// A path route under a gated host is gated too, and the token does not
252// open it: while the host is gated nothing but the verify request reaches
253// anything, and the verify request reaches only the gated row. The other
254// direction holds as well: an ungated host's prefix that selects a gated
255// row is refused, token or not, because a verify names the import's host.
256func TestImportGateCoversPathRoutes(t *testing.T) {
257 s := newGateScene(t, "")
258 targetSrv, target := newGateBackend(t, "prefix target", "")
259 frontSrv, front := newGateBackend(t, "front", "")
260 for _, rt := range []registry.Route{
261 {Host: "next.example.com", Kind: "service", Addr: targetSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"},
262 {Host: "front.example.com", Kind: "service", Addr: frontSrv.Listener.Addr().String(), Status: "ready", App: "shop", Account: "bbw"},
263 } {
264 if err := s.reg.Upsert(rt); err != nil {
265 t.Fatal(err)
266 }
267 }
268 upsertPath(t, s.reg, gateHost, "shop", "next.example.com")
269 upsertPath(t, s.reg, "front.example.com", "old", gateHost)
270 if err := s.e.Reload(); err != nil {
271 t.Fatal(err)
272 }
273
274 for _, tok := range []string{"", s.token} {
275 for _, p := range []string{"/shop", "/shop/cart", "//shop/cart", "/x/../shop"} {
276 wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateHost, p, tok), s.token, "the gated host's prefix "+p)
277 }
278 wantGateRefusal(t, gateDo(s.e, http.MethodGet, gateAliasHost, "/shop", tok), s.token, "the prefix through the alias")
279 wantGateRefusal(t, gateDo(s.e, http.MethodGet, "front.example.com", "/old/up", tok), s.token, "a prefix selecting the gated row")
280 }
281 if n, _ := target.seen(); n != 0 {
282 t.Fatalf("a path route under a gated host was reached %d times", n)
283 }
284 if n, _ := s.imported.seen(); n != 0 || s.act.served != 0 {
285 t.Fatalf("the gated row was reached through a prefix: app %d, wakes %d", n, s.act.served)
286 }
287
288 // The ungated front's own route is not the import's business.
289 if rec := gateDo(s.e, http.MethodGet, "front.example.com", "/home", ""); rec.Code != http.StatusOK || rec.Body.String() != "front" {
290 t.Fatalf("the ungated front = %d %q", rec.Code, rec.Body.String())
291 }
292 if n, _ := front.seen(); n != 1 {
293 t.Fatalf("front reached %d times", n)
294 }
295 // A verify on a path no prefix claims reaches the gated row.
296 if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
297 t.Fatalf("verify beside a prefix = %d %q", rec.Code, rec.Body.String())
298 }
299
300 // After commit the prefix routes as before.
301 if err := s.reg.CommitImport(gateHost, gateSeedID); err != nil {
302 t.Fatal(err)
303 }
304 if err := s.e.Reload(); err != nil {
305 t.Fatal(err)
306 }
307 if rec := gateDo(s.e, http.MethodGet, gateHost, "/shop/cart", ""); rec.Code != http.StatusOK || rec.Body.String() != "prefix target" {
308 t.Fatalf("the prefix after commit = %d %q", rec.Code, rec.Body.String())
309 }
310}
311
312// The verify request never touches the shared cache: it does not read the
313// legacy app's cached answer, and its own answer is not stored, so a
314// rollback that puts the legacy route back never replays the import's
315// response to visitors.
316func TestImportGateVerifyBypassesTheCache(t *testing.T) {
317 s := newGateScene(t, "public, max-age=60")
318 for i := 1; i <= 2; i++ {
319 if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", s.token); rec.Code != http.StatusOK || rec.Body.String() != "imported" {
320 t.Fatalf("verify %d = %d %q", i, rec.Code, rec.Body.String())
321 }
322 if n, _ := s.imported.seen(); n != i {
323 t.Fatalf("verify %d reached the app %d times: served from the cache", i, n)
324 }
325 }
326 // The rollback restores the legacy route.
327 if err := s.reg.CloseImportGate(gateHost, gateSeedID, 1); err != nil {
328 t.Fatal(err)
329 }
330 if err := s.reg.RestoreImportRoute(gateHost, gateSeedID); err != nil {
331 t.Fatal(err)
332 }
333 if err := s.e.Reload(); err != nil {
334 t.Fatal(err)
335 }
336 if rec := gateDo(s.e, http.MethodGet, gateHost, "/up", ""); rec.Code != http.StatusOK || rec.Body.String() != "legacy" {
337 t.Fatalf("after the rollback = %d %q, want the legacy answer", rec.Code, rec.Body.String())
338 }
339}
340
341// A gated host homed on another box is gated by that box's edge: the gate
342// column travels with the row, and the sending edge has no row for the host,
343// so it backhauls the request untouched and the home edge decides. Without
344// the token the client gets the home edge's 503; with it, the import answers
345// and never sees the header.
346func TestImportGateHoldsAcrossABackhaul(t *testing.T) {
347 s := newBackhaulScene(t)
348 s.converge(t)
349 importSrv, imported := newGateBackend(t, "imported", "")
350 token, hash, err := seeds.NewVerifyToken()
351 if err != nil {
352 t.Fatal(err)
353 }
354 gateForImport(t, s.a.edge.reg, e2eHomedHost, importSrv.Listener.Addr().String(), hash, false)
355 if err := s.a.edge.Reload(); err != nil {
356 t.Fatal(err)
357 }
358 if _, ok := s.b.edge.lookupUpstream(e2eHomedHost); ok {
359 t.Fatal("test bug: the sending edge must not route the gated host")
360 }
361 url := "https://" + e2eHomedHost + "/up"
362 home := s.a.calls.Load()
363 resp, body := s.get(t, s.b, url, nil, true)
364 if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") != "5" || body != gateRefusal {
365 t.Fatalf("backhauled with no token = %d retry %q body %q, want the home edge's gate", resp.StatusCode, resp.Header.Get("Retry-After"), body)
366 }
367 if got := s.a.calls.Load() - home; got != 1 {
368 t.Fatalf("the home edge saw %d request(s), want the one it refused", got)
369 }
370 if n, _ := imported.seen(); n != 0 {
371 t.Fatalf("a backhauled request with no token reached the import %d times", n)
372 }
373 resp, body = s.get(t, s.b, url, map[string]string{ImportVerifyHeader: token}, true)
374 if resp.StatusCode != http.StatusOK || body != "imported" {
375 t.Fatalf("backhauled verify = %d %q, want the import", resp.StatusCode, body)
376 }
377 if n, saw := imported.seen(); n != 1 || len(saw) != 0 {
378 t.Fatalf("backhauled verify: import reached %d times, header at the app %q", n, saw)
379 }
380}
381