carlos / platform Public

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>
Paul Campbell 080c4f18b0b2a3188ed1fbfcee48205759d63460 parent 5d5098e
3 files changed, +345 −0
  • internal/edge/edge.go +23 −0
  • internal/edge/edge_test.go +185 −0
  • internal/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
+}