carlos / platform Public

edge: a static route stamps the version whose tree answered the request

Static routes are never adopted, so registry.Route.Version stays empty and
edge.go's versionHeader wrapper never applies — carlosframework.com served
the new site with no X-Carlos-Version at all (#112). The handler knows the
pointer it resolved, so it stamps its own: per-request truth, better than the
route-table stamp binary apps get.

currentDir now returns the version alongside the dir so ServeHTTP can stamp
without re-taking mu. The degraded path names the CACHED version, never the
head it could not reach; the pre-EnableStatic 404 stays unstamped, because no
version exists there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 854d638e74fb8bac9519a0162bd4999afdc81592 parent c8b2eed
2 files changed, +201 −13
  • internal/edge/static.go +39 −13
  • internal/edge/static_test.go +162 −0
diff --git a/internal/edge/static.go b/internal/edge/static.go
index 43b134cfe..39fa9c4df 100644
--- a/internal/edge/static.go
+++ b/internal/edge/static.go
@@ -141,12 +141,26 @@ func newStaticHandler(rt registry.Route, store release.Store, pub ed25519.Public
}
func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- dir, err := s.currentDir(r.Context())
+ dir, version, err := s.currentDir(r.Context())
if err != nil {
+ // Nothing has ever resolved here, so there is no version to name:
+ // this 502 stays unstamped rather than claiming a build it isn't
+ // serving.
log.Printf("edge: static %s/%s: %v", s.app, s.channel, err)
http.Error(w, "static site unavailable", http.StatusBadGateway)
return
}
+ // X-Carlos-Version for the static class (issue #112). Binary routes get
+ // this from the route table (edge.go, versionHeader), which the adopt
+ // pass stamps; a static route is never adopted, so the truth lives here
+ // instead — and it is PER-REQUEST truth, the version whose tree answers
+ // THIS request, which is strictly better than a table stamp that can lag
+ // the process it describes. That includes the degraded path: currentDir
+ // returns the CACHED version when the pointer is unreadable, and this
+ // stamps what is actually served, never the pointer's unreachable head.
+ // Set before any body is written, so it lands on the tree's own 404s too
+ // (the handler serving that tree IS a version).
+ w.Header().Set("X-Carlos-Version", version)
s.serveTree(dir, w, r)
}
@@ -161,16 +175,26 @@ func (s *staticSite) appDir() string {
return filepath.Join(s.cacheDir, s.acct, s.app)
}
-// currentDir returns the directory to serve this request from, re-resolving
-// the channel pointer when the cache window has lapsed. A resolution or
-// staging failure NEVER takes down a site that was already serving: the
-// cached version keeps answering and the pointer is retried next window.
-// Only a handler that has never resolved (nothing safe to serve) errors out.
-func (s *staticSite) currentDir(ctx context.Context) (string, error) {
+// currentDir returns the directory to serve this request from AND the version
+// that directory holds, re-resolving the channel pointer when the cache window
+// has lapsed. A resolution or staging failure NEVER takes down a site that was
+// already serving: the cached version keeps answering and the pointer is
+// retried next window. Only a handler that has never resolved (nothing safe to
+// serve) errors out.
+//
+// The version comes back with the dir rather than being read off s.version
+// afterwards so ServeHTTP can stamp X-Carlos-Version without re-taking mu (and
+// without the window in between, where a concurrent resolve could swap
+// s.version and make the header describe a different tree than the one served).
+func (s *staticSite) currentDir(ctx context.Context) (dir, version string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.version != "" && s.recheck > 0 && time.Since(s.checkedAt) < s.recheck {
- return s.dir(s.version), nil
+ // Inside the trust window: this request is answered from the cached
+ // version, and stamping that version is honest by construction — the
+ // header names what the bytes are, not what the pointer may have
+ // advanced to since the last check.
+ return s.dir(s.version), s.version, nil
}
cp, err := release.Channel(ctx, s.store, s.pub, s.acct, s.app, s.channel)
if err != nil {
@@ -186,18 +210,20 @@ func (s *staticSite) currentDir(ctx context.Context) (string, error) {
s.version = cp.Version
}
s.checkedAt = time.Now()
- return s.dir(s.version), nil
+ return s.dir(s.version), s.version, nil
}
// stale is the degraded path: keep serving the version we already verified
-// and extracted, or fail if there has never been one.
-func (s *staticSite) stale(err error) (string, error) {
+// and extracted — and name it, so the response header describes the CACHED
+// tree rather than the head this handler could not reach — or fail if there
+// has never been one.
+func (s *staticSite) stale(err error) (string, string, error) {
if s.version == "" {
- return "", err
+ return "", "", err
}
log.Printf("edge: static %s/%s: %v — still serving cached %s", s.app, s.channel, err, s.version)
s.checkedAt = time.Now() // back off: retry next recheck window, not next request
- return s.dir(s.version), nil
+ return s.dir(s.version), s.version, nil
}
// ensureExtracted makes <cacheDir>/<app>/<version>/ exist and hold the
diff --git a/internal/edge/static_test.go b/internal/edge/static_test.go
index 3bd4188ce..6b225f932 100644
--- a/internal/edge/static_test.go
+++ b/internal/edge/static_test.go
@@ -4,12 +4,14 @@ import (
"bytes"
"context"
"crypto/ed25519"
+ "errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
"github.com/carlosframework/platform/internal/registry"
@@ -37,6 +39,24 @@ func getPath(t *testing.T, base, host, path string) (int, string) {
return resp.StatusCode, string(b)
}
+// getPathResp is getPath's sibling for tests that need the response headers
+// rather than the body — X-Carlos-Version lives there, not in the tree.
+func getPathResp(t *testing.T, base, host, path string) *http.Response {
+ t.Helper()
+ req, err := http.NewRequest("GET", base+path, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET %s%s (host %s): %v", base, path, host, err)
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body)
+ return resp
+}
+
func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
t.Helper()
seedB64, pubB64, err := release.GenerateKey()
@@ -235,6 +255,148 @@ func TestEdgeServesStaticRouteWhenEnabled(t *testing.T) {
}
}
+// --- X-Carlos-Version for static routes (issue #112) --------------------
+//
+// A static route is never adopted, so registry.Route.Version is always empty
+// and edge.go's versionHeader wrapper never applies. The handler stamps its
+// own — the version whose tree answered the request.
+
+// TestStaticStampsTheVersionItServes: every response out of the site tree
+// carries the version that tree is, including the tree's own 404s, and the
+// stamp follows a pointer advance.
+func TestStaticStampsTheVersionItServes(t *testing.T) {
+ store, _, priv, srv := staticFixture(t)
+
+ resp := getPathResp(t, srv.URL, "site.example.com", "/")
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET / = %d, want 200", resp.StatusCode)
+ }
+ if got := resp.Header.Get("X-Carlos-Version"); got != "v1" {
+ t.Fatalf("X-Carlos-Version = %q, want %q", got, "v1")
+ }
+
+ // A 404 from inside the tree is still an answer from a version — the
+ // deploy watch polls "/" and a site whose root is a 404 must still be
+ // diagnosable as "serving v1, but HTTP 404" rather than as an old edge.
+ resp = getPathResp(t, srv.URL, "site.example.com", "/missing.html")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode)
+ }
+ if got := resp.Header.Get("X-Carlos-Version"); got != "v1" {
+ t.Fatalf("404 X-Carlos-Version = %q, want %q", got, "v1")
+ }
+
+ shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"})
+ if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ resp = getPathResp(t, srv.URL, "site.example.com", "/")
+ if got := resp.Header.Get("X-Carlos-Version"); got != "v2" {
+ t.Fatalf("post-advance X-Carlos-Version = %q, want %q", got, "v2")
+ }
+}
+
+// brokenStore reads through to a real store until breakNow, then fails every
+// read — the shape that drives staticSite down its degraded path (an
+// unreadable channel pointer) after it has already resolved and extracted.
+type brokenStore struct {
+ release.Store
+ mu sync.Mutex
+ broken bool
+}
+
+func (b *brokenStore) breakNow() {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.broken = true
+}
+
+func (b *brokenStore) failing() bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.broken
+}
+
+func (b *brokenStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if b.failing() {
+ return nil, "", errors.New("store unreachable")
+ }
+ return b.Store.Get(ctx, key)
+}
+
+func (b *brokenStore) List(ctx context.Context, prefix string) ([]string, error) {
+ if b.failing() {
+ return nil, errors.New("store unreachable")
+ }
+ return b.Store.List(ctx, prefix)
+}
+
+// TestStaticDegradedStampsTheCachedVersion: when the pointer goes unreadable
+// the handler keeps serving the version it already verified — and must stamp
+// THAT, never the head it could not reach. A watch that saw the new version
+// on a response whose bytes are the old one would be exactly the false
+// "verified" this header exists to prevent.
+func TestStaticDegradedStampsTheCachedVersion(t *testing.T) {
+ ctx := context.Background()
+ dir := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
+ priv, pub := testKeys(t)
+ shipSite(t, dir, "site", "v1", map[string]string{"index.html": "home v1"})
+ if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote v1: %v", err)
+ }
+
+ bs := &brokenStore{Store: dir}
+ rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
+ h := newStaticHandler(rt, bs, pub, filepath.Join(t.TempDir(), "cache"))
+ h.(*staticSite).recheck = 0 // re-check the pointer on every request
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
+ t.Fatalf("pre-break X-Carlos-Version = %q, want %q", got, "v1")
+ }
+
+ // The pointer advances to v2 and the store goes away in the same breath:
+ // the head is v2, the bytes on disk are v1.
+ shipSite(t, dir, "site", "v2", map[string]string{"index.html": "home v2"})
+ if err := release.Promote(ctx, dir, priv, release.OpsAccount, "site", "canary/x", "v2"); err != nil {
+ t.Fatalf("Promote v2: %v", err)
+ }
+ bs.breakNow()
+
+ code, body := getPath(t, srv.URL, "site.example.com", "/")
+ if code != http.StatusOK || body != "home v1" {
+ t.Fatalf("degraded GET / = %d %q, want 200 %q", code, body, "home v1")
+ }
+ if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
+ t.Fatalf("degraded X-Carlos-Version = %q, want %q (the CACHED version, not the unreachable head)", got, "v1")
+ }
+}
+
+// TestStaticUnconfiguredHasNoVersionHeader: the pre-EnableStatic 404 is not a
+// version — the route exists (so its cert can be minted) before any bytes can
+// flow, and there is nothing to name.
+func TestStaticUnconfiguredHasNoVersionHeader(t *testing.T) {
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ srv := httptest.NewServer(e)
+ defer srv.Close()
+
+ resp := getHostHeader(t, srv.URL, "site.example.com")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("unconfigured GET = %d, want 404", resp.StatusCode)
+ }
+ if got, ok := resp.Header["X-Carlos-Version"]; ok {
+ t.Fatalf("X-Carlos-Version present (%v) on the unconfigured 404 — no version exists there", got)
+ }
+}
+
// legacyTripwire fails the test if the edge asks the store for anything under
// one app's LEGACY unqualified prefix. The release package has its own; the
// edge needs one of its own too, because a tripwire only guards the paths a