Merge pull request #120 from carlosframework/static-header
Static routes carry X-Carlos-Version; carlos deploy watches static apps (#112)
6 files changed,
+754
−37
deploy.go+135 −23deploy_e2e_test.go+208 −0deploy_test.go+140 −0internal/edge/edge.go+10 −1internal/edge/static.go+39 −13internal/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 |