carlos / platform Public

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>
Paul Campbell 3c1287b7c50429432749a84d26d7e2bf045303b8 parent e5e567a
5 files changed, +303 −118
  • deploy.go +77 −76
  • deploy_e2e_test.go +64 −28
  • deploy_test.go +92 −13
  • internal/edge/edge.go +10 −1
  • internal/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.