cli: carlos deploy works for an app with no instance records
A static site has no instances to resolve a channel or a host from, so deploy resolved neither and its watch timed out headerless (#112). With zero records deploy now takes the host from -host — required, and refused before ship and promote run rather than after — and the channel from -channel or, failing that, the app's own channels when it has exactly one. The watch rule is untouched: version match AND 2xx, no static special case. Its clock is not: a static route trusts its channel pointer for a whole recheck window, and the probe's cache-bust defeats the edge's HTTP cache, not that window — so the default timeout is raised to the floor that outlasts one window, an explicit -timeout is left alone, and the delay is said out loud.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+349
−5
deploy.go+116 −5deploy_e2e_test.go+172 −0deploy_test.go+61 −0
diff --git a/deploy.go b/deploy.go| index 797b786a1..dfa90fccd 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] " + |
| @@ -67,7 +67,32 @@ func runDeploy(args []string) { |
| } |
| var channel string |
| var hosts []string |
| - if *channelFlag != "" { |
| + // 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). |
| @@ -80,7 +105,7 @@ func runDeploy(args []string) { |
| if err != nil { |
| log.Fatalf("deploy: %v", err) |
| } |
| - } else { |
| + default: |
| channel, hosts, err = resolveDeployChannel(recs, *hostFlag) |
| if err != nil { |
| log.Fatalf("deploy: %v", err) |
| @@ -105,6 +130,20 @@ 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. |
| + 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 { |
| targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h)}) |
| @@ -114,6 +153,76 @@ func runDeploy(args []string) { |
| } |
| } |
| +// defaultDeployTimeout is how long -timeout watches a URL before failing, |
| +// unless the operator says otherwise. |
| +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 |
| + |
| +// 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 |
| + } |
| + }) |
| + return set |
| +} |
| + |
| +// 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> -host carlosframework.com <site-dir>\n" + |
| + "(a binary app needs its instance first: carlos instances create -app <a> -host <h> -channel <ch>)") |
| + } |
| + 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. |
| +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 { |
| + 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 +264,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..05800acf8 100644 |
| --- a/deploy_e2e_test.go |
| +++ b/deploy_e2e_test.go |
| @@ -42,6 +42,178 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// 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, 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) |
| + })) |
| + t.Cleanup(edgeSrv.Close) |
| + 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) |
| + 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() |
| + 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) |
| + } |
| + mu.Lock() |
| + gotPolls := polls |
| + mu.Unlock() |
| + if gotPolls < 2 { |
| + t.Fatalf("edge polled %d times — the watch passed on a stale answer", gotPolls) |
| + } |
| + |
| + // --- 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() |
| + 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() |
| + 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) |
| + } |
| + |
| + 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..e3be387de 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -68,6 +68,67 @@ 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", |
| + "carlos instances create", |
| + } { |
| + 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) |
| + } |
| + _, 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) |
| + } |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) { |
| var mu sync.Mutex |
| ver := "old1234" |