carlos / platform Public

Merge pull request #120 from carlosframework/static-header

Static routes carry X-Carlos-Version; carlos deploy watches static apps (#112)
Paul Campbell 916cd6b3f4669b410e6f2653aa9d95f076b7b03e parent de23f6aparent 3c1287b
6 files changed, +754 −37
  • deploy.go +135 −23
  • deploy_e2e_test.go +208 −0
  • deploy_test.go +140 −0
  • internal/edge/edge.go +10 −1
  • internal/edge/static.go +39 −13
  • internal/edge/static_test.go +222 −0
diff --git a/deploy.go b/deploy.go
index 797b786a1..08da41667 100644
--- a/deploy.go
+++ b/deploy.go
@@ -36,9 +36,9 @@ func runDeploy(args []string) {
ver := fs.String("version", "", "version (default: git rev-parse --short HEAD)")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
- channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances; error if they disagree)")
- hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host")
- timeout := fs.Duration("timeout", 120*time.Second, "how long to watch the URL before failing")
+ channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances, or its one channel if it has no instances; error if ambiguous)")
+ hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host (required for an app with no instances, e.g. a static site)")
+ timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing")
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
log.Fatal("usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " +
@@ -65,26 +65,10 @@ func runDeploy(args []string) {
if err != nil {
log.Fatalf("deploy: list instances: %v", err)
}
- var channel string
- var hosts []string
- if *channelFlag != "" {
- // Explicit channel: no ambiguity to resolve. Watch the instances that
- // follow it (none is fine — promote still lands; the watch is skipped
- // with a note, since there is nothing that will serve this channel).
- // -host is still validated against the instance list even here: a
- // typo'd host must be its own error, not silently absorbed into "no
- // instance follows this channel" once ship and promote have already
- // run (finding 3, task-9 review).
- channel = *channelFlag
- hosts, err = filterChannelHosts(recs, channel, *hostFlag)
- if err != nil {
- log.Fatalf("deploy: %v", err)
- }
- } else {
- channel, hosts, err = resolveDeployChannel(recs, *hostFlag)
- if err != nil {
- log.Fatalf("deploy: %v", err)
- }
+ channel, hosts, staticDeploy, err := deployTargets(recs, *kind, *channelFlag, *hostFlag,
+ func() ([]apiChannel, error) { return sess.channels(ctx, acct, *app) })
+ if err != nil {
+ log.Fatalf("deploy: %v", err)
}
// printBanner=false: the "using ..." line already printed above, before
// the instance list — so a listing failure still names the console —
@@ -105,6 +89,18 @@ func runDeploy(args []string) {
mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel)
return
}
+ if staticDeploy {
+ // The watch rule is unchanged — version match AND 2xx — but the wait
+ // needs explaining: a static route trusts a resolved channel pointer
+ // for a whole recheck window, and the probe's cache-bust does NOT
+ // shorten that (it defeats the edge's HTTP response cache, not the
+ // handler's own trust window). The default timeout already outlasts
+ // one window (defaultDeployTimeout, pinned by test), so nothing here
+ // adjusts the clock — the operator just gets told why "promoted" can
+ // sit in front of the old page for a minute.
+ mutedf(stdout, "watching %s — a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
+ strings.Join(hosts, ", "), int(staticRecheckWindow.Seconds()))
+ }
targets := make([]watchTarget, 0, len(hosts))
for _, h := range hosts {
targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h)})
@@ -114,6 +110,120 @@ func runDeploy(args []string) {
}
}
+// defaultDeployTimeout is how long -timeout watches a URL before failing,
+// unless the operator says otherwise. It has to outlast one static recheck
+// window with room to spare, or a static deploy that was always going to land
+// times out first — TestDefaultDeployTimeoutOutlastsAStaticRecheck pins that,
+// so lowering this without raising the watch is a test failure, not a
+// mysterious timeout in production.
+const defaultDeployTimeout = 120 * time.Second
+
+// staticRecheckWindow mirrors internal/edge/static.go's staticRecheck: how
+// long a static route trusts a resolved channel pointer before re-reading it.
+// It is stated here rather than imported because it is a property of the EDGE
+// being watched — possibly an older build than this CLI — so sharing the
+// constant would promise a match this process cannot know it has.
+const staticRecheckWindow = 60 * time.Second
+
+// deployTargets decides what this deploy promotes to and what it then watches,
+// and reports whether it took the static path. It is a function, not a stretch
+// of runDeploy, so the decision can be tested without a console: `channels` is
+// called only where the static path actually needs it (a lazy closure over
+// apiSession.channels in the verb; a stub in tests).
+//
+// Zero instance records mean different things per kind, and the split matters:
+// - static: there are no records BY DESIGN — the edge serves the route off
+// the channel pointer and nothing is ever adopted — so host and channel
+// have to come from -host and -channel/the app's channels (issue #112).
+// - binary: the records simply aren't there yet, which is the pre-existing
+// "no instance follows this channel — promoted, nothing to watch" shape.
+// It keeps its old behaviour verbatim: this branch must not demand -host
+// from a binary app, nor tell it about static recheck windows.
+func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
+ channels func() ([]apiChannel, error)) (channel string, hosts []string, static bool, err error) {
+ if len(recs) == 0 && kind == "static" {
+ hosts, err := staticDeployHosts(hostFlag)
+ if err != nil {
+ return "", nil, true, err
+ }
+ if channelFlag != "" {
+ return channelFlag, hosts, true, nil
+ }
+ chans, err := channels()
+ if err != nil {
+ return "", nil, true, fmt.Errorf("list channels: %w", err)
+ }
+ channel, err := staticDeployChannel(chans)
+ if err != nil {
+ return "", nil, true, err
+ }
+ return channel, hosts, true, nil
+ }
+ if channelFlag != "" {
+ // Explicit channel: no ambiguity to resolve. Watch the instances that
+ // follow it (none is fine — promote still lands; the watch is skipped
+ // with a note, since there is nothing that will serve this channel).
+ // -host is still validated against the instance list even here: a
+ // typo'd host must be its own error, not silently absorbed into "no
+ // instance follows this channel" once ship and promote have already
+ // run (finding 3, task-9 review).
+ hosts, err := filterChannelHosts(recs, channelFlag, hostFlag)
+ if err != nil {
+ return "", nil, false, err
+ }
+ return channelFlag, hosts, false, nil
+ }
+ channel, hosts, err = resolveDeployChannel(recs, hostFlag)
+ return channel, hosts, false, err
+}
+
+// staticDeployHosts resolves the hosts a NO-INSTANCE deploy watches. There is
+// nothing to resolve them FROM — that is the whole point — so the flag is
+// required, and its absence is refused before ship and promote run rather
+// than after, when the only remaining honest report would be "promoted,
+// nothing to watch".
+func staticDeployHosts(hostFlag string) ([]string, error) {
+ if hostFlag == "" {
+ return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
+ "Name the hostname the site answers on, so the deploy can watch it:\n" +
+ " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
+ }
+ return []string{hostFlag}, nil
+}
+
+// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
+// from the app's own channels — the only record of what this app releases
+// onto once instance records are off the table. One channel is unambiguous;
+// zero and several are both for a human to settle with -channel, in
+// resolveDeployChannel's style: name what was found, name the flag that
+// breaks the tie, guess at nothing.
+//
+// A channel row can also carry an Err: the console's channels endpoint emits
+// {"channel": name, "error": ...} for a pointer it could not verify. Reading
+// only the name would resolve to — and then promote onto — a channel whose
+// pointer is unreadable or unsigned, so the one-channel case refuses on it.
+func staticDeployChannel(chans []apiChannel) (string, error) {
+ names := make([]string, 0, len(chans))
+ for _, ch := range chans {
+ names = append(names, ch.Channel)
+ }
+ if len(names) == 1 {
+ if chans[0].Err != "" {
+ return "", fmt.Errorf("this app's only channel %s is unreadable (%s) — resolving to it would promote onto "+
+ "a pointer the console cannot verify; fix it, or name a channel with -channel", chans[0].Channel, chans[0].Err)
+ }
+ return names[0], nil
+ }
+ if len(names) == 0 {
+ return "", errors.New("this app has no channels yet, and no instances to resolve one from — " +
+ "pass -channel to name the channel to promote onto")
+ }
+ sort.Strings(names)
+ return "", fmt.Errorf("this app has several channels and no instances to resolve one from — "+
+ "pass -channel to pick one: %s", strings.Join(names, ", "))
+}
+
// requireDeployAPISession resolves signed-in mode for deploy, refusing
// direct-bucket mode with deploy's OWN message — not instances_cli.go's
// requireAPISession, whose hardcoded text ("carlos instances %s works
@@ -155,6 +265,8 @@ func gitShortHead() string {
// left to resolve. With no host, every instance must already agree on one
// channel — several distinct channels is treated as ambiguity for a human to
// break with -channel or -host, not something this verb should guess at.
+// runDeploy no longer reaches this with zero records (that is the
+// no-instance/static path above), but the check stays as its own guard.
func resolveDeployChannel(recs []apiInstanceRecord, hostFlag string) (channel string, hosts []string, err error) {
if len(recs) == 0 {
return "", nil, errors.New("no instances — create one: carlos instances create -app <a> -host <h> -channel <ch>")
diff --git a/deploy_e2e_test.go b/deploy_e2e_test.go
index 64a09c242..db2f6e6c9 100644
--- a/deploy_e2e_test.go
+++ b/deploy_e2e_test.go
@@ -28,6 +28,7 @@ package main
import (
"context"
+ "io"
"net/http"
"net/http/httptest"
"os"
@@ -38,10 +39,217 @@ import (
"time"
"github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
+// assertServes checks that the edge under test answers "/" with the expected
+// site content AND stamps the expected version — the pair is the point: a
+// header that agreed with nothing on disk would be the false "verified" the
+// whole feature exists to prevent.
+func assertServes(t *testing.T, origin, wantBody, wantVersion string) {
+ t.Helper()
+ resp, err := http.Get(origin + "/")
+ if err != nil {
+ t.Fatalf("GET %s/: %v", origin, err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read body: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK || string(body) != wantBody {
+ t.Fatalf("GET %s/ = %d %q, want 200 %q", origin, resp.StatusCode, body, wantBody)
+ }
+ if got := resp.Header.Get("X-Carlos-Version"); got != wantVersion {
+ t.Fatalf("X-Carlos-Version = %q, want %q (the header must name the bytes it served)", got, wantVersion)
+ }
+}
+
+// TestE2EStaticDeployThroughConsole is the same verb for the class that has
+// no instance records at all (issue #112): a static site, served by the edge
+// off the channel pointer, never adopted. Nothing can be resolved from
+// instances here, so the deploy takes its host from -host and its channel
+// from the app's own channels — and the watch works at all only because the
+// static handler now stamps X-Carlos-Version (internal/edge/static.go).
+func TestE2EStaticDeployThroughConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ addr = "paul@keymail.dev"
+ app = "sitely"
+ channel = "canary/site"
+ host = "sitely.example.test"
+ )
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid",
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // The site's edge is the REAL one: an edge.Edge with static serving
+ // enabled over the same store and pubkey the console writes through, and
+ // its static route pointed at the same channel. So the header the watch
+ // judges is the one internal/edge/static.go actually stamps — the two
+ // halves of #112 meeting, rather than each being tested against a mock of
+ // the other. The registry here is the EDGE's own, in the test's temp dir;
+ // checkNoRegistry still guards the one the CLI must never open.
+ //
+ // SetStaticRecheck(0) collapses the recheck window: the window is real in
+ // production (and is what the muted watch line is about) but waiting one
+ // out here would buy nothing but a minute per run.
+ edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { edgeReg.Close() })
+ ed, err := edge.New(edgeReg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache"))
+ ed.SetStaticRecheck(0)
+ edgeSrv := httptest.NewServer(ed)
+ t.Cleanup(edgeSrv.Close)
+ // The edge matches on the request's Host with the port stripped, and the
+ // watch dials this loopback server, so the route's host is 127.0.0.1 —
+ // the public name lives in -host, which is what gets printed.
+ if err := edgeReg.Upsert(registry.Route{
+ Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := ed.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string {
+ if h != host {
+ t.Errorf("watch dialled an unexpected host %q, want %q", h, host)
+ }
+ return edgeSrv.URL
+ }
+ t.Cleanup(func() { watchOrigin = origOrigin })
+
+ siteDir := filepath.Join(tmp, "site")
+ if err := os.MkdirAll(siteDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- run 1: the app has no channels yet, so -channel names one --------
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-host", host, "-timeout", "30s", siteDir})
+ out := buf.String()
+ for _, wantLine := range []string{
+ "shipped " + app + " sha0001",
+ "promoted " + app + " sha0001 -> " + channel,
+ "re-checks its channel",
+ "live https://" + host,
+ } {
+ if !strings.Contains(out, wantLine) {
+ t.Fatalf("static deploy stdout missing %q:\n%s", wantLine, out)
+ }
+ }
+ ptr, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, app, channel)
+ if err != nil {
+ t.Fatalf("channel after deploy: %v", err)
+ }
+ if ptr.Version != "sha0001" {
+ t.Fatalf("%s points at %q, want sha0001", channel, ptr.Version)
+ }
+ m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, app, "sha0001")
+ if err != nil {
+ t.Fatalf("manifest after deploy: %v", err)
+ }
+ if m.Kind != "static" {
+ t.Fatalf("manifest kind = %q, want static", m.Kind)
+ }
+ // The bytes and the stamp agree, straight off the real handler: "live"
+ // meant the site tree, not a header floating free of any content.
+ assertServes(t, edgeSrv.URL, "v1", "sha0001")
+
+ // --- run 2: no -channel at all. The app now has exactly one channel, so
+ // deploy resolves it from the channels API — the instance-less
+ // equivalent of resolving from an instance record.
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ buf.Reset()
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
+ "-host", host, "-timeout", "30s", siteDir})
+ out = buf.String()
+ if !strings.Contains(out, "promoted "+app+" sha0002 -> "+channel) {
+ t.Fatalf("channel was not resolved from the app's one channel:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("static deploy did not confirm the URL:\n%s", out)
+ }
+ if strings.Contains(out, edgeSrv.URL) {
+ t.Fatalf("deploy printed the test's loopback origin instead of the public URL:\n%s", out)
+ }
+ assertServes(t, edgeSrv.URL, "v2", "sha0002")
+
+ checkNoRegistry()
+}
+
// TestE2EDeployThroughConsole is the full verb: one command takes an
// unshipped binary to a URL that serves it.
func TestE2EDeployThroughConsole(t *testing.T) {
diff --git a/deploy_test.go b/deploy_test.go
index 6cfeace25..c90cffa10 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -68,6 +68,146 @@ func TestFilterChannelHosts(t *testing.T) {
}
}
+// TestStaticDeployHosts pins the no-instance refusal (issue #112): a static
+// site has no instance records to resolve a host from, so -host is required —
+// and required BEFORE ship and promote run, since afterwards the only honest
+// report left is "promoted, nothing to watch".
+func TestStaticDeployHosts(t *testing.T) {
+ hosts, err := staticDeployHosts("carlosframework.com")
+ if err != nil || len(hosts) != 1 || hosts[0] != "carlosframework.com" {
+ t.Fatalf("with -host: %v %v", hosts, err)
+ }
+ _, err = staticDeployHosts("")
+ if err == nil {
+ t.Fatal("an app with no instances and no -host must be refused")
+ }
+ for _, want := range []string{
+ "no instance records",
+ "-host carlosframework.com",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("refusal must mention %q:\n%s", want, err)
+ }
+ }
+}
+
+// TestStaticDeployChannel: one channel resolves silently; zero and several
+// are both refused, naming what was found and the flag that settles it.
+func TestStaticDeployChannel(t *testing.T) {
+ ch, err := staticDeployChannel([]apiChannel{{Channel: "canary/site"}})
+ if err != nil || ch != "canary/site" {
+ t.Fatalf("single channel: %q %v", ch, err)
+ }
+ _, err = staticDeployChannel(nil)
+ if err == nil || !strings.Contains(err.Error(), "-channel") {
+ t.Fatalf("no channels must be refused, asking for -channel: %v", err)
+ }
+ // A single channel the console could not verify is NOT a resolution: the
+ // channels endpoint reports it as a row with an error, and promoting onto
+ // it would be this verb writing over a pointer nobody can read.
+ _, err = staticDeployChannel([]apiChannel{{Channel: "canary/site", Err: "bad signature"}})
+ if err == nil {
+ t.Fatal("a single UNREADABLE channel must not resolve")
+ }
+ for _, want := range []string{"canary/site", "bad signature", "-channel"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("broken-channel refusal must mention %q:\n%s", want, err)
+ }
+ }
+ _, err = staticDeployChannel([]apiChannel{{Channel: "stable"}, {Channel: "canary/site"}})
+ if err == nil {
+ t.Fatal("several channels with no instances must be refused")
+ }
+ for _, want := range []string{"-channel", "canary/site, stable"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("refusal must mention %q (sorted, both listed):\n%s", want, err)
+ }
+ }
+}
+
+// TestDefaultDeployTimeoutOutlastsAStaticRecheck pins the one thing that
+// keeps a static deploy from failing on the clock rather than on the facts: a
+// static route trusts its resolved channel pointer for a whole recheck window
+// (internal/edge/static.go), and the deploy probe's cache-bust does not
+// shorten that — it defeats the edge's HTTP response cache, not the handler's
+// trust window. So the DEFAULT watch has to outlast one window with room for
+// the pointer read, the extract and a poll interval. Lower the default below
+// that and this test says so, instead of production saying "timed out" about a
+// deploy that had already landed.
+func TestDefaultDeployTimeoutOutlastsAStaticRecheck(t *testing.T) {
+ if min := staticRecheckWindow + 30*time.Second; defaultDeployTimeout < min {
+ t.Fatalf("defaultDeployTimeout %s must be at least %s (one %s recheck window plus slack)",
+ defaultDeployTimeout, min, staticRecheckWindow)
+ }
+}
+
+// TestDeployTargetsStaticVsBinary pins the kind gate. Zero instance records
+// mean two different things, and only ONE of them is the static shape:
+//
+// - static: no records by design — -host is required, and the channel comes
+// from -channel or the app's single channel.
+// - binary: records just haven't been created yet — the pre-existing
+// "promoted, nothing to watch" path, which must not grow a -host demand
+// or a static-flavoured watch line.
+func TestDeployTargetsStaticVsBinary(t *testing.T) {
+ noChannels := func() ([]apiChannel, error) {
+ t.Helper()
+ t.Error("the channels API was consulted on a path that must not need it")
+ return nil, nil
+ }
+ oneChannel := func() ([]apiChannel, error) {
+ return []apiChannel{{Channel: "canary/site"}}, nil
+ }
+
+ // Static, no instances: channel resolved from the app's one channel.
+ ch, hosts, static, err := deployTargets(nil, "static", "", "site.test", oneChannel)
+ if err != nil || ch != "canary/site" || len(hosts) != 1 || hosts[0] != "site.test" || !static {
+ t.Fatalf("static/no-instances: %q %v %v %v", ch, hosts, static, err)
+ }
+ // Static, no instances, explicit -channel: the channels API is not needed.
+ ch, _, static, err = deployTargets(nil, "static", "canary/other", "site.test", noChannels)
+ if err != nil || ch != "canary/other" || !static {
+ t.Fatalf("static/-channel: %q %v %v", ch, static, err)
+ }
+ // Static, no instances, no -host: refused.
+ if _, _, _, err = deployTargets(nil, "static", "canary/site", "", noChannels); err == nil {
+ t.Fatal("static with no instances and no -host must be refused")
+ }
+
+ // BINARY, no instances, explicit -channel: unchanged from before #112 —
+ // no -host demanded, no channels lookup, no static treatment, and zero
+ // hosts so runDeploy prints "promoted, nothing to watch" and exits 0.
+ ch, hosts, static, err = deployTargets(nil, "binary", "canary/dev", "", noChannels)
+ if err != nil {
+ t.Fatalf("binary/no-instances/-channel must not error: %v", err)
+ }
+ if ch != "canary/dev" || len(hosts) != 0 || static {
+ t.Fatalf("binary/no-instances/-channel: %q %v static=%v", ch, hosts, static)
+ }
+ // BINARY, no instances, no -channel: still the old "create one" refusal,
+ // never the static one.
+ _, _, static, err = deployTargets(nil, "binary", "", "", noChannels)
+ if err == nil || !strings.Contains(err.Error(), "carlos instances create") {
+ t.Fatalf("binary/no-instances must keep its own refusal: %v", err)
+ }
+ if strings.Contains(err.Error(), "static site") {
+ t.Fatalf("a binary app must never be told about static sites: %v", err)
+ }
+ if static {
+ t.Fatal("binary must never take the static path")
+ }
+
+ // With records, kind is irrelevant: the instance path is byte-identical
+ // for both kinds.
+ recs := []apiInstanceRecord{{Host: "a.test", Channel: "canary/dev"}}
+ for _, kind := range []string{"binary", "static"} {
+ ch, hosts, static, err = deployTargets(recs, kind, "", "", noChannels)
+ if err != nil || ch != "canary/dev" || len(hosts) != 1 || hosts[0] != "a.test" || static {
+ t.Fatalf("%s with records: %q %v %v %v", kind, ch, hosts, static, err)
+ }
+ }
+}
+
func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) {
var mu sync.Mutex
ver := "old1234"
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index ad2df5014..bd7aaa156 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -314,7 +314,16 @@ func (e *Edge) Reload() error {
// as the new one. A watcher that needs the fresh answer within the
// TTL window must bust the cache (e.g. a unique query string).
- if t.Version != "" {
+ // Static and status routes are excluded structurally, not because a
+ // Version is expected on them today (nothing adopts either, so the
+ // column is empty): the static handler stamps its OWN per-request
+ // version and must be the only voice on that header, and both classes
+ // have exits that are deliberately unstamped — static's "never
+ // resolved" 502 and its pre-EnableStatic 404. A stale row Version
+ // wrapping those would put a version on a response that is serving
+ // none, which is exactly the false "verified" the header exists to
+ // prevent.
+ if t.Version != "" && t.Kind != "static" && t.Kind != "status" {
proxy = versionHeader(t.Version, proxy)
}
// Hibernation is per-host: only an instance whose route carries the
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..3f74d442b 100644
--- a/internal/edge/static_test.go
+++ b/internal/edge/static_test.go
@@ -4,13 +4,16 @@ import (
"bytes"
"context"
"crypto/ed25519"
+ "errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -37,6 +40,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 +256,207 @@ 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")
+ }
+}
+
+// TestStaticCacheWindowStampsTheCachedVersion: inside the trust window the
+// handler answers from the version it last resolved WITHOUT re-reading the
+// pointer, and the stamp has to name that version rather than the head the
+// pointer has since advanced to. This is the window the deploy watch waits
+// out, and the reason it can only converge once the recheck lapses.
+func TestStaticCacheWindowStampsTheCachedVersion(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)
+ }
+
+ rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
+ h := newStaticHandler(rt, dir, pub, filepath.Join(t.TempDir(), "cache"))
+ h.(*staticSite).recheck = time.Hour // a window no test run can outlive
+ 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("first request X-Carlos-Version = %q, want %q", got, "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)
+ }
+
+ code, body := getPath(t, srv.URL, "site.example.com", "/")
+ if code != http.StatusOK || body != "home v1" {
+ t.Fatalf("in-window GET / = %d %q, want 200 %q (the window has not lapsed)", code, body, "home v1")
+ }
+ if got := getPathResp(t, srv.URL, "site.example.com", "/").Header.Get("X-Carlos-Version"); got != "v1" {
+ t.Fatalf("in-window X-Carlos-Version = %q, want %q — the header must name the bytes, not the pointer", got, "v1")
+ }
+}
+
+// TestStaticNeverResolvedHasNoVersionHeader: a handler that has never
+// resolved anything answers 502, and there is no version to name — stamping
+// one would be inventing a build out of an empty bucket.
+func TestStaticNeverResolvedHasNoVersionHeader(t *testing.T) {
+ _, pub := testKeys(t)
+ empty := release.NewDirStore(filepath.Join(t.TempDir(), "store")) // no pointer, no release
+ rt := registry.Route{Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"}
+ h := newStaticHandler(rt, empty, pub, filepath.Join(t.TempDir(), "cache"))
+ h.(*staticSite).recheck = 0
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ resp := getPathResp(t, srv.URL, "site.example.com", "/")
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("never-resolved GET / = %d, want 502", resp.StatusCode)
+ }
+ if got, ok := resp.Header["X-Carlos-Version"]; ok {
+ t.Fatalf("X-Carlos-Version present (%v) on a handler that has never served a version", got)
+ }
+}
+
+// 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