carlos / platform Public

cli: carlos deploy — ship, promote, and watch until the URL serves it (spec §6)

Composes the existing ship/promote/instances doors into one member verb:
resolve the channel the app's instances follow, ship, promote, then poll
X-Carlos-Version on each instance until it matches (cache-busted every
poll — the edge can serve a cached header for up to 5 min, longer than
the 120s default timeout). A held promote (production bake window) exits
0 without watching; apiPromoteResult gains Held so a no-op promote is
still watched on retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0b59e2b5ef033951a17f2d151c5fad88421cd370 parent 9d848d8
4 files changed, +358 −1
  • apimode.go +10 −1
  • deploy.go +229 −0
  • deploy_test.go +116 −0
  • main.go +3 −0
diff --git a/apimode.go b/apimode.go
index e6457ba47..5c6dbd896 100644
--- a/apimode.go
+++ b/apimode.go
@@ -163,6 +163,14 @@ type apiChannel struct {
// them.
type apiPromoteResult struct {
NoOp bool
+ // Held mirrors the console's additive "held" field (Task 6, spec §6.6):
+ // true when this is a production app promoting into a rung that bakes.
+ // Present on BOTH the promoted and the no-op response — a repeated
+ // `carlos deploy` against an unheld app must still watch (the channel
+ // already points at the version, but an instance may not have converged
+ // yet), so deploy needs this fact on the no-op path too, not only on a
+ // fresh promotion.
+ Held bool `json:"held"`
}
type apiShipResult struct {
@@ -331,11 +339,12 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann
if status == http.StatusOK {
var res struct {
Result string `json:"result"`
+ Held bool `json:"held"`
}
if err := json.Unmarshal(body, &res); err != nil {
return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err)
}
- return apiPromoteResult{NoOp: res.Result == "noop"}, nil
+ return apiPromoteResult{NoOp: res.Result == "noop", Held: res.Held}, nil
}
if refErr, ok := stableRefusal(status, body); ok {
return apiPromoteResult{}, refErr
diff --git a/deploy.go b/deploy.go
new file mode 100644
index 000000000..0be8674fd
--- /dev/null
+++ b/deploy.go
@@ -0,0 +1,229 @@
+// deploy.go composes ship + promote + watch into one member-facing verb:
+// "make this app's URL serve this version" (spec §6). It is deliberately
+// thin — every piece it calls (runShipAPI, apiSession.promote,
+// apiSession.instanceList, the X-Carlos-Version header) already exists for
+// the step-by-step verbs; this file only sequences them and watches for the
+// one fact that proves the job is actually done.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os/exec"
+ "sort"
+ "strings"
+ "time"
+)
+
+// runDeploy is `carlos deploy`. API-mode only (spec §6): direct-bucket
+// operators keep ship + promote as separate acts; deploy is the member path
+// that ships, promotes to the channel the app's instances already follow,
+// and then proves it by watching the edge's version header — never AWS,
+// never a box command, per the operate-via-API rule.
+func runDeploy(args []string) {
+ fs := flag.NewFlagSet("deploy", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to deploy through (API mode only; default: the only logged-in one)")
+ kind := fs.String("kind", "binary", "binary | static")
+ 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")
+ fs.Parse(args)
+ if *app == "" || fs.NArg() == 0 {
+ log.Fatal("usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " +
+ "[-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>] [-capabilities <file.json>] " +
+ "[-channel <ch>] [-host <host>] [-timeout 120s] <files-or-site-dir>")
+ }
+ if *ver == "" {
+ *ver = gitShortHead()
+ }
+
+ // Read and decode the capability declaration before ever dialing the
+ // console, exactly like runShip does — a bad file is refused at the
+ // operator's terminal, not after the ship half of this verb already ran.
+ capsSet, err := readCapabilities(*capsFile, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ sess := requireAPISession("deploy", *consoleFlag) // API-mode only: member verb
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ ctx := context.Background()
+ recs, _, err := sess.instanceList(ctx, acct, *app)
+ 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).
+ channel = *channelFlag
+ for _, r := range recs {
+ if r.Channel == channel && (*hostFlag == "" || r.Host == *hostFlag) {
+ hosts = append(hosts, r.Host)
+ }
+ }
+ } else {
+ channel, hosts, err = resolveDeployChannel(recs, *hostFlag)
+ if err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+ }
+ if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, capsSet); err != nil {
+ log.Fatalf("deploy: ship: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), *app, *ver)
+ res, err := sess.promote(ctx, acct, *app, *ver, channel)
+ if err != nil {
+ log.Fatalf("deploy: promote: %v", err) // a stable 403's console_url surfaces in err
+ }
+ fmt.Fprintf(stdout, "%s %s %s -> %s\n", colorFor(stdout).ok("promoted"), *app, *ver, channel)
+ if res.Held {
+ mutedf(stdout, "%s is a production app: the %s bake window applies — the fleet adopts when it expires; console fast-track if this is an emergency\n", *app, channel)
+ return // exit 0: the writes succeeded, the hold is policy (spec §6.6)
+ }
+ if len(hosts) == 0 {
+ mutedf(stdout, "no instance follows %s — promoted, nothing to watch (carlos instances create to add one)\n", channel)
+ return
+ }
+ targets := make([]watchTarget, 0, len(hosts))
+ for _, h := range hosts {
+ targets = append(targets, watchTarget{Host: h, Origin: "https://" + h})
+ }
+ if err := watchVersion(stdout, targets, *ver, *timeout, 2*time.Second); err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+}
+
+// gitShortHead resolves -version's default: the working directory's short
+// git SHA. Fatal here (rather than a returned error) matches every other
+// "this environment is wrong" refusal in this binary — there is no sane
+// fallback value, only the operator's own -version.
+func gitShortHead() string {
+ out, err := exec.Command("git", "rev-parse", "--short", "HEAD").Output()
+ if err != nil {
+ log.Fatal("not in a git repository — pass -version")
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// resolveDeployChannel picks the channel `carlos deploy` promotes to, and the
+// hosts it then watches, from the app's own instance records (spec §6 step
+// 2). -host (hostFlag) narrows to one instance's own channel with nothing
+// 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.
+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>")
+ }
+ if hostFlag != "" {
+ for _, r := range recs {
+ if r.Host == hostFlag {
+ return r.Channel, []string{r.Host}, nil
+ }
+ }
+ return "", nil, fmt.Errorf("no instance with host %q", hostFlag)
+ }
+ byChannel := map[string][]string{}
+ var order []string
+ for _, r := range recs {
+ if _, ok := byChannel[r.Channel]; !ok {
+ order = append(order, r.Channel)
+ }
+ byChannel[r.Channel] = append(byChannel[r.Channel], r.Host)
+ hosts = append(hosts, r.Host)
+ }
+ if len(byChannel) == 1 {
+ return order[0], hosts, nil
+ }
+ sort.Strings(order)
+ var b strings.Builder
+ b.WriteString("instances follow different channels — pass -channel or -host to pick one: ")
+ for i, ch := range order {
+ if i > 0 {
+ b.WriteString("; ")
+ }
+ fmt.Fprintf(&b, "%s: %s", ch, strings.Join(byChannel[ch], ", "))
+ }
+ return "", nil, errors.New(b.String())
+}
+
+// watchTarget is one instance to watch: Host is what gets printed (the
+// public hostname the operator will actually visit) and Origin is what gets
+// dialed (https://<host> in production; a test's httptest.Server URL in
+// unit tests, which is never the same string as Host).
+type watchTarget struct{ Host, Origin string }
+
+// watchVersion polls each target's origin until it serves ver on
+// X-Carlos-Version — the deploy verb's exit criterion (spec §6 step 5, §7).
+// Every poll appends a query parameter that changes from the previous poll
+// (?carlos-deploy-probe=<attempt>) to the URL it dials. This is not
+// decoration: the edge caches a proxied response, header included, for up to
+// 5 minutes (internal/edge/edge.go, versionHeader) — longer than this
+// function's own 120s default timeout — so an un-cache-busted watch could
+// poll one stale cached hit for the whole timeout and report a healthy
+// deploy as failed. The attempt counter is sufficient (no randomness
+// needed): each request just needs to differ from the ones before it.
+func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, interval time.Duration) error {
+ pending := make(map[string]watchTarget, len(targets))
+ for _, t := range targets {
+ pending[t.Host] = t
+ }
+ last := make(map[string]string, len(targets))
+ client := &http.Client{Timeout: 10 * time.Second}
+ deadline := time.Now().Add(timeout)
+ attempt := 0
+ for {
+ for host, t := range pending {
+ attempt++
+ url := fmt.Sprintf("%s/?carlos-deploy-probe=%d", t.Origin, attempt)
+ resp, err := client.Get(url)
+ if err != nil {
+ last[host] = "unreachable: " + err.Error()
+ continue
+ }
+ io.CopyN(io.Discard, resp.Body, 4096)
+ resp.Body.Close()
+ got := resp.Header.Get("X-Carlos-Version")
+ switch {
+ case got == ver:
+ fmt.Fprintf(w, "%s https://%s\n", colorFor(w).ok("live"), host)
+ delete(pending, host)
+ case got == "":
+ last[host] = "no X-Carlos-Version header (edge predates this feature — the hourly pass will adopt it)"
+ default:
+ last[host] = "still serving " + got
+ }
+ }
+ if len(pending) == 0 {
+ return nil
+ }
+ if time.Now().After(deadline) {
+ hosts := make([]string, 0, len(pending))
+ for h := range pending {
+ hosts = append(hosts, h)
+ }
+ sort.Strings(hosts)
+ var b strings.Builder
+ fmt.Fprintf(&b, "timed out waiting for %s to serve %s (route not minted yet or a wake failing? try `carlos instances -app <a>`):", strings.Join(hosts, ", "), ver)
+ for _, h := range hosts {
+ fmt.Fprintf(&b, "\n %s: %s", h, last[h])
+ }
+ return errors.New(b.String())
+ }
+ time.Sleep(interval)
+ }
+}
diff --git a/deploy_test.go b/deploy_test.go
new file mode 100644
index 000000000..201fa27ae
--- /dev/null
+++ b/deploy_test.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestResolveDeployChannel(t *testing.T) {
+ recs := []apiInstanceRecord{
+ {Host: "a.test", Channel: "canary/dev"},
+ {Host: "b.test", Channel: "canary/dev"},
+ }
+ ch, hosts, err := resolveDeployChannel(recs, "")
+ if err != nil || ch != "canary/dev" || len(hosts) != 2 {
+ t.Fatalf("%q %v %v", ch, hosts, err)
+ }
+ recs[1].Channel = "edge"
+ if _, _, err := resolveDeployChannel(recs, ""); err == nil {
+ t.Fatal("two channels with no -channel/-host must error")
+ }
+ ch, hosts, err = resolveDeployChannel(recs, "b.test")
+ if err != nil || ch != "edge" || len(hosts) != 1 || hosts[0] != "b.test" {
+ t.Fatalf("host-scoped: %q %v %v", ch, hosts, err)
+ }
+ if _, _, err := resolveDeployChannel(nil, ""); err == nil {
+ t.Fatal("no instances must error (hint: carlos instances create)")
+ }
+}
+
+func TestWatchVersionSucceedsWhenHeaderMatches(t *testing.T) {
+ var mu sync.Mutex
+ ver := "old1234"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ w.Header().Set("X-Carlos-Version", ver)
+ mu.Unlock()
+ }))
+ defer srv.Close()
+ go func() { time.Sleep(30 * time.Millisecond); mu.Lock(); ver = "new5678"; mu.Unlock() }()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 2*time.Second, 10*time.Millisecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "x.test") {
+ t.Fatalf("live line missing: %q", out.String())
+ }
+}
+
+func TestWatchVersionTimeoutDiagnostics(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "old1234")
+ }))
+ defer srv.Close()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 50*time.Millisecond, 10*time.Millisecond)
+ if err == nil {
+ t.Fatal("must time out")
+ }
+ if !strings.Contains(err.Error(), "old1234") {
+ t.Fatalf("diagnostic must name the version still serving: %v", err)
+ }
+}
+
+func TestWatchVersionNoHeaderDiagnostic(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+ defer srv.Close()
+ var out bytes.Buffer
+ err := watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 50*time.Millisecond, 10*time.Millisecond)
+ if err == nil || !strings.Contains(err.Error(), "no X-Carlos-Version") {
+ t.Fatalf("old-edge diagnostic: %v", err)
+ }
+}
+
+// TestWatchVersionCacheBustsEveryPoll pins the fix for the edge's up-to-5min
+// cache TTL on X-Carlos-Version (internal/edge/edge.go, versionHeader): a
+// watch with no cache-bust could poll the same cached response for the whole
+// default 120s timeout and false-FAIL a deploy that already converged. Every
+// poll must carry a query parameter that differs from the previous one, so
+// the edge cache never serves a stale hit back to the watch.
+func TestWatchVersionCacheBustsEveryPoll(t *testing.T) {
+ var mu sync.Mutex
+ var queries []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ queries = append(queries, r.URL.RawQuery)
+ mu.Unlock()
+ w.Header().Set("X-Carlos-Version", "old1234")
+ }))
+ defer srv.Close()
+ var out bytes.Buffer
+ _ = watchVersion(&out, []watchTarget{{Host: "x.test", Origin: srv.URL}}, "new5678",
+ 35*time.Millisecond, 10*time.Millisecond)
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(queries) < 2 {
+ t.Fatalf("expected multiple polls against a version that never matches, got %d: %v", len(queries), queries)
+ }
+ for _, q := range queries {
+ if q == "" {
+ t.Fatalf("poll missing cache-bust query param: %v", queries)
+ }
+ }
+ if queries[0] == queries[1] {
+ t.Fatalf("cache-bust param did not vary between polls: %v", queries)
+ }
+}
diff --git a/main.go b/main.go
index a068aa33a..9e4c93853 100644
--- a/main.go
+++ b/main.go
@@ -47,6 +47,8 @@ func main() {
runShip(args)
case "promote":
runPromote(args)
+ case "deploy":
+ runDeploy(args)
case "channels":
runChannels(args)
case "rollback":
@@ -102,6 +104,7 @@ Your apps — ship and run software on a CARLOS deployment
apps create | delete | restore an app name
ship publish a release of an app
promote move a version up the ladder: canary → edge → beta → stable
+ deploy ship + promote + wait until the URL serves the new version
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
env plain per-app config vars: set | unset | list