carlos / platform Public

carlos deploy: watch the app's own build report, not the edge's header

An app declaring a build probe is watched by polling that path and matching
the build as a WHOLE TOKEN in the body — substring matching would let abc123
satisfy a watch for abc1234, which short git shas make a real collision. No
probe declared keeps the header path byte for byte.

X-Carlos-Version is stamped by the edge from the route row, so it goes green
the moment adoption moves a symlink, whether or not the process running the
old binary ever restarted. For a long-running unit — the console above all —
that is not an exit criterion at all. The app's own /healthz is.

The probe reaches the CLI on the SHIP response: deploy always ships before it
watches, so the value arrives with no extra round trip on an answer already
scoped to that app, and it rides both ship paths (a repeated deploy
short-circuits to 200 and still has a watch to run). A failed flags read
degrades to the header watch rather than failing a ship whose bytes are
already stored.

The probe leg inherits the cache-buster: the edge caches a proxied 2xx —
body included — for up to five minutes, longer than the watch's own timeout.
And the timeout NAMES what the probe said, so a binary linked without
-X …/internal/version.Build diagnoses itself instead of looking like a failed
deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev b98d1c746c82f49d7eb2a476257c36f0eb8a12e3 parent b88c4cd
7 files changed, +430 −26
  • apimode.go +11 −2
  • apimode_test.go +5 −5
  • cli.go +15 −11
  • deploy.go +79 −6
  • deploy_test.go +232 −2
  • internal/console/apicli.go +31 −0
  • internal/console/apicli_test.go +57 −0
diff --git a/apimode.go b/apimode.go
index e675178ed..d6beab6c6 100644
--- a/apimode.go
+++ b/apimode.go
@@ -348,6 +348,14 @@ type apiPromoteResult struct {
type apiShipResult struct {
NoOp bool
Version string
+ // BuildProbe is the app's build-report path, echoed by the ship door
+ // (handleAPIShip) and empty for an app that declares none — including
+ // against any console built before the field existed, where the key is
+ // simply absent and the deploy watch keeps reading the edge's header.
+ // `carlos deploy` learns it here rather than through a door of its own:
+ // it always ships before it watches, so the value arrives with no extra
+ // round trip on a response already scoped to this one app.
+ BuildProbe string
}
// apiAcct is one resolved -account/-account-id pair, API-mode: the segment
@@ -2185,12 +2193,13 @@ func (s *apiSession) ship(ctx context.Context, a apiAcct, app, version, kind, la
// call sent: a minted ship (empty version in) has no other way to
// learn what the console actually shipped it as.
var out struct {
- Version string `json:"version"`
+ Version string `json:"version"`
+ BuildProbe string `json:"build_probe"`
}
if err := json.Unmarshal(body, &out); err != nil {
return apiShipResult{}, fmt.Errorf("ship: malformed response: %w", err)
}
- return apiShipResult{NoOp: status == http.StatusOK, Version: out.Version}, nil
+ return apiShipResult{NoOp: status == http.StatusOK, Version: out.Version, BuildProbe: out.BuildProbe}, nil
default:
return apiShipResult{}, apiErrorFromBody(status, body)
}
diff --git a/apimode_test.go b/apimode_test.go
index 94923dae3..734deed91 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1484,7 +1484,7 @@ func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true); err != nil {
+ if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1531,7 +1531,7 @@ func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, siteDir)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true); err == nil {
+ if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titesite", "abc1234", "static", "", "", "", nil, true); err == nil {
t.Fatal("runShipAPI against a failing server must error")
}
@@ -1597,7 +1597,7 @@ func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
- if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true); err != nil {
+ if _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true); err != nil {
t.Fatalf("runShipAPI: %v", err)
}
@@ -1639,7 +1639,7 @@ func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
- err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true)
+ _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true)
if err == nil {
t.Fatal("runShipAPI with a bad basename must error")
}
@@ -1674,7 +1674,7 @@ func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
sess := newTestSession(ts.URL)
fs, acctRaw, acctIDRaw := newShipFlagSet(t, apiTestAccount, missing)
- err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true)
+ _, err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, "titogo", "abc1234", "binary", "linux-arm64", "", "", nil, true)
if err == nil {
t.Fatal("runShipAPI with a missing artifact path must error")
}
diff --git a/cli.go b/cli.go
index 102ffb7df..d13a8cff4 100644
--- a/cli.go
+++ b/cli.go
@@ -1794,7 +1794,7 @@ func runShip(args []string) {
log.Fatal(err)
}
}
- if err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil {
+ if _, err := runShipAPI(sess, acct, fs, app2, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil {
log.Fatal(err)
}
return
@@ -1956,27 +1956,27 @@ var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
// would otherwise print it twice. The "shipped"/"already shipped" line
// below is never suppressed; it is the one true account of what ship did,
// and deploy relies on that rather than printing its own.
-func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner bool) error {
+func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind, target, label, notes string, capsSet *caps.Set, printBanner bool) (apiShipResult, error) {
files := map[string]string{}
if kind == "static" {
if fs.NArg() != 1 {
- return errors.New("static ship takes exactly one site directory")
+ return apiShipResult{}, errors.New("static ship takes exactly one site directory")
}
data, err := edge.PackDir(fs.Arg(0))
if err != nil {
- return err
+ return apiShipResult{}, err
}
tmp, err := os.CreateTemp("", "carlos-ship-*.tar.gz")
if err != nil {
- return err
+ return apiShipResult{}, err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
- return err
+ return apiShipResult{}, err
}
if err := tmp.Close(); err != nil {
- return err
+ return apiShipResult{}, err
}
files["site.tar.gz"] = tmp.Name()
} else if fs.NArg() == 1 {
@@ -1996,7 +1996,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
if kind != "static" {
for name, path := range files {
if !shipTargetRe.MatchString(name) {
- return fmt.Errorf("ship: invalid artifact name %q (from %s) — want a GOOS-GOARCH shape like linux-arm64 "+
+ return apiShipResult{}, fmt.Errorf("ship: invalid artifact name %q (from %s) — want a GOOS-GOARCH shape like linux-arm64 "+
"(pass -target to name a single file explicitly)", name, path)
}
}
@@ -2007,7 +2007,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
// the "using ..." line already printed.
for name, path := range files {
if _, err := os.Stat(path); err != nil {
- return fmt.Errorf("ship: artifact %q: %w", name, err)
+ return apiShipResult{}, fmt.Errorf("ship: artifact %q: %w", name, err)
}
}
@@ -2016,7 +2016,7 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
}
res, err := sess.ship(context.Background(), acct, app, ver, kind, label, notes, files, capsSet)
if err != nil {
- return err
+ return apiShipResult{}, err
}
// res.Version, never ver: when the caller passed no -version the CONSOLE
// minted one from the app's target, and the response is the only place
@@ -2027,7 +2027,11 @@ func runShipAPI(sess *apiSession, acct apiAcct, fs *flag.FlagSet, app, ver, kind
} else {
fmt.Fprintf(stdout, "%s %s %s\n", colorFor(stdout).ok("shipped"), app, res.Version)
}
- return nil
+ // res carries more than the print above needs: BuildProbe is the app's
+ // build-report path, which `carlos deploy` turns into its watch's exit
+ // criterion. Returned rather than acted on here — ship itself has no
+ // watch.
+ return res, nil
}
// runPromote points a channel at a shipped version, with the ladder enforced:
diff --git a/deploy.go b/deploy.go
index 6cf96667f..2ec49d980 100644
--- a/deploy.go
+++ b/deploy.go
@@ -200,7 +200,8 @@ func runDeploy(args []string) {
// printBanner=false: the "using ..." line already printed above, before
// the instance list — so a listing failure still names the console —
// and runShipAPI would otherwise print it a second time.
- if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, "", "", capsSet, false); err != nil {
+ shipRes, err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, "", "", capsSet, false)
+ if err != nil {
log.Fatalf("deploy: ship: %v", err)
}
res, err := sess.promote(ctx, acct, *app, *ver, channel)
@@ -228,9 +229,19 @@ func runDeploy(args []string) {
mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
int(staticRecheckWindow.Seconds()))
}
+ // The probe comes from the ship response the deploy just made (spec §3b):
+ // an app that declares one is watched by asking its OWN process which
+ // build it is running, because X-Carlos-Version is stamped by the edge
+ // from the route row and goes green the moment adoption moves a symlink —
+ // before, and whether or not, the process running the old binary
+ // restarts. Empty (no probe declared, or a console too old to echo the
+ // field) leaves the header watch exactly as it was.
+ if shipRes.BuildProbe != "" {
+ mutedf(stdout, "watching %s for the build %s reports itself\n", shipRes.BuildProbe, *app)
+ }
targets := make([]watchTarget, 0, len(hosts))
for _, h := range hosts {
- targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h)})
+ targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: shipRes.BuildProbe})
}
diag := &watchDiagnostics{
// A timed-out host's wake reason, from the platform log stream —
@@ -993,7 +1004,19 @@ func filterChannelHosts(recs []apiInstanceRecord, channel, hostFlag string) ([]s
// 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 }
+//
+// Probe is the app's own build-report path (`/healthz`, say), empty for every
+// app that declares none. It exists because X-Carlos-Version is stamped by
+// the EDGE from the route row, so it reports what adoption INTENDED from the
+// moment the symlink moves — whether or not any process restarted. For a
+// long-running unit (the console above all) that header is not an exit
+// criterion at all: it goes green while the old binary is still serving. A
+// probe path is answered by the app's OWN process, so its body is the only
+// honest answer to "which build is running here".
+type watchTarget struct {
+ Host, Origin string
+ Probe string
+}
// watchOrigin maps an instance host to the origin the watch dials. In
// production this is always https://<host> — the public URL the operator
@@ -1009,8 +1032,12 @@ type watchTarget struct{ Host, Origin string }
// something other than the URL people actually visit.
var watchOrigin = func(host string) string { return "https://" + host }
-// 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).
+// watchVersion polls each target's origin until it reports ver — the deploy
+// verb's exit criterion (spec §6 step 5, §7). A target with no Probe is
+// watched exactly as it always was: GET / and match X-Carlos-Version. A
+// target that declares one is watched by GETting that path and matching ver
+// as a whole whitespace-delimited token in the BODY, because the app's own
+// process answers there and the edge's header does not (see watchTarget).
// 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
@@ -1062,12 +1089,58 @@ func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, inter
go func(i int, t watchTarget) {
defer wg.Done()
n := attempt.Add(1)
- url := fmt.Sprintf("%s/?carlos-deploy-probe=%d", t.Origin, n)
+ // The cache-buster rides BOTH legs. The edge caches a proxied
+ // 2xx — body included — for up to 5 minutes, longer than this
+ // function's own default timeout, so an un-busted probe poll
+ // could sit on one stale hit for the whole watch and report a
+ // healthy deploy as failed.
+ path := "/"
+ if t.Probe != "" {
+ path = t.Probe
+ }
+ url := fmt.Sprintf("%s%s?carlos-deploy-probe=%d", t.Origin, path, n)
resp, err := client.Get(url)
if err != nil {
results[i] = result{diag: "unreachable: " + err.Error()}
return
}
+ if t.Probe != "" {
+ // The app's own process answers here, so the body IS the
+ // truth — no header/route-row disagreement to reconcile
+ // the way the header path has to. Read a bounded amount:
+ // this is a health endpoint, and a target that answers
+ // with a stream is misconfigured, not something to buffer.
+ b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ resp.Body.Close()
+ if rerr != nil {
+ results[i] = result{diag: "probe unreadable: " + rerr.Error()}
+ return
+ }
+ got := strings.TrimSpace(string(b))
+ if resp.StatusCode/100 != 2 {
+ // Same rule as the header leg's 502 case: the right
+ // build in a crash loop is not a live deploy.
+ results[i] = result{diag: fmt.Sprintf("%s answered HTTP %d: %q", path, resp.StatusCode, got)}
+ return
+ }
+ // Whole-token, never substring: abc123 must not satisfy a
+ // watch for abc1234, and short git shas collide like that
+ // in practice.
+ for _, f := range strings.Fields(got) {
+ if f == ver {
+ results[i] = result{live: true}
+ return
+ }
+ }
+ // Naming the body is what makes the ldflags trap
+ // self-diagnosing: a binary linked without
+ // -X …/internal/version.Build reports "dev" forever, so a
+ // perfectly healthy roll fails its watch — and "timed out"
+ // would send the operator hunting the wrong thing, while
+ // `reports "ok dev", waiting for abc1234` says what to fix.
+ results[i] = result{diag: fmt.Sprintf("%s reports %q, waiting for %s", path, got, ver)}
+ return
+ }
io.CopyN(io.Discard, resp.Body, 4096)
resp.Body.Close()
got := resp.Header.Get("X-Carlos-Version")
diff --git a/deploy_test.go b/deploy_test.go
index 992aedc0c..47936e120 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -621,6 +621,12 @@ type deployFakeConsole struct {
// as a first route, the review#1 case attachUnclaimedHost must read off
// the door's own answer rather than assume.
attachRouteOverride string
+
+ // shipProbe is the app's build_probe, echoed by the ship door exactly as
+ // handleAPIShip echoes it. "" means the app declares none and the key is
+ // absent from the response — which is also what every console built
+ // before the field existed answers.
+ shipProbe string
}
func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
@@ -676,10 +682,14 @@ func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole {
mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
fc.mu.Lock()
- ver := fc.shipVersion
+ ver, probe := fc.shipVersion, fc.shipProbe
fc.mu.Unlock()
w.WriteHeader(http.StatusCreated)
- json.NewEncoder(w).Encode(map[string]string{"version": ver})
+ body := map[string]string{"version": ver}
+ if probe != "" {
+ body["build_probe"] = probe
+ }
+ json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) {
fc.record(r)
@@ -718,6 +728,12 @@ func (fc *deployFakeConsole) setAttachRefusal(status int, msg string) {
fc.attachStatus, fc.attachErrMsg = status, msg
}
+func (fc *deployFakeConsole) setShipProbe(probe string) {
+ fc.mu.Lock()
+ defer fc.mu.Unlock()
+ fc.shipProbe = probe
+}
+
func (fc *deployFakeConsole) setAttachRouteOverride(route string) {
fc.mu.Lock()
defer fc.mu.Unlock()
@@ -1401,3 +1417,217 @@ func TestDeploySecondStaticHostAttaches(t *testing.T) {
}
checkNoRegistry()
}
+
+// ---- the build probe: watching the app's own report, not the edge's header --
+
+// TestWatchProbeMatchesAWholeToken: the probe body must carry the build as a
+// whitespace-delimited token. Substring matching would let abc123 satisfy a
+// watch for abc1234 — and short git shas make that a real collision, not a
+// theoretical one.
+func TestWatchProbeMatchesAWholeToken(t *testing.T) {
+ var body atomic.Value
+ body.Store("ok abc123")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/healthz" {
+ t.Errorf("watch dialled %q, want /healthz", r.URL.Path)
+ }
+ if r.URL.Query().Get("carlos-deploy-probe") == "" {
+ t.Error("probe poll carried no cache-buster — the edge caches a proxied 2xx for up to 5 minutes")
+ }
+ fmt.Fprintln(w, body.Load().(string))
+ }))
+ defer srv.Close()
+
+ targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
+ err := watchVersion(io.Discard, targets, "abc1234", 300*time.Millisecond, 50*time.Millisecond, nil)
+ if err == nil {
+ t.Fatal(`watch for abc1234 succeeded against a body of "ok abc123" — substring match, not token match`)
+ }
+
+ body.Store("ok abc1234")
+ if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
+ t.Fatalf("watch against the matching build = %v, want success", err)
+ }
+}
+
+// TestWatchProbeTimeoutNamesTheBody: a binary built without the version
+// ldflag reports "dev" forever, so the deploy fails while the roll was
+// perfectly healthy. The report has to make that self-diagnosing rather than
+// saying "timed out".
+func TestWatchProbeTimeoutNamesTheBody(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintln(w, "ok dev")
+ }))
+ defer srv.Close()
+ var buf bytes.Buffer
+ targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
+ err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil)
+ if err == nil {
+ t.Fatal("want a timeout")
+ }
+ out := buf.String() + err.Error()
+ if !strings.Contains(out, "dev") {
+ t.Fatalf("timeout report %q does not name what the probe actually said — the ldflags trap stays invisible", out)
+ }
+}
+
+// TestWatchProbeNon2xxIsNotLive: a probe path can answer with the right build
+// in the body and still be failing — the console's own /healthz is served by
+// the same process that would 500. Status gates the success arm on this leg
+// exactly as it does on the header leg.
+func TestWatchProbeNon2xxIsNotLive(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ fmt.Fprintln(w, "ok abc1234")
+ }))
+ defer srv.Close()
+ var buf bytes.Buffer
+ targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
+ err := watchVersion(&buf, targets, "abc1234", 100*time.Millisecond, 50*time.Millisecond, nil)
+ if err == nil {
+ t.Fatal("a 500 whose body names the build must not be reported live")
+ }
+ if !strings.Contains(err.Error(), "HTTP 500") {
+ t.Fatalf("diagnostic must name the failing status: %v", err)
+ }
+ if strings.Contains(buf.String(), "live") {
+ t.Fatalf("must not print live: %q", buf.String())
+ }
+}
+
+// TestWatchWithoutAProbeStillReadsTheHeader: an app that declares no probe
+// keeps today's behaviour byte for byte.
+func TestWatchWithoutAProbeStillReadsTheHeader(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" {
+ t.Errorf("header watch dialled %q, want /", r.URL.Path)
+ }
+ w.Header().Set("X-Carlos-Version", "abc1234")
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+ targets := []watchTarget{{Host: "app.example", Origin: srv.URL}}
+ if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
+ t.Fatalf("header watch = %v, want success", err)
+ }
+}
+
+// TestDeployWatchesTheProbeTheShipResponseNames is the whole point of the
+// probe, end to end: the edge's X-Carlos-Version is stamped from the route
+// row, so here it still says the OLD build — exactly what a long-running
+// unit looks like between "adoption moved the symlink" and "the process
+// restarted". The header watch would have reported this deploy live while
+// the old binary was still serving; the probe, answered by the app's own
+// process, is what actually turns green.
+func TestDeployWatchesTheProbeTheShipResponseNames(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ const (
+ app = "console"
+ host = "console.example.test"
+ channel = "canary/dev"
+ )
+ fc := newDeployFakeConsole(t, "sha0002")
+ fc.setShipProbe("/healthz")
+ fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}})
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ var probed atomic.Int64
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The edge answers BOTH legs: the header leg with the stale route-row
+ // version, the probe leg with what the process is really running.
+ w.Header().Set("X-Carlos-Version", "sha0001")
+ if r.URL.Path == "/healthz" {
+ probed.Add(1)
+ fmt.Fprintln(w, "ok sha0002")
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ bin := filepath.Join(t.TempDir(), "console")
+ if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002",
+ "-channel", channel, "-timeout", "5s", bin})
+ out := buf.String()
+
+ if probed.Load() == 0 {
+ t.Fatalf("the watch never polled /healthz — it read the (stale) header instead:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("deploy did not complete against the probe:\n%s", out)
+ }
+}
+
+// TestDeployWithNoProbeStillWatchesTheHeader: a ship response with no
+// build_probe key — every app that declares none, and every console built
+// before the field existed — leaves the header watch exactly as it was.
+func TestDeployWithNoProbeStillWatchesTheHeader(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ const (
+ app = "titogo"
+ host = "titogo.example.test"
+ channel = "canary/dev"
+ )
+ fc := newDeployFakeConsole(t, "sha0002")
+ fc.setInstances([]apiInstanceRecord{{Host: host, Channel: channel}})
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token",
+ ExpiresAt: time.Now().Add(time.Hour)})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ var paths sync.Map
+ edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths.Store(r.URL.Path, true)
+ w.Header().Set("X-Carlos-Version", "sha0002")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer edgeSrv.Close()
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string { return edgeSrv.URL }
+ defer func() { watchOrigin = origOrigin }()
+
+ bin := filepath.Join(t.TempDir(), "titogo")
+ if err := os.WriteFile(bin, []byte("ELF-ish"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-version", "sha0002",
+ "-channel", channel, "-timeout", "5s", bin})
+ out := buf.String()
+
+ if _, dialled := paths.Load("/healthz"); dialled {
+ t.Fatalf("an app with no probe must never be polled anywhere but /:\n%s", out)
+ }
+ if _, dialled := paths.Load("/"); !dialled {
+ t.Fatalf("the header watch never dialled /:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("header watch did not complete:\n%s", out)
+ }
+}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 7b68e2b38..9f28025b4 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -1603,6 +1603,7 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// above, and echoing what is ON RECORD is the honest answer to "what
// did this version end up declaring".
addCapabilitiesEcho(body, existing.Capabilities)
+ s.addBuildProbe(body, acct, app)
writeJSON(w, http.StatusOK, body)
return
}
@@ -1634,9 +1635,39 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
}
body := map[string]any{"result": "shipped", "version": ver, "artifacts": out}
addCapabilitiesEcho(body, m.Capabilities)
+ s.addBuildProbe(body, acct, app)
writeJSON(w, http.StatusCreated, body)
}
+// addBuildProbe adds the app's build-probe path to a ship response, so
+// `carlos deploy` learns it from the call it was already making. The probe is
+// what the deploy watch polls for a build the app's own PROCESS reports
+// instead of the edge's route-row header (deploy.go, watchTarget.Probe).
+//
+// It rides the ship response rather than a door of its own because deploy
+// always ships before it watches: the value arrives with no extra round trip,
+// and the ship response is already scoped to exactly the app whose probe this
+// is. Read from the app ROW (Store.AppFlags) — never from provision.Record,
+// whose handleAPIInstanceUpdate is a read-modify-write that would silently
+// drop a field it does not know: the probe is app-level, not per-instance.
+//
+// Additive twice over. A client that does not read the key is unaffected, an
+// app with no probe carries no key at all (so every ship the fleet does today
+// answers byte for byte as before) — and a FAILED read is not a failed ship.
+// The bytes are stored by the time this runs; degrading to the header watch
+// with a logged line is the honest outcome, refusing a successful upload over
+// a metadata read is not.
+func (s *Server) addBuildProbe(body map[string]any, acct *Account, app string) {
+ _, probe, err := s.store.AppFlags(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api ship %s/%s: read build_probe: %v", acct.Sqid, app, err)
+ return
+ }
+ if probe != "" {
+ body["build_probe"] = probe
+ }
+}
+
// addCapabilitiesEcho adds the "capabilities" key to a ship response: the
// canonical JSON of the block that was actually STORED, or nothing at all when
// the version declares none.
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index b5485e243..cfb920fec 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2513,3 +2513,60 @@ func TestAPICreateAppRateLimit(t *testing.T) {
t.Fatalf("browser claim after CLI budget spent = %v %v, want true", has, err)
}
}
+
+// TestAPIShipEchoesTheBuildProbe: `carlos deploy` learns the app's build
+// probe from the ship it was already making — no second door, no second
+// round trip — so the watch can poll the app's own build report instead of
+// the edge's route-row header. It has to ride BOTH ship answers: a repeated
+// deploy of bytes already shipped short-circuits to 200, and that deploy
+// still has a watch to run.
+func TestAPIShipEchoesTheBuildProbe(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-probe")
+ payload := randomBytes(t, 256)
+ parts := []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, parts)
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ if out["build_probe"] != "/healthz" {
+ t.Fatalf("ship body build_probe = %v, want /healthz — the deploy watch has no other way to learn it", out["build_probe"])
+ }
+
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusOK {
+ t.Fatalf("re-ship of identical bytes = %d %v, want 200", status, out)
+ }
+ if out["build_probe"] != "/healthz" {
+ t.Fatalf("no-op ship body build_probe = %v, want /healthz (that deploy still watches)", out["build_probe"])
+ }
+}
+
+// TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared: every ship the fleet does
+// today answers byte for byte as it did — the key is absent, and a CLI
+// decoding it gets "" and keeps the header watch.
+func TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noprobe")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 256)}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ if _, present := out["build_probe"]; present {
+ t.Fatalf("ship body carries build_probe for an app that declares none: %v", out)
+ }
+}