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 "bytes"
5 "context"
6 "crypto/ed25519"
7 "errors"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/apperr"
19 "github.com/carlosframework/platform/internal/registry"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// getPath GETs base+path with an explicit Host header (getHost, but with a
24// request path — static trees are about more URLs than "/").
25func getPath(t *testing.T, base, host, path string) (int, string) {
26 t.Helper()
27 req, err := http.NewRequest("GET", base+path, nil)
28 if err != nil {
29 t.Fatalf("new request: %v", err)
30 }
31 req.Host = host
32 resp, err := http.DefaultClient.Do(req)
33 if err != nil {
34 t.Fatalf("GET %s%s (host %s): %v", base, path, host, err)
35 }
36 defer resp.Body.Close()
37 b, err := io.ReadAll(resp.Body)
38 if err != nil {
39 t.Fatalf("read body: %v", err)
40 }
41 return resp.StatusCode, string(b)
42}
43
44// getPathResp is getPath's sibling for tests that need the response headers
45// rather than the body — X-Carlos-Version lives there, not in the tree.
46func getPathResp(t *testing.T, base, host, path string) *http.Response {
47 t.Helper()
48 req, err := http.NewRequest("GET", base+path, nil)
49 if err != nil {
50 t.Fatalf("new request: %v", err)
51 }
52 req.Host = host
53 resp, err := http.DefaultClient.Do(req)
54 if err != nil {
55 t.Fatalf("GET %s%s (host %s): %v", base, path, host, err)
56 }
57 defer resp.Body.Close()
58 io.Copy(io.Discard, resp.Body)
59 return resp
60}
61
62func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
63 t.Helper()
64 seedB64, pubB64, err := release.GenerateKey()
65 if err != nil {
66 t.Fatalf("generate key: %v", err)
67 }
68 priv, err := release.ParseSigningKey(seedB64, "test")
69 if err != nil {
70 t.Fatalf("parse signing key: %v", err)
71 }
72 pub, err := release.ParsePublicKey(pubB64, "test")
73 if err != nil {
74 t.Fatalf("parse public key: %v", err)
75 }
76 return priv, pub
77}
78
79// writeTree materialises files (rel path -> body) under a fresh temp dir.
80func writeTree(t *testing.T, files map[string]string) string {
81 t.Helper()
82 dir := t.TempDir()
83 for name, body := range files {
84 p := filepath.Join(dir, filepath.FromSlash(name))
85 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
86 t.Fatalf("mkdir for %s: %v", name, err)
87 }
88 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
89 t.Fatalf("write %s: %v", name, err)
90 }
91 }
92 return dir
93}
94
95// shipSite packs a tree and ships it as a static release of app@version.
96func shipSite(t *testing.T, store release.Store, app, version string, files map[string]string) {
97 t.Helper()
98 tarball, err := PackDir(writeTree(t, files))
99 if err != nil {
100 t.Fatalf("PackDir: %v", err)
101 }
102 m := release.Manifest{
103 Schema: 1, App: app, Version: version, Kind: "static",
104 Artifacts: map[string]release.Artifact{siteArtifactFile: {}},
105 }
106 if err := release.Ship(context.Background(), store, release.OpsAccount, m, map[string][]byte{siteArtifactFile: tarball}); err != nil {
107 t.Fatalf("Ship %s %s: %v", app, version, err)
108 }
109}
110
111// staticFixture is a shipped+promoted site behind a fresh static handler
112// whose pointer cache is disabled (recheck 0: every request re-resolves).
113func staticFixture(t *testing.T) (release.Store, string, ed25519.PrivateKey, *httptest.Server) {
114 t.Helper()
115 storeRoot := filepath.Join(t.TempDir(), "store")
116 store := release.NewDirStore(storeRoot)
117 priv, pub := testKeys(t)
118
119 shipSite(t, store, "site", "v1", map[string]string{
120 "index.html": "home v1",
121 "docs/page.html": "docs v1",
122 "style.css": "body{}",
123 })
124 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
125 t.Fatalf("Promote v1: %v", err)
126 }
127
128 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
129 h := newStaticHandler(rt, store, pub, filepath.Join(t.TempDir(), "cache"))
130 h.(*staticSite).recheck = 0 // re-check the pointer on every request
131 srv := httptest.NewServer(h)
132 t.Cleanup(srv.Close)
133 return store, storeRoot, priv, srv
134}
135
136func TestServesShippedTree(t *testing.T) {
137 _, _, _, srv := staticFixture(t)
138
139 for path, want := range map[string]string{
140 "/": "home v1", // directory -> index.html fallback
141 "/docs/page.html": "docs v1",
142 "/style.css": "body{}",
143 } {
144 code, body := getPath(t, srv.URL, "site.example.com", path)
145 if code != http.StatusOK || body != want {
146 t.Fatalf("GET %s = %d %q, want 200 %q", path, code, body, want)
147 }
148 }
149 if code, _ := getPath(t, srv.URL, "site.example.com", "/missing.html"); code != http.StatusNotFound {
150 t.Fatalf("GET /missing.html = %d, want 404", code)
151 }
152 // A directory with no index.html is a clean 404, never a listing.
153 if code, body := getPath(t, srv.URL, "site.example.com", "/docs/"); code != http.StatusNotFound {
154 t.Fatalf("GET /docs/ = %d %q, want 404 (no index.html, no listing)", code, body)
155 }
156}
157
158func TestChannelAdvanceSwapsContent(t *testing.T) {
159 store, _, priv, srv := staticFixture(t)
160
161 if code, body := getPath(t, srv.URL, "site.example.com", "/"); code != 200 || body != "home v1" {
162 t.Fatalf("pre-advance GET / = %d %q, want 200 %q", code, body, "home v1")
163 }
164
165 shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"})
166 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
167 t.Fatalf("Promote v2: %v", err)
168 }
169
170 // recheck is 0, so the very next request re-reads the pointer and swaps.
171 if code, body := getPath(t, srv.URL, "site.example.com", "/"); code != 200 || body != "home v2" {
172 t.Fatalf("post-advance GET / = %d %q, want 200 %q", code, body, "home v2")
173 }
174 // v2's tree has no docs/ — the old version's files must not bleed through.
175 if code, _ := getPath(t, srv.URL, "site.example.com", "/docs/page.html"); code != http.StatusNotFound {
176 t.Fatalf("GET /docs/page.html after advance = %d, want 404", code)
177 }
178}
179
180func TestTamperedTarRejected(t *testing.T) {
181 _, storeRoot, _, srv := staticFixture(t)
182
183 // Corrupt the stored artifact bytes underneath the store (an attacker or
184 // bit-rot on the bucket): flip one byte in the middle of the tar.
185 artPath := filepath.Join(storeRoot, filepath.FromSlash(release.ArtifactKey(release.OpsAccount, "site", "v1", siteArtifactFile)))
186 raw, err := os.ReadFile(artPath)
187 if err != nil {
188 t.Fatalf("read stored artifact: %v", err)
189 }
190 raw[len(raw)/2] ^= 0xff
191 if err := os.WriteFile(artPath, raw, 0o644); err != nil {
192 t.Fatalf("corrupt stored artifact: %v", err)
193 }
194
195 code, body := getPath(t, srv.URL, "site.example.com", "/")
196 if code < 500 || code > 599 {
197 t.Fatalf("GET / over tampered tar = %d %q, want 5xx", code, body)
198 }
199 if bytes.Contains([]byte(body), []byte("home v1")) {
200 t.Fatalf("tampered tar was served: %q", body)
201 }
202}
203
204func TestPackDirDeterministic(t *testing.T) {
205 files := map[string]string{
206 "index.html": "same tree",
207 "b/two.txt": "2",
208 "a/one.txt": "1",
209 }
210 // Two independent checkouts of the same tree must pack byte-identically,
211 // so re-shipping an unchanged site is Ship's idempotent no-op.
212 p1, err := PackDir(writeTree(t, files))
213 if err != nil {
214 t.Fatalf("PackDir 1: %v", err)
215 }
216 p2, err := PackDir(writeTree(t, files))
217 if err != nil {
218 t.Fatalf("PackDir 2: %v", err)
219 }
220 if !bytes.Equal(p1, p2) {
221 t.Fatalf("PackDir is not deterministic: %d vs %d bytes differ", len(p1), len(p2))
222 }
223}
224
225func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) {
226 storeRoot := filepath.Join(t.TempDir(), "store")
227 store := release.NewDirStore(storeRoot)
228 priv, pub := testKeys(t)
229 shipSite(t, store, "site", "v1", map[string]string{"index.html": "via the edge"})
230 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
231 t.Fatalf("Promote: %v", err)
232 }
233
234 reg := openReg(t)
235 if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil {
236 t.Fatalf("upsert: %v", err)
237 }
238 e, err := New(reg, nil)
239 if err != nil {
240 t.Fatalf("New: %v", err)
241 }
242 srv := httptest.NewServer(e)
243 defer srv.Close()
244
245 // Before EnableStatic the route exists but answers 404 (cert-first shape).
246 if code, _ := getHost(t, srv.URL, "site.example.com"); code != http.StatusNotFound {
247 t.Fatalf("pre-enable GET = %d, want 404", code)
248 }
249
250 e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
251 if err := e.Reload(); err != nil {
252 t.Fatalf("Reload: %v", err)
253 }
254 code, body := getHost(t, srv.URL, "site.example.com")
255 if code != 200 || body != "via the edge" {
256 t.Fatalf("post-enable GET = %d %q, want 200 %q", code, body, "via the edge")
257 }
258}
259
260// --- X-Carlos-Version for static routes (issue #112) --------------------
261//
262// A static route is never adopted, so registry.Route.Version is always empty
263// and edge.go's versionHeader wrapper never applies. The handler stamps its
264// own — the version whose tree answered the request.
265
266// TestStaticStampsTheVersionItServes: every response out of the site tree
267// carries the version that tree is, including the tree's own 404s, and the
268// stamp follows a pointer advance.
269func TestStaticStampsTheVersionItServes(t *testing.T) {
270 store, _, priv, srv := staticFixture(t)
271
272 resp := getPathResp(t, srv.URL, "site.example.com", "/")
273 if resp.StatusCode != http.StatusOK {
274 t.Fatalf("GET / = %d, want 200", resp.StatusCode)
275 }
276 if got := resp.Header.Get("X-Carlos-Version"); got != "v1" {
277 t.Fatalf("X-Carlos-Version = %q, want %q", got, "v1")
278 }
279
280 // A 404 from inside the tree is still an answer from a version — the
281 // deploy watch polls "/" and a site whose root is a 404 must still be
282 // diagnosable as "serving v1, but HTTP 404" rather than as an old edge.
283 resp = getPathResp(t, srv.URL, "site.example.com", "/missing.html")
284 if resp.StatusCode != http.StatusNotFound {
285 t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode)
286 }
287 if got := resp.Header.Get("X-Carlos-Version"); got != "v1" {
288 t.Fatalf("404 X-Carlos-Version = %q, want %q", got, "v1")
289 }
290
291 shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"})
292 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
293 t.Fatalf("Promote v2: %v", err)
294 }
295 resp = getPathResp(t, srv.URL, "site.example.com", "/")
296 if got := resp.Header.Get("X-Carlos-Version"); got != "v2" {
297 t.Fatalf("post-advance X-Carlos-Version = %q, want %q", got, "v2")
298 }
299}
300
301// brokenStore reads through to a real store until breakNow, then fails every
302// read — the shape that drives staticSite down its degraded path (an
303// unreadable channel pointer) after it has already resolved and extracted.
304type brokenStore struct {
305 release.Store
306 mu sync.Mutex
307 broken bool
308}
309
310func (b *brokenStore) breakNow() {
311 b.mu.Lock()
312 defer b.mu.Unlock()
313 b.broken = true
314}
315
316func (b *brokenStore) failing() bool {
317 b.mu.Lock()
318 defer b.mu.Unlock()
319 return b.broken
320}
321
322func (b *brokenStore) Get(ctx context.Context, key string) ([]byte, string, error) {
323 if b.failing() {
324 return nil, "", errors.New("store unreachable")
325 }
326 return b.Store.Get(ctx, key)
327}
328
329func (b *brokenStore) List(ctx context.Context, prefix string) ([]string, error) {
330 if b.failing() {
331 return nil, errors.New("store unreachable")
332 }
333 return b.Store.List(ctx, prefix)
334}
335
336// TestStaticDegradedStampsTheCachedVersion: when the pointer goes unreadable
337// the handler keeps serving the version it already verified — and must stamp
338// THAT, never the head it could not reach. A watch that saw the new version
339// on a response whose bytes are the old one would be exactly the false
340// "verified" this header exists to prevent.
341func TestStaticDegradedStampsTheCachedVersion(t *testing.T) {
342 ctx := context.Background()
343 dir := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
344 priv, pub := testKeys(t)
345 shipSite(t, dir, "site", "v1", map[string]string{"index.html": "home v1"})
346 if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
347 t.Fatalf("Promote v1: %v", err)
348 }
349
350 bs := &brokenStore{Store: dir}
351 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
352 h := newStaticHandler(rt, bs, pub, filepath.Join(t.TempDir(), "cache"))
353 h.(*staticSite).recheck = 0 // re-check the pointer on every request
354 srv := httptest.NewServer(h)
355 defer srv.Close()
356
357 if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
358 t.Fatalf("pre-break X-Carlos-Version = %q, want %q", got, "v1")
359 }
360
361 // The pointer advances to v2 and the store goes away in the same breath:
362 // the head is v2, the bytes on disk are v1.
363 shipSite(t, dir, "site", "v2", map[string]string{"index.html": "home v2"})
364 if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
365 t.Fatalf("Promote v2: %v", err)
366 }
367 bs.breakNow()
368
369 code, body := getPath(t, srv.URL, "site.example.com", "/")
370 if code != http.StatusOK || body != "home v1" {
371 t.Fatalf("degraded GET / = %d %q, want 200 %q", code, body, "home v1")
372 }
373 if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
374 t.Fatalf("degraded X-Carlos-Version = %q, want %q (the CACHED version, not the unreachable head)", got, "v1")
375 }
376}
377
378// TestStaticCacheWindowStampsTheCachedVersion: inside the trust window the
379// handler answers from the version it last resolved WITHOUT re-reading the
380// pointer, and the stamp has to name that version rather than the head the
381// pointer has since advanced to. This is the window the deploy watch waits
382// out, and the reason it can only converge once the recheck lapses.
383func TestStaticCacheWindowStampsTheCachedVersion(t *testing.T) {
384 ctx := context.Background()
385 dir := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
386 priv, pub := testKeys(t)
387 shipSite(t, dir, "site", "v1", map[string]string{"index.html": "home v1"})
388 if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
389 t.Fatalf("Promote v1: %v", err)
390 }
391
392 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
393 h := newStaticHandler(rt, dir, pub, filepath.Join(t.TempDir(), "cache"))
394 h.(*staticSite).recheck = time.Hour // a window no test run can outlive
395 srv := httptest.NewServer(h)
396 defer srv.Close()
397
398 if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
399 t.Fatalf("first request X-Carlos-Version = %q, want %q", got, "v1")
400 }
401
402 shipSite(t, dir, "site", "v2", map[string]string{"index.html": "home v2"})
403 if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
404 t.Fatalf("Promote v2: %v", err)
405 }
406
407 code, body := getPath(t, srv.URL, "site.example.com", "/")
408 if code != http.StatusOK || body != "home v1" {
409 t.Fatalf("in-window GET / = %d %q, want 200 %q (the window has not lapsed)", code, body, "home v1")
410 }
411 if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
412 t.Fatalf("in-window X-Carlos-Version = %q, want %q — the header must name the bytes, not the pointer", got, "v1")
413 }
414}
415
416// TestStaticNeverResolvedHasNoVersionHeader: a handler that has never
417// resolved anything answers 502, and there is no version to name — stamping
418// one would be inventing a build out of an empty bucket.
419func TestStaticNeverResolvedHasNoVersionHeader(t *testing.T) {
420 _, pub := testKeys(t)
421 empty := release.NewDirStore(filepath.Join(t.TempDir(), "store")) // no pointer, no release
422 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
423 h := newStaticHandler(rt, empty, pub, filepath.Join(t.TempDir(), "cache"))
424 h.(*staticSite).recheck = 0
425 srv := httptest.NewServer(h)
426 defer srv.Close()
427
428 resp := getPathResp(t, srv.URL, "site.example.com", "/")
429 if resp.StatusCode != http.StatusBadGateway {
430 t.Fatalf("never-resolved GET / = %d, want 502", resp.StatusCode)
431 }
432 if got, ok := resp.Header["X-Carlos-Version"]; ok {
433 t.Fatalf("X-Carlos-Version present (%v) on a handler that has never served a version", got)
434 }
435 // The 502 is the edge's own, and says so: Availability files it as
436 // "the static site could not be read", not as the app answering.
437 req, out := withOutcome(httptest.NewRequest("GET", "http://site.example.com/", nil))
438 h.ServeHTTP(httptest.NewRecorder(), req)
439 if out.cause != apperr.CauseStatic {
440 t.Fatalf("static 502 cause = %q, want %q", out.cause, apperr.CauseStatic)
441 }
442}
443
444// TestStaticUnconfiguredHasNoVersionHeader: the pre-EnableStatic 404 is not a
445// version — the route exists (so its cert can be minted) before any bytes can
446// flow, and there is nothing to name.
447func TestStaticUnconfiguredHasNoVersionHeader(t *testing.T) {
448 reg := openReg(t)
449 if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil {
450 t.Fatalf("upsert: %v", err)
451 }
452 e, err := New(reg, nil)
453 if err != nil {
454 t.Fatalf("New: %v", err)
455 }
456 srv := httptest.NewServer(e)
457 defer srv.Close()
458
459 resp := getHostHeader(t, srv.URL, "site.example.com")
460 if resp.StatusCode != http.StatusNotFound {
461 t.Fatalf("unconfigured GET = %d, want 404", resp.StatusCode)
462 }
463 if got, ok := resp.Header["X-Carlos-Version"]; ok {
464 t.Fatalf("X-Carlos-Version present (%v) on the unconfigured 404 — no version exists there", got)
465 }
466}
467
468// legacyTripwire fails the test if the edge asks the store for anything under
469// one app's LEGACY unqualified prefix. The release package has its own; the
470// edge needs one of its own too, because a tripwire only guards the paths a
471// test actually routes through it — and the static extract is where a
472// fallback would put unmigrated bytes in front of real traffic.
473type legacyTripwire struct {
474 release.Store
475 t *testing.T
476 app string
477}
478
479func (l *legacyTripwire) trip(what, key string) {
480 l.t.Helper()
481 if strings.HasPrefix(key, release.LegacyAppPrefix(l.app)) {
482 l.t.Errorf("the edge %s a legacy key: %s — the read-fallback is retired", what, key)
483 }
484}
485
486func (l *legacyTripwire) Get(ctx context.Context, key string) ([]byte, string, error) {
487 l.trip("read", key)
488 return l.Store.Get(ctx, key)
489}
490
491func (l *legacyTripwire) List(ctx context.Context, prefix string) ([]string, error) {
492 l.trip("listed", prefix)
493 return l.Store.List(ctx, prefix)
494}
495
496// The static extract never falls back to a legacy artifact. The fixture is
497// the half-migrated shape that makes the read observable: pointer and
498// manifest qualified (so the handler resolves and gets as far as wanting the
499// tar), the tar itself only at the legacy key. A fallback reintroduced in
500// ensureExtracted would serve those bytes; qualified-only serves an error.
501//
502// Without this, a fallback in ensureExtracted passes the whole suite.
503func TestStaticExtractNeverReadsALegacyArtifact(t *testing.T) {
504 ctx := context.Background()
505 dir := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
506 priv, pub := testKeys(t)
507
508 scratch := release.NewDirStore(filepath.Join(t.TempDir(), "scratch"))
509 shipSite(t, scratch, "site", "v1", map[string]string{"index.html": "legacy bytes"})
510 if err := release.Promote(ctx, scratch, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
511 t.Fatalf("Promote: %v", err)
512 }
513 keys, err := scratch.List(ctx, release.AppPrefix(release.OpsAccount, "site"))
514 if err != nil {
515 t.Fatal(err)
516 }
517 for _, k := range keys {
518 body, _, err := scratch.Get(ctx, k)
519 if err != nil {
520 t.Fatal(err)
521 }
522 dst := k
523 if strings.HasSuffix(k, siteArtifactFile) {
524 dst = release.LegacyAppPrefix("site") + strings.TrimPrefix(k, release.AppPrefix(release.OpsAccount, "site"))
525 }
526 if _, err := dir.Put(ctx, dst, body, ""); err != nil {
527 t.Fatal(err)
528 }
529 }
530
531 s := &legacyTripwire{Store: dir, t: t, app: "site"}
532 rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
533 h := newStaticHandler(rt, s, pub, filepath.Join(t.TempDir(), "cache"))
534 h.(*staticSite).recheck = 0
535 srv := httptest.NewServer(h)
536 defer srv.Close()
537
538 if code, body := getPath(t, srv.URL, "site.example.com", "/"); code == 200 {
539 t.Errorf("GET / = %d %q — the edge served an artifact that only exists at the legacy key", code, body)
540 }
541}
542