cli, edge: the no-instance deploy path is for static apps only
Review fixes on #112. The watch floor could never fire — the unset default is 120s and the floor 90s, and an explicit -timeout disabled it either way — so it and its fs.Visit helper are gone. The invariant that actually matters is that the DEFAULT outlasts one recheck window, which is now a test rather than a branch. The no-instance branch was kind-blind, so a binary app whose instance simply had not been created yet lost its 'promoted, nothing to watch' exit and, with -host, earned a static-flavoured explanation and a guaranteed timeout. The decision moves into deployTargets (testable without a console) and is gated on -kind static; binary keeps its old behaviour verbatim. Also: a single channel the console could not verify no longer resolves silently — the row's error is the refusal. The versionHeader wrap now skips static and status routes structurally, so no route-table Version can ever leak onto the two deliberately unstamped exits. The static e2e serves its polls from a REAL edge.Edge over the same store, so the header the watch judges is the one the handler stamps, and the cache-window hit and never-resolved 502 get tests of their own.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+303
−118
deploy.go+77 −76deploy_e2e_test.go+64 −28deploy_test.go+92 −13internal/edge/edge.go+10 −1internal/edge/static_test.go+60 −0
diff --git a/deploy.go b/deploy.go| index dfa90fccd..08da41667 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -65,51 +65,10 @@ func runDeploy(args []string) { |
| if err != nil { |
| log.Fatalf("deploy: list instances: %v", err) |
| } |
| - var channel string |
| - var hosts []string |
| - // No instance records at all is the static shape (issue #112): the edge |
| - // serves a static route straight off the channel pointer, nothing is ever |
| - // adopted, and no instance is ever created — so neither the channel nor |
| - // the host can come from instance records, and both have to be resolved |
| - // another way. (A binary app whose instance has simply not been created |
| - // yet lands here too, and the refusal below names that case.) |
| - noInstances := len(recs) == 0 |
| - switch { |
| - case noInstances: |
| - hosts, err = staticDeployHosts(*hostFlag) |
| - if err != nil { |
| - log.Fatalf("deploy: %v", err) |
| - } |
| - if *channelFlag != "" { |
| - channel = *channelFlag |
| - break |
| - } |
| - chans, cerr := sess.channels(ctx, acct, *app) |
| - if cerr != nil { |
| - log.Fatalf("deploy: list channels: %v", cerr) |
| - } |
| - channel, err = staticDeployChannel(chans) |
| - if err != nil { |
| - log.Fatalf("deploy: %v", err) |
| - } |
| - case *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) |
| - } |
| - default: |
| - 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 — |
| @@ -130,19 +89,17 @@ func runDeploy(args []string) { |
| mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel) |
| return |
| } |
| - if noInstances { |
| - // The watch itself is unchanged — version match AND 2xx — but its |
| - // clock has to allow for how a static route learns: it 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). So raise |
| - // the default past one full window and say why, rather than time out |
| - // honestly on a deploy that was always going to land. |
| + 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())) |
| - if *timeout < staticWatchFloor && !flagWasSet(fs, "timeout") { |
| - *timeout = staticWatchFloor |
| - } |
| } |
| targets := make([]watchTarget, 0, len(hosts)) |
| for _, h := range hosts { |
| @@ -154,7 +111,11 @@ func runDeploy(args []string) { |
| } |
| // defaultDeployTimeout is how long -timeout watches a URL before failing, |
| -// unless the operator says otherwise. |
| +// 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 |
| @@ -164,24 +125,56 @@ const defaultDeployTimeout = 120 * time.Second |
| // constant would promise a match this process cannot know it has. |
| const staticRecheckWindow = 60 * time.Second |
| -// staticWatchFloor is the shortest watch that can honestly call a static |
| -// deploy failed: one whole recheck window plus room for the pointer read, the |
| -// extract and a poll interval. Only the DEFAULT is raised to it — an explicit |
| -// -timeout is the operator's own call, and quietly overriding that would be |
| -// this verb deciding it knows better than the person who typed it. |
| -const staticWatchFloor = staticRecheckWindow + 30*time.Second |
| - |
| -// flagWasSet reports whether -name was actually typed, as against its default |
| -// being in force — the same fs.Visit idiom the tri-state flags in cli.go and |
| -// instances_cli.go use. |
| -func flagWasSet(fs *flag.FlagSet, name string) bool { |
| - set := false |
| - fs.Visit(func(f *flag.Flag) { |
| - if f.Name == name { |
| - set = true |
| +// 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) |
| } |
| - }) |
| - return set |
| + 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 |
| @@ -194,8 +187,7 @@ func staticDeployHosts(hostFlag string) ([]string, error) { |
| 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> -host carlosframework.com <site-dir>\n" + |
| - "(a binary app needs its instance first: carlos instances create -app <a> -host <h> -channel <ch>)") |
| + " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>") |
| } |
| return []string{hostFlag}, nil |
| } |
| @@ -206,12 +198,21 @@ func staticDeployHosts(hostFlag string) ([]string, error) { |
| // 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 { |
diff --git a/deploy_e2e_test.go b/deploy_e2e_test.go| index 05800acf8..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,35 @@ 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 |
| @@ -115,23 +141,41 @@ func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| t.Fatal(err) |
| } |
| - // The site's edge, on loopback: it answers the first poll of each run |
| - // with the previous version — the recheck window the muted line warns |
| - // about — and the shipped one after that. |
| - var mu sync.Mutex |
| - polls := 0 |
| - want := "" |
| - edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - mu.Lock() |
| - polls++ |
| - served := "old0000" |
| - if polls > 1 { |
| - served = want |
| - } |
| - mu.Unlock() |
| - w.Header().Set("X-Carlos-Version", served) |
| - })) |
| + // 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 { |
| @@ -151,9 +195,6 @@ func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| // --- run 1: the app has no channels yet, so -channel names one -------- |
| buf := captureStdout(t) |
| - mu.Lock() |
| - polls, want = 0, "sha0001" |
| - mu.Unlock() |
| runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| "-channel", channel, "-host", host, "-timeout", "30s", siteDir}) |
| out := buf.String() |
| @@ -181,12 +222,9 @@ func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| if m.Kind != "static" { |
| t.Fatalf("manifest kind = %q, want static", m.Kind) |
| } |
| - mu.Lock() |
| - gotPolls := polls |
| - mu.Unlock() |
| - if gotPolls < 2 { |
| - t.Fatalf("edge polled %d times — the watch passed on a stale answer", gotPolls) |
| - } |
| + // 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 |
| @@ -195,9 +233,6 @@ func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| t.Fatal(err) |
| } |
| buf.Reset() |
| - mu.Lock() |
| - polls, want = 0, "sha0002" |
| - mu.Unlock() |
| runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| "-host", host, "-timeout", "30s", siteDir}) |
| out = buf.String() |
| @@ -210,6 +245,7 @@ func TestE2EStaticDeployThroughConsole(t *testing.T) { |
| 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() |
| } |
diff --git a/deploy_test.go b/deploy_test.go| index e3be387de..c90cffa10 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -84,7 +84,6 @@ func TestStaticDeployHosts(t *testing.T) { |
| for _, want := range []string{ |
| "no instance records", |
| "-host carlosframework.com", |
| - "carlos instances create", |
| } { |
| if !strings.Contains(err.Error(), want) { |
| t.Fatalf("refusal must mention %q:\n%s", want, err) |
| @@ -103,6 +102,18 @@ func TestStaticDeployChannel(t *testing.T) { |
| 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") |
| @@ -114,18 +125,86 @@ func TestStaticDeployChannel(t *testing.T) { |
| } |
| } |
| -// TestStaticWatchFloorOutlastsOneRecheck: the watch's floor for a static |
| -// deploy must exceed one whole edge recheck window, or a deploy that was |
| -// always going to land times out "honestly" while the edge is still trusting |
| -// its cached pointer. The probe's cache-bust does not help here — it defeats |
| -// the edge's HTTP response cache, not the static handler's trust window. |
| -func TestStaticWatchFloorOutlastsOneRecheck(t *testing.T) { |
| - if staticWatchFloor <= staticRecheckWindow { |
| - t.Fatalf("staticWatchFloor %s must exceed the recheck window %s", staticWatchFloor, staticRecheckWindow) |
| - } |
| - if defaultDeployTimeout < staticWatchFloor { |
| - t.Logf("default timeout %s is below the static floor %s — the floor is what a static deploy gets", |
| - defaultDeployTimeout, staticWatchFloor) |
| +// 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) |
| + } |
| } |
| } |
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_test.go b/internal/edge/static_test.go| index 6b225f932..3f74d442b 100644 |
| --- a/internal/edge/static_test.go |
| +++ b/internal/edge/static_test.go |
| @@ -13,6 +13,7 @@ import ( |
| "strings" |
| "sync" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -373,6 +374,65 @@ func TestStaticDegradedStampsTheCachedVersion(t *testing.T) { |
| } |
| } |
| +// 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. |