edge: kind=status — the page outlives the box that rendered it
Routes with Kind=="status" now dispatch to a bucket-backed handler (status.go) that serves the fleet status page's three published objects (status/index.html, status/fleet.json, status/status.css — see internal/status's PublishFleet) straight out of the same release.Store EnableStatic already wires up for the static serving class. No signature verification and no versioned channel pointer: PublishFleet overwrites these three keys in place every publish cycle, so there's no "which version" question, just "read the key, cache it briefly, serve it." The per-key cache is asymmetric on purpose: inside its 60s TTL it always serves the cached copy (bounding bucket reads); outside the TTL, a store error still serves a stale cached copy rather than failing the request — the status page exists to tell an operator whether the fleet is healthy, so it must not itself go dark on a transient bucket blip. fleet.json's own staleness bands already say "stale" honestly; the handler doesn't need to 503 to be honest too. Only a key that has never been successfully fetched 503s on a store error — there's nothing left to serve. testEdgeWithStore in edge_test.go is a new harness helper (Edge + EnableStatic + one route, all httptest.NewRecorder-driven with no real listener, so it runs under the unix-socket-restricted sandbox) covering: the brief's allowlist/404 shape, content types, TTL-hold-then-refresh with a fake clock seam, stale-serve-on-store-error, and 503 on an empty store.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+345
−0
internal/edge/edge.go+23 −0internal/edge/edge_test.go+185 −0internal/edge/status.go+137 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 707353997..78be14db8 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -102,6 +102,14 @@ func New(reg *registry.Registry, act Activator) (*Edge, error) { |
| // resolve their app's channel pointer against store (verified with pub) and |
| // serve the extracted tree from cacheDir. Call Reload afterwards so existing |
| // static routes pick up real handlers; without EnableStatic they 404. |
| +// |
| +// Kind=="status" routes (statusHandlerFor, status.go) share this same store |
| +// handle rather than getting their own EnableXxx entry point: it's the same |
| +// deployment bucket, just a fixed unsigned three-key menu (status/index.html, |
| +// status/fleet.json, status/status.css — see internal/status's PublishFleet) |
| +// instead of a signed, versioned app tree. pub and cacheDir are meaningless |
| +// to the status class (no signature to verify, nothing to extract to disk), |
| +// so it only ever reads e.staticStore off this call. |
| func (e *Edge) EnableStatic(store release.Store, pub ed25519.PublicKey, cacheDir string) { |
| e.staticStore = store |
| e.staticPub = pub |
| @@ -130,6 +138,19 @@ func (e *Edge) staticHandlerFor(rt registry.Route) http.Handler { |
| return h |
| } |
| +// statusHandlerFor builds the handler for one status route: the real |
| +// bucket-backed handler (status.go) once EnableStatic has wired a store, else |
| +// the pre-config 404 — same cert-first shape as staticHandlerFor, so a |
| +// status route's cert can be minted before the store config lands. |
| +func (e *Edge) statusHandlerFor(rt registry.Route) http.Handler { |
| + if e.staticStore == nil { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Error(w, "status serving not configured", http.StatusNotFound) |
| + }) |
| + } |
| + return newStatusHandler(e.staticStore) |
| +} |
| + |
| // Reload rebuilds the host→backend map from the registry and swaps it in |
| // atomically. Safe to call while serving. |
| func (e *Edge) Reload() error { |
| @@ -157,6 +178,8 @@ func (e *Edge) Reload() error { |
| proxy = tcpProxy(t.Addr) |
| case t.Kind == "static": |
| proxy = e.staticHandlerFor(t) |
| + case t.Kind == "status": |
| + proxy = e.statusHandlerFor(t) |
| default: |
| continue // no backend to reach |
| } |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 43189162f..88477efd1 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1,11 +1,13 @@ |
| package edge |
| import ( |
| + "context" |
| "errors" |
| "io" |
| "net" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| "path/filepath" |
| "strings" |
| "syscall" |
| @@ -13,6 +15,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| func openReg(t *testing.T) *registry.Registry { |
| @@ -254,6 +257,188 @@ func (f *fakeActivator) RetireExcept(keep map[string]bool) { |
| f.retired = append(f.retired, keep) |
| } |
| +// testEdgeWithStore builds an Edge with one route already loaded and static |
| +// serving (the plumbing kind=status shares — see EnableStatic's doc comment) |
| +// wired to a fresh, empty DirStore. It returns the store (so a test can Put |
| +// bucket objects straight into it) and the Edge itself as an http.Handler: |
| +// Edge.ServeHTTP dispatches on the request's Host header, so tests drive it |
| +// with httptest.NewRecorder + httptest.NewRequest — no real listener, hence |
| +// no unix-socket/EPERM sandbox concerns (contrast TestUnixSocketUpstream). |
| +func testEdgeWithStore(t *testing.T, rt registry.Route) (release.Store, http.Handler) { |
| + t.Helper() |
| + reg := openReg(t) |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) |
| + // The status class doesn't verify signatures (unlike static), but |
| + // EnableStatic's signature still asks for a pubkey — any well-formed one |
| + // works since it's never used on this path. |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + return store, e |
| +} |
| + |
| +func TestStatusRouteServesBucketBacked(t *testing.T) { |
| + s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + s.Put(context.Background(), "status/fleet.json", []byte(`{"generated_at":"x"}`), "") |
| + s.Put(context.Background(), "status/index.html", []byte("<title>Fleet status</title>"), "") |
| + for path, want := range map[string]string{"/": "Fleet status", "/fleet.json": "generated_at"} { |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil)) |
| + if rec.Code != 200 || !strings.Contains(rec.Body.String(), want) { |
| + t.Errorf("%s: %d %q", path, rec.Code, rec.Body.String()) |
| + } |
| + } |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/boxes/anything", nil)) |
| + if rec.Code != 404 { |
| + t.Errorf("non-allowlisted path must 404, got %d", rec.Code) |
| + } |
| +} |
| + |
| +// TestStatusRouteContentTypes pins the three content types the brief names: |
| +// a client (or the console iframe embedding the page) depends on these to |
| +// render the HTML instead of downloading it, parse the JSON, and apply the |
| +// stylesheet. |
| +func TestStatusRouteContentTypes(t *testing.T) { |
| + s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + s.Put(context.Background(), "status/index.html", []byte("<html></html>"), "") |
| + s.Put(context.Background(), "status/fleet.json", []byte(`{}`), "") |
| + s.Put(context.Background(), "status/status.css", []byte("body{}"), "") |
| + |
| + for path, wantPrefix := range map[string]string{ |
| + "/": "text/html", |
| + "/fleet.json": "application/json", |
| + "/status.css": "text/css", |
| + } { |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test"+path, nil)) |
| + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, wantPrefix) { |
| + t.Errorf("%s: Content-Type = %q, want prefix %q", path, ct, wantPrefix) |
| + } |
| + } |
| +} |
| + |
| +// TestStatusRouteCacheServesStaleOnStoreError proves the two halves of the |
| +// per-key cache's contract: inside the TTL, a mutated store object is NOT |
| +// picked up (the cache, not the bucket, is the source of truth); after the |
| +// TTL, a store error serves the last-fetched copy rather than failing the |
| +// request — the status page outliving a bucket blip is the point (see the |
| +// statusHandler doc comment in edge.go). |
| +func TestStatusRouteCacheServesStaleOnStoreError(t *testing.T) { |
| + rt := registry.Route{Host: "status.test", Kind: "status", Status: "ready"} |
| + reg := openReg(t) |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + storeDir := filepath.Join(t.TempDir(), "store") |
| + store := release.NewDirStore(storeDir) |
| + _, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("generate key: %v", err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("parse public key: %v", err) |
| + } |
| + e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) |
| + if err := e.Reload(); err != nil { |
| + t.Fatalf("Reload: %v", err) |
| + } |
| + if _, err := store.Put(context.Background(), "status/fleet.json", []byte("first"), ""); err != nil { |
| + t.Fatalf("seed put: %v", err) |
| + } |
| + |
| + sh := e.statusHandlerFor(rt).(*statusHandler) |
| + sh.ttl = 20 * time.Millisecond |
| + fakeNow := time.Now() |
| + sh.now = func() time.Time { return fakeNow } |
| + |
| + rec := httptest.NewRecorder() |
| + sh.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/fleet.json", nil)) |
| + if rec.Code != 200 || rec.Body.String() != "first" { |
| + t.Fatalf("first fetch = %d %q, want 200 %q", rec.Code, rec.Body.String(), "first") |
| + } |
| + |
| + // Mutate the store underneath the cache, but stay inside the TTL: the |
| + // cached copy must still be served, proving the cache isn't a pass-through. |
| + if _, err := store.Put(context.Background(), "status/fleet.json", []byte("second"), etagOfFile(t, storeDir, "status/fleet.json")); err != nil { |
| + t.Fatalf("mutate put: %v", err) |
| + } |
| + rec = httptest.NewRecorder() |
| + sh.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/fleet.json", nil)) |
| + if rec.Code != 200 || rec.Body.String() != "first" { |
| + t.Fatalf("inside-TTL fetch = %d %q, want cached %q", rec.Code, rec.Body.String(), "first") |
| + } |
| + |
| + // Advance the clock seam past the TTL: the handler must re-read the store |
| + // and pick up the mutation. |
| + fakeNow = fakeNow.Add(21 * time.Millisecond) |
| + rec = httptest.NewRecorder() |
| + sh.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/fleet.json", nil)) |
| + if rec.Code != 200 || rec.Body.String() != "second" { |
| + t.Fatalf("post-TTL fetch = %d %q, want refreshed %q", rec.Code, rec.Body.String(), "second") |
| + } |
| + |
| + // Now break the store (remove the underlying file, simulating a bucket |
| + // error) and advance past the TTL again: with a stale copy in hand, the |
| + // handler must keep serving it rather than 503ing. |
| + fakeNow = fakeNow.Add(21 * time.Millisecond) |
| + if err := os.Remove(filepath.Join(storeDir, "status", "fleet.json")); err != nil { |
| + t.Fatalf("remove backing file: %v", err) |
| + } |
| + rec = httptest.NewRecorder() |
| + sh.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/fleet.json", nil)) |
| + if rec.Code != 200 || rec.Body.String() != "second" { |
| + t.Fatalf("stale-on-error fetch = %d %q, want stale %q", rec.Code, rec.Body.String(), "second") |
| + } |
| +} |
| + |
| +// TestStatusRouteEmptyStoreServes503 covers the honest-failure half: a key |
| +// that has NEVER been successfully fetched, hit with a store error (here: |
| +// the object was never published), must 503 rather than fabricate a body. |
| +func TestStatusRouteEmptyStoreServes503(t *testing.T) { |
| + _, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://status.test/fleet.json", nil)) |
| + if rec.Code != http.StatusServiceUnavailable { |
| + t.Fatalf("empty-store fetch = %d, want 503", rec.Code) |
| + } |
| +} |
| + |
| +// etagOfFile reads a DirStore-backed key straight off disk to recover its |
| +// current etag for a replace-only Put — the same read-etag-then-Put dance |
| +// internal/status/fleet.go's putOverwrite does against the real interface; |
| +// done directly here since the test wants to mutate underneath an existing |
| +// cache, not through it. |
| +func etagOfFile(t *testing.T, root, key string) string { |
| + t.Helper() |
| + _, etag, err := release.NewDirStore(root).Get(context.Background(), key) |
| + if err != nil { |
| + t.Fatalf("read etag for %s: %v", key, err) |
| + } |
| + return etag |
| +} |
| + |
| func TestHibernatingRouteGoesThroughActivator(t *testing.T) { |
| backend, _ := hostEchoBackend(t, "woken") |
| addr := backend.Listener.Addr().String() |
diff --git a/internal/edge/status.go b/internal/edge/status.go| new file mode 100644 |
| index 000000000..812602b85 |
| --- /dev/null |
| +++ b/internal/edge/status.go |
| @@ -0,0 +1,137 @@ |
| +// The status serving class (routes with Kind=="status"): the fleet status |
| +// page's three published objects (see internal/status's PublishFleet), |
| +// served straight out of the deployment bucket behind a per-key, |
| +// short-lived in-memory cache. Modeled on static.go's staticSite but much |
| +// simpler: PublishFleet overwrites the same three keys in place every |
| +// publish cycle (no signed, versioned channel pointer to resolve), so there |
| +// is no "which version" question — just "read the key, cache it briefly, |
| +// serve it, and don't fall over when the bucket hiccups." |
| +package edge |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "path" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// statusCacheTTL is how long a fetched status object is trusted before the |
| +// handler re-reads the store — mirrors staticRecheck's "trust briefly, then |
| +// recheck" tradeoff, tuned loose because the backing data only changes every |
| +// 5 minutes (PublishFleet's publish cadence per the design spec). |
| +const statusCacheTTL = 60 * time.Second |
| + |
| +// statusRoutes is the fleet status page's fixed, allowlisted menu: the |
| +// public path a browser requests, mapped to the bucket key PublishFleet |
| +// wrote it under and the Content-Type to serve it with. Unlike static.go's |
| +// staticSite (an arbitrary extracted file tree), the status route never |
| +// serves anything outside this map — a request for any other path 404s, |
| +// same as an unlisted key would on a real file server. |
| +var statusRoutes = map[string]struct { |
| + key string |
| + ctype string |
| +}{ |
| + "/": {"status/index.html", "text/html; charset=utf-8"}, |
| + "/fleet.json": {"status/fleet.json", "application/json"}, |
| + "/status.css": {"status/status.css", "text/css; charset=utf-8"}, |
| +} |
| + |
| +// statusCached is one fetched-and-cached bucket object. |
| +type statusCached struct { |
| + body []byte |
| + ctype string |
| + fetched time.Time |
| +} |
| + |
| +// statusHandler serves the fleet status page's three files out of a |
| +// release.Store, cached per key. The cache is the point of this handler, not |
| +// an optimization bolted on afterward: the fleet status page exists so an |
| +// operator can tell whether the fleet is healthy, which means IT must not go |
| +// dark the instant the deployment bucket has a blip. So the contract is |
| +// asymmetric by design — |
| +// |
| +// - inside the TTL: always serve the cached copy, even if the store has |
| +// moved on (bounds bucket read volume/cost to once per key per TTL). |
| +// - outside the TTL with a store error and a cached copy in hand: still |
| +// serve the (now stale) cached copy rather than fail the request — the |
| +// status page outliving a transient bucket error is the whole point; |
| +// fleet.json's own staleness bands (internal/status/fleet.go) are what |
| +// tell the operator the DATA is old, so this handler doesn't need to |
| +// lie by omission (503) to be honest — the page itself says "stale". |
| +// - outside the TTL with a store error and NO cached copy (nothing has |
| +// ever been successfully fetched): 503. There is nothing honest left to |
| +// serve. |
| +type statusHandler struct { |
| + store release.Store |
| + // ttl and now are both overridable by tests (a tiny ttl plus a fake |
| + // clock lets a TTL-expiry test run in microseconds instead of sleeping |
| + // 60 real seconds); production always gets statusCacheTTL and time.Now. |
| + ttl time.Duration |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + cache map[string]statusCached |
| +} |
| + |
| +// newStatusHandler builds the handler for one status route. store is shared |
| +// with the static serving class (see EnableStatic's doc comment) — the |
| +// status page has no per-route config of its own (no app, no channel), so |
| +// unlike newStaticHandler this takes no registry.Route. |
| +func newStatusHandler(store release.Store) *statusHandler { |
| + return &statusHandler{ |
| + store: store, |
| + ttl: statusCacheTTL, |
| + now: time.Now, |
| + cache: make(map[string]statusCached), |
| + } |
| +} |
| + |
| +func (h *statusHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + rt, ok := statusRoutes[path.Clean(r.URL.Path)] |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body, ctype, err := h.get(r.Context(), rt.key, rt.ctype) |
| + if err != nil { |
| + log.Printf("edge: status %s: %v", rt.key, err) |
| + http.Error(w, "status page unavailable", http.StatusServiceUnavailable) |
| + return |
| + } |
| + w.Header().Set("Content-Type", ctype) |
| + w.Write(body) |
| +} |
| + |
| +// get implements the cache contract documented on statusHandler: fresh cache |
| +// hit wins outright (no store touch at all — that's what bounds read |
| +// volume); otherwise it tries the store, falling back to a stale cached copy |
| +// on error, and only fails when there is nothing cached to fall back to. |
| +func (h *statusHandler) get(ctx context.Context, key, ctype string) ([]byte, string, error) { |
| + h.mu.Lock() |
| + c, cached := h.cache[key] |
| + fresh := cached && h.now().Sub(c.fetched) < h.ttl |
| + h.mu.Unlock() |
| + if fresh { |
| + return c.body, c.ctype, nil |
| + } |
| + |
| + body, _, err := h.store.Get(ctx, key) |
| + if err != nil { |
| + if cached { |
| + log.Printf("edge: status %s: store read failed outside TTL (%v) — serving stale copy fetched at %s", |
| + key, err, c.fetched.Format(time.RFC3339)) |
| + return c.body, c.ctype, nil |
| + } |
| + return nil, "", err |
| + } |
| + |
| + entry := statusCached{body: body, ctype: ctype, fetched: h.now()} |
| + h.mu.Lock() |
| + h.cache[key] = entry |
| + h.mu.Unlock() |
| + return body, ctype, nil |
| +} |