edge: X-Carlos-Version on versioned routes — the deploy watch's proof (spec §7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+86
−0
internal/edge/edge.go+23 −0internal/edge/edge_test.go+63 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 88308d2d9..5f5f873a3 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -295,6 +295,15 @@ func (e *Edge) Reload() error { |
| default: |
| continue // no backend to reach |
| } |
| + // The deploy watch's proof and the human "verify with the binary you |
| + // built" tell (carlos-deploy spec §7): the version this route is |
| + // serving, from the row the adopt pass stamps AFTER the restart. |
| + // Server:-header-grade disclosure, ruled public 2026-08-08. Reload |
| + // rebuilds these handlers on every registry change — Fingerprint is |
| + // content-accurate since #15 closed, so the header can't go stale. |
| + if t.Version != "" { |
| + proxy = versionHeader(t.Version, proxy) |
| + } |
| // Hibernation is per-host: only an instance whose route carries the |
| // hibernate flag is activator-managed; unflagged instances and sibling |
| // services are proxied straight. With an activator wired but no route |
| @@ -864,6 +873,20 @@ func (e *Edge) Watch(stop <-chan struct{}) { |
| } |
| } |
| +// versionHeader wraps next so every response also carries |
| +// X-Carlos-Version: ver — the deploy watch's proof (carlos-deploy spec §7) |
| +// that this route is serving the version it was told to. It wraps the |
| +// finished handler rather than reaching inside it, so it never interferes |
| +// with a ReverseProxy's ModifyResponse purge-header hook or the activator's |
| +// dispatch — both already ran (or will run inside next) by the time this |
| +// sets the header. |
| +func versionHeader(ver string, next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", ver) |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| // unixProxy reverse-proxies to a backend listening on a unix socket, |
| // preserving the client's Host header (so the backend sees the real hostname) |
| // and passing WebSocket upgrades straight through. |
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go| index 59008f1ef..80d00b007 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1464,3 +1464,66 @@ func TestRecordingWriterIgnoresInformational(t *testing.T) { |
| t.Fatal("response written after a 103 was not cached") |
| } |
| } |
| + |
| +// getHostHeader is getHost's sibling for tests that need to inspect a |
| +// response header rather than the body — the deploy watch's proof |
| +// (X-Carlos-Version) lives there, not in the body. |
| +func getHostHeader(t *testing.T, edgeURL, host string) *http.Response { |
| + t.Helper() |
| + req, err := http.NewRequest("GET", edgeURL+"/", 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 (host %s): %v", edgeURL, host, err) |
| + } |
| + defer resp.Body.Close() |
| + io.Copy(io.Discard, resp.Body) |
| + return resp |
| +} |
| + |
| +func TestVersionHeaderOnVersionedRoute(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "hello from backend") |
| + addr := backend.Listener.Addr().String() |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "service", Addr: addr, Status: "ready", Version: "abc1234"}); 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, "app.example.com") |
| + if got := resp.Header.Get("X-Carlos-Version"); got != "abc1234" { |
| + t.Fatalf("X-Carlos-Version = %q, want %q", got, "abc1234") |
| + } |
| +} |
| + |
| +func TestNoVersionHeaderOnUnversionedRoute(t *testing.T) { |
| + backend, _ := hostEchoBackend(t, "hello from backend") |
| + addr := backend.Listener.Addr().String() |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "app.example.com", Kind: "service", Addr: addr, 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, "app.example.com") |
| + if got, ok := resp.Header["X-Carlos-Version"]; ok { |
| + t.Fatalf("X-Carlos-Version present (%v), want absent for an unversioned route", got) |
| + } |
| +} |