carlos / platform Public

carlos deploy: re-check the echoed probe, and state the body contract

Fix round 1 (three Minors, all promoted).

The deploy path took the console's echoed build_probe on trust and
concatenated it onto an origin. Both grammar gates live at the WRITE door;
this value arrives from a READ. A probe not starting with "/" is the shape
that matters: "https://" + host + "@evil.example/x" parses host as USERINFO
and evil.example as the HOST, so the watch would poll a machine of someone
else's choosing and report ITS build as this deploy's — exactly what
validBuildProbe's own comment says it exists to prevent. Re-checked with that
same mirror, and degraded (never fatal: ship and promote already landed).

`-build-probe`'s help now states the body contract an app author has to meet:
the build must appear as a whole whitespace-delimited word. {"build":"abc1234"}
satisfied the old wording and then watched forever.

And the ship door's degradation branch — read the flags, fail, log, answer
201 anyway — is pinned. It is the whole premise of "a metadata read must
never fail a ship whose bytes are already stored", and nothing tested it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2eb3aaf6f2c52f5c432d55d049883a30141e15a4 parent b98d1c7
4 files changed, +135 −4
  • apps.go +2 −1
  • deploy.go +24 −3
  • deploy_test.go +68 −0
  • internal/console/apicli_test.go +41 −0
diff --git a/apps.go b/apps.go
index a76c24333..9ac32f9a5 100644
--- a/apps.go
+++ b/apps.go
@@ -416,7 +416,8 @@ func runAppsCreate(args []string) {
buildProbe := fs.String("build-probe", "",
`path whose response body carries the running build, e.g. "/healthz" — `+
"`carlos deploy` polls it instead of the X-Carlos-Version header, which the edge "+
- "stamps from the route row and which therefore lies until the process restarts")
+ "stamps from the route row and which therefore lies until the process restarts; "+
+ `the build must appear as a whole whitespace-delimited word in the body, e.g. "ok abc1234"`)
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — the console resolves
// the account against the caller's own memberships.
diff --git a/deploy.go b/deploy.go
index 2ec49d980..1fd40ee14 100644
--- a/deploy.go
+++ b/deploy.go
@@ -236,12 +236,33 @@ func runDeploy(args []string) {
// 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)
+ //
+ // Re-checked HERE against the same grammar the create door enforces
+ // (validBuildProbe, apps.go — the byte-identical mirror of the console's
+ // own). Both existing gates are at the WRITE door; this value arrives
+ // from a READ, and the watch CONCATENATES it onto an origin. A probe not
+ // starting with "/" is the shape that matters: "https://" + host +
+ // "@evil.example/x" parses `host` as userinfo and evil.example as the
+ // HOST, so the watch would poll a machine of someone else's choosing and
+ // report ITS build as this deploy's — precisely the failure
+ // validBuildProbe exists to prevent. Only a pre-existing row, a future
+ // migration that forgets, or a hostile console can produce it; the cost
+ // of refusing to depend on that is four lines. Degrade, never fatal: the
+ // ship and promote already landed, so the honest outcome is the header
+ // watch plus a line saying why.
+ probe := shipRes.BuildProbe
+ if probe != "" {
+ if err := validBuildProbe(probe); err != nil {
+ mutedf(stdout, "ignoring the console's build probe: %v — watching the version header instead\n", err)
+ probe = ""
+ }
+ }
+ if probe != "" {
+ mutedf(stdout, "watching %s for the build %s reports itself\n", probe, *app)
}
targets := make([]watchTarget, 0, len(hosts))
for _, h := range hosts {
- targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: shipRes.BuildProbe})
+ targets = append(targets, watchTarget{Host: h, Origin: watchOrigin(h), Probe: probe})
}
diag := &watchDiagnostics{
// A timed-out host's wake reason, from the platform log stream —
diff --git a/deploy_test.go b/deploy_test.go
index 47936e120..3ee25ee4d 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -1631,3 +1631,71 @@ func TestDeployWithNoProbeStillWatchesTheHeader(t *testing.T) {
t.Fatalf("header watch did not complete:\n%s", out)
}
}
+
+// TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader pins the re-check
+// on the READ side. Both grammar gates live at the write door; this value
+// arrives from a response and is CONCATENATED onto an origin, so a probe
+// that does not start with "/" is a redirection primitive:
+// "https://" + host + "@evil.example/x" parses host as USERINFO and
+// evil.example as the host, and the watch would report a machine of someone
+// else's choosing as this deploy's build. Degrade to the header watch, say
+// why, and never dial the named host at all.
+func TestDeployRefusesAMalformedProbeAndFallsBackToTheHeader(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ const (
+ app = "console"
+ host = "console.example.test"
+ channel = "canary/dev"
+ )
+ fc := newDeployFakeConsole(t, "sha0002")
+ fc.setShipProbe("@evil.example/healthz") // no leading slash: the userinfo trick
+ 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(), "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 !strings.Contains(out, "ignoring the console's build probe") {
+ t.Fatalf("a malformed probe must be named and dropped, not silently used:\n%s", out)
+ }
+ paths.Range(func(k, _ any) bool {
+ if k.(string) != "/" {
+ t.Errorf("a refused probe was still dialled: %q", k)
+ }
+ return true
+ })
+ if _, dialled := paths.Load("/"); !dialled {
+ t.Fatalf("the header watch never ran:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+host) {
+ t.Fatalf("deploy must still complete on the header watch:\n%s", out)
+ }
+}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index cfb920fec..8d5158098 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2570,3 +2570,44 @@ func TestAPIShipOmitsTheBuildProbeWhenNoneIsDeclared(t *testing.T) {
t.Fatalf("ship body carries build_probe for an app that declares none: %v", out)
}
}
+
+// TestAPIShipSurvivesAnUnreadableBuildProbe pins the degradation the ruling
+// rests on: the bytes are STORED by the time addBuildProbe runs, so a failed
+// metadata read must log and leave the response alone — never turn a
+// successful upload into an error. The CLI then sees no key, reads "", and
+// keeps the header watch, which is the same outcome as talking to a console
+// too old to echo the field at all.
+//
+// The column is dropped rather than the database closed, the same technique
+// (and for the same reason) as TestControlPlaneLookupFailureRefuses: closing
+// it would fail the token lookup and the app 404 long before the ship handler
+// reached this read. Dropping ONE column leaves every earlier gate working
+// and fails exactly AppFlags.
+func TestAPIShipSurvivesAnUnreadableBuildProbe(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-blip")
+ if _, err := fx.st.db.Exec(`ALTER TABLE apps DROP COLUMN build_probe`); err != nil {
+ t.Fatalf("drop the probe column: %v", err)
+ }
+
+ payload := randomBytes(t, 256)
+ 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.StatusCreated {
+ t.Fatalf("ship with an unreadable build_probe = %d %v, want 201 — the bytes are already stored", status, out)
+ }
+ if _, present := out["build_probe"]; present {
+ t.Fatalf("an unreadable probe must leave the key off, not guess: %v", out)
+ }
+ // The release really landed — the point is that only the ECHO degraded.
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "console", "abc1234"); err != nil {
+ t.Fatalf("manifest after a degraded echo: %v", err)
+ }
+}