carlos / platform Public

edge: X-Carlos-Version on versioned routes — the deploy watch's proof (spec §7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 30c99fabc5e641238357104925df0c6bb3f1fb21 parent a74e48b
2 files changed, +86 −0
  • internal/edge/edge.go +23 −0
  • internal/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)
+ }
+}