carlos / platform Public

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>
Paul Campbell e5e567a74d7c009913e0f35c1a5e356521b1c22a parent 854d638
3 files changed, +349 −5
  • deploy.go +116 −5
  • deploy_e2e_test.go +172 −0
  • deploy_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"