carlos / platform Public

apps create: -control-plane and -build-probe, stamped with the claim

Set in the SAME call that claims the name. Claiming first and flagging
second leaves a window in which the production console is an ordinary app
any ops member — or any Publish service credential — could roll.

Both validated before claimApp, like -place: a refused flag refuses the
whole create rather than leaving a claimed name with the wrong shape, and
costs no attempt against the per-day create budget. Both echoed in the
response (read back from the store, not just trusted from the setters'
error returns), so a caller can tell 'claimed and flagged' from 'claimed,
flag silently didn't'.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev b272e56524abb854fdb3d72684f6ea392355106f parent dfd85be
8 files changed, +257 −37
  • apimode.go +25 −9
  • apimode_test.go +6 −6
  • apps.go +57 −11
  • apps_test.go +46 −0
  • deploy.go +1 −1
  • internal/console/apicli.go +49 −10
  • internal/console/apps_claim_test.go +60 −0
  • internal/console/server.go +13 −0
diff --git a/apimode.go b/apimode.go
index fdec85bdb..e675178ed 100644
--- a/apimode.go
+++ b/apimode.go
@@ -832,12 +832,17 @@ func (s *apiSession) promote(ctx context.Context, a apiAcct, app, version, chann
// echoes the field at all leaves this at the same "" zero value
// encoding/json gives an explicit empty string, which is exactly what makes
// it usable as the one signal runAppsCreate needs — see
-// placementMismatchErr, apps.go).
+// placementMismatchErr, apps.go). ControlPlane and BuildProbe are the same
+// kind of echo for the two claim-time flags (Task 3): what the console
+// actually stamped on the row, read back after the write, not merely what
+// the request asked for.
type apiCreateAppResult struct {
- Account string `json:"account"`
- App string `json:"app"`
- URL string `json:"url"`
- Placement string `json:"placement"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ URL string `json:"url"`
+ Placement string `json:"placement"`
+ ControlPlane bool `json:"control_plane"`
+ BuildProbe string `json:"build_probe"`
}
// createApp claims one app name through the console — the API door onto the
@@ -852,11 +857,22 @@ type apiCreateAppResult struct {
// server-side, plus fleet ownership) before the app is created, so an
// invalid or foreign place refuses the whole create rather than silently
// creating an unplaced app.
-func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string) (apiCreateAppResult, error) {
+//
+// controlPlane and buildProbe are the claim-time flags Task 3 adds
+// (-control-plane, -build-probe): set in the SAME call that claims the
+// name, so there is no window in which the freshly-claimed row is an
+// ordinary app before it is flagged. controlPlane costs an owner-only 403
+// (server-side; there is no client-side role check possible here) and
+// buildProbe is grammar-checked client-side before this is ever called
+// (validBuildProbe, apps.go), same "a shape the server is certain to
+// refuse should cost nothing" principle as place.
+func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe string) (apiCreateAppResult, error) {
in := struct {
- Name string `json:"name"`
- Place string `json:"place,omitempty"`
- }{app, place}
+ Name string `json:"name"`
+ Place string `json:"place,omitempty"`
+ ControlPlane bool `json:"control_plane,omitempty"`
+ BuildProbe string `json:"build_probe,omitempty"`
+ }{app, place, controlPlane, buildProbe}
status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in)
if err != nil {
return apiCreateAppResult{}, err
diff --git a/apimode_test.go b/apimode_test.go
index e0bfd941a..94923dae3 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2898,7 +2898,7 @@ func TestAPISessionCreateApp(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "")
+ res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "")
if err != nil {
t.Fatalf("createApp: %v", err)
}
@@ -2909,7 +2909,7 @@ func TestAPISessionCreateApp(t *testing.T) {
t.Fatalf("result = %+v", res)
}
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", ""); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, ""); err != nil {
t.Fatalf("createApp exact: %v", err)
}
if gotQuery != "exact=sqid" {
@@ -2917,13 +2917,13 @@ func TestAPISessionCreateApp(t *testing.T) {
}
status = http.StatusConflict
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", ""); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, ""); err == nil ||
err.Error() != "that name is taken" {
t.Fatalf("409 err = %v, want the server's sentence", err)
}
status = http.StatusNotFound
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", ""); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", "", false, ""); err == nil ||
!strings.Contains(err.Error(), "check -account,") {
t.Fatalf("404 err = %v, want create's account-only wording", err)
}
@@ -2956,14 +2956,14 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi"); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, ""); err != nil {
t.Fatalf("createApp with place: %v", err)
}
if gotPlace != "fleet/pi" {
t.Fatalf("wire place = %q, want fleet/pi", gotPlace)
}
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", ""); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", "", false, ""); err != nil {
t.Fatalf("createApp with no place: %v", err)
}
if sawPlaceKey && gotPlace != "" {
diff --git a/apps.go b/apps.go
index baee56d07..1693057f8 100644
--- a/apps.go
+++ b/apps.go
@@ -376,10 +376,35 @@ func validPlaceTarget(target string) error {
return nil
}
+// validBuildProbe is the client-side grammar gate for -build-probe, checked
+// BEFORE the API call on the same principle validAppName and
+// validPlaceTarget already state: a shape the server is certain to refuse
+// should cost nothing against the create verb's own scarce per-day budget.
+// "" is always valid (no probe). The grammar mirrors the server's own
+// validBuildProbe (internal/console/server.go) exactly: an absolute path,
+// no query, no spaces, no fragment.
+func validBuildProbe(probe string) error {
+ if probe == "" {
+ return nil
+ }
+ if !strings.HasPrefix(probe, "/") || strings.ContainsAny(probe, "?# \t") || len(probe) > 256 {
+ return fmt.Errorf(`%q is not a usable build probe — want an absolute URL path with no query, `+
+ `e.g. "/healthz"`, probe)
+ }
+ return nil
+}
+
func runAppsCreate(args []string) {
fs := flag.NewFlagSet("apps create", flag.ExitOnError)
app := fs.String("app", "", "app name to claim (lowercase letters, digits, dashes)")
place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/<name>"`)
+ controlPlane := fs.Bool("control-plane", false,
+ "mark the app as the control plane: it refuses trash, purge, rename and transfer, "+
+ "and refuses a service credential's promote (owner only)")
+ 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")
// 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.
@@ -388,12 +413,12 @@ func runAppsCreate(args []string) {
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
if strings.TrimSpace(*app) == "" {
- log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-account <sqid|name>] " +
- "[-account-id <sqid>] [-console <origin>]")
+ log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-control-plane] [-build-probe /path] " +
+ "[-account <sqid|name>] [-account-id <sqid>] [-console <origin>]")
}
// Client-side gates before the server's: the create limiter charges
- // ATTEMPTS, so a name — or a place value — the server is certain to
- // refuse should cost a typo nothing.
+ // ATTEMPTS, so a name — or a place or build-probe value — the server is
+ // certain to refuse should cost a typo nothing.
if err := validAppName(strings.TrimSpace(*app)); err != nil {
log.Fatalf("apps create: %v", err)
}
@@ -401,6 +426,10 @@ func runAppsCreate(args []string) {
if err := validPlaceTarget(place2); err != nil {
log.Fatalf("apps create: %v", err)
}
+ probe2 := strings.TrimSpace(*buildProbe)
+ if err := validBuildProbe(probe2); err != nil {
+ log.Fatalf("apps create: %v", err)
+ }
sess, err := resolveMode(*consoleFlag)
if err != nil {
@@ -418,7 +447,7 @@ func runAppsCreate(args []string) {
}
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
- res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2)
+ res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2)
if err != nil {
log.Fatalf("apps create: %v", err)
}
@@ -426,9 +455,6 @@ func runAppsCreate(args []string) {
fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account)
fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL))
fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App)
- if place2 == "" {
- return
- }
// The app WAS created (everything above already printed and is true) —
// what's still unverified is whether -place actually stuck. The server
// echoes back what it really stored (handleAPICreateApp's "placement"
@@ -438,10 +464,30 @@ func runAppsCreate(args []string) {
// (whose readJSON ignores an unknown "place" key and returns 200 with
// no such field at all — which unmarshals to the same "" zero value)
// report success for an app that is NOT actually placed.
- if err := placementMismatchErr(res.App, place2, res.Placement); err != nil {
- log.Fatalf("apps create: %v", err)
+ if place2 != "" {
+ if err := placementMismatchErr(res.App, place2, res.Placement); err != nil {
+ log.Fatalf("apps create: %v", err)
+ }
+ fmt.Fprintf(stdout, " placed on %s\n", place2)
+ }
+ // Same principle for the two claim-time flags: print what the console
+ // actually stamped (res.ControlPlane / res.BuildProbe), not what was
+ // asked for, so a silently-swallowed store-write error is visible here
+ // rather than trusted away.
+ if *controlPlane {
+ if !res.ControlPlane {
+ log.Fatalf("apps create: -control-plane did not stick (the console did not confirm it) — "+
+ "the app %s was created, but is NOT flagged as the control plane", res.App)
+ }
+ fmt.Fprintf(stdout, " control plane: yes\n")
+ }
+ if probe2 != "" {
+ if res.BuildProbe != probe2 {
+ log.Fatalf("apps create: -build-probe did not stick (console echoed %q, want %q) — "+
+ "the app %s was created, but has no build probe set", res.BuildProbe, probe2, res.App)
+ }
+ fmt.Fprintf(stdout, " build probe: %s\n", res.BuildProbe)
}
- fmt.Fprintf(stdout, " placed on %s\n", place2)
}
// placementMismatchErr compares what -place asked for (requested) against
diff --git a/apps_test.go b/apps_test.go
index 8f1f95509..5fbebf7a1 100644
--- a/apps_test.go
+++ b/apps_test.go
@@ -268,6 +268,52 @@ func TestPlacementMismatchErr(t *testing.T) {
}
}
+// TestValidBuildProbeMirrorsTheServer pins the client-side grammar gate for
+// -build-probe against the same shapes the server's own validBuildProbe
+// (internal/console/server.go) refuses — a typo here must cost nothing
+// against the create verb's own scarce per-day budget, so the two gates
+// must agree on what's refused.
+func TestValidBuildProbeMirrorsTheServer(t *testing.T) {
+ for _, bad := range []string{"healthz", "http://x/healthz", "/healthz?x=1", "/health z", strings.Repeat("/a", 200)} {
+ if err := validBuildProbe(bad); err == nil {
+ t.Errorf("validBuildProbe(%q) = nil, want an error", bad)
+ }
+ }
+ for _, good := range []string{"", "/healthz", "/a/b/c"} {
+ if err := validBuildProbe(good); err != nil {
+ t.Errorf("validBuildProbe(%q) = %v, want nil", good, err)
+ }
+ }
+}
+
+// TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe pins the wire
+// shape createApp sends (Task 3: -control-plane, -build-probe) and that it
+// surfaces the server's echo — NOT the request's own values — the same
+// "what actually stuck" principle Placement already established.
+func TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe(t *testing.T) {
+ var gotBody map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "account": "acct", "app": "app", "url": "http://x/a/acct/apps/app",
+ "control_plane": true, "build_probe": "/healthz",
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz")
+ if err != nil {
+ t.Fatalf("createApp: %v", err)
+ }
+ if gotBody["control_plane"] != true || gotBody["build_probe"] != "/healthz" {
+ t.Fatalf("request body = %v, want control_plane=true build_probe=/healthz", gotBody)
+ }
+ if !res.ControlPlane || res.BuildProbe != "/healthz" {
+ t.Fatalf("createApp result = %+v, want the server's echoed flags", res)
+ }
+}
+
// newHeadlessTestStore returns a fresh DirStore plus a parsed keypair
// (release.GenerateKey base64-encoded, parsed the same way the CLI parses
// CARLOS_RELEASE_PUBKEY/CARLOS_RELEASE_KEY) for headlessTrash's direct tests.
diff --git a/deploy.go b/deploy.go
index 19a872778..6cf96667f 100644
--- a/deploy.go
+++ b/deploy.go
@@ -415,7 +415,7 @@ func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noA
log.Fatalf("deploy: %v", verr)
}
}
- res, err := sess.createApp(ctx, acct, name, "")
+ res, err := sess.createApp(ctx, acct, name, "", false, "")
if err != nil {
log.Fatalf("deploy: create app: %v", err)
}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 05fca6385..c8d1bc05b 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -357,14 +357,30 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
- Name string `json:"name"`
- Place string `json:"place"`
+ Name string `json:"name"`
+ Place string `json:"place"`
+ ControlPlane bool `json:"control_plane"`
+ BuildProbe string `json:"build_probe"`
}
if !readJSON(w, r, &req) {
return
}
name := normalizeAppName(req.Name)
place := strings.TrimSpace(req.Place)
+ // Validated BEFORE claimApp, like place: a refused flag refuses the WHOLE
+ // create rather than leaving a claimed name with the wrong shape, and a
+ // shape the server is certain to refuse costs no claim attempt.
+ if req.ControlPlane && acct.Role != RoleOwner {
+ jsonError(w, http.StatusForbidden,
+ "only an owner can mark an app as the control plane")
+ return
+ }
+ probe := strings.TrimSpace(req.BuildProbe)
+ if probe != "" && !validBuildProbe(probe) {
+ jsonError(w, http.StatusBadRequest,
+ `build_probe must be an absolute URL path with no query, e.g. "/healthz"`)
+ return
+ }
if place != "" {
fleetName, validShape := registry.FleetPlacement(place)
if !validShape {
@@ -413,16 +429,39 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not check the release store — try again")
case claimOK:
s.audit(acct, name, addr, "create", "created app "+name+" (cli)")
- // "placement" echoes what actually stuck (claimApp's second
- // return) — NOT the request's own place value — so a caller (CLI's
- // runAppsCreate, apps.go) can tell "placed" apart from "claim
- // succeeded, placement silently didn't" (a swallowed store-write
- // error) by comparing this field to what it asked for, rather than
- // trusting a 200 to mean everything it asked for happened.
+ // Stamped in the SAME call that claims the name, after the claim has
+ // actually succeeded: claiming first and flagging second would leave
+ // a window in which the freshly-claimed row is an ordinary app any
+ // ops member — or a Publish service credential — could roll before
+ // the flag lands. Both setters are plain UPDATEs that no-op on a
+ // missing row (store.go), so they are safe here only because claimOK
+ // guarantees CreateAppIn just succeeded.
+ if req.ControlPlane {
+ if err := s.store.SetAppControlPlane(acct.ID, name, true); err != nil {
+ log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err)
+ }
+ }
+ if probe != "" {
+ if err := s.store.SetAppBuildProbe(acct.ID, name, probe); err != nil {
+ log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err)
+ }
+ }
+ // "placement", "control_plane" and "build_probe" all echo what
+ // actually stuck, NOT the request's own values — so a caller (CLI's
+ // runAppsCreate, apps.go) can tell "claimed and flagged" apart from
+ // "claimed, flag silently didn't" (a swallowed store-write error) by
+ // comparing these fields to what it asked for, rather than trusting
+ // a 200 to mean everything it asked for happened.
+ gotCP, gotProbe, err := s.store.AppFlags(acct.ID, name)
+ if err != nil {
+ log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err)
+ }
writeJSON(w, http.StatusOK, map[string]any{
"account": acct.Sqid, "app": name,
- "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
- "placement": placed,
+ "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
+ "placement": placed,
+ "control_plane": gotCP,
+ "build_probe": gotProbe,
})
default:
// Any future claimOutcome lands here rather than silently 200ing
diff --git a/internal/console/apps_claim_test.go b/internal/console/apps_claim_test.go
index b94eba071..0a861650f 100644
--- a/internal/console/apps_claim_test.go
+++ b/internal/console/apps_claim_test.go
@@ -140,3 +140,63 @@ func TestBrowserDoorMemberRefusedOverExistingObjects(t *testing.T) {
t.Fatalf("HasAppIn after a member claim = %v %v, want false", has, err)
}
}
+
+// TestCreateAppStampsControlPlaneAndProbe: the flags are set in the SAME call
+// that claims the name. Claiming first and flagging second would leave a
+// window in which the production console is an ordinary app any ops member
+// could roll.
+func TestCreateAppStampsControlPlaneAndProbe(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedBucketApp(t, fx, "bab", "console")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-cp")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
+ "name": "console", "control_plane": true, "build_probe": "/healthz",
+ }, token)
+ if status != http.StatusOK {
+ t.Fatalf("create = %d %v, want 200", status, out)
+ }
+ cp, probe, err := fx.st.AppFlags(OpsAccountID, "console")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cp || probe != "/healthz" {
+ t.Fatalf("flags after create = %v %q, want true \"/healthz\"", cp, probe)
+ }
+}
+
+// TestOnlyAnOwnerSetsControlPlane: the flag refuses destruction, so setting it
+// is an ownership act — a member who could set it could also make an app
+// undeletable by its owner.
+func TestOnlyAnOwnerSetsControlPlane(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "member@keymail.dev", "code-create-cp-member")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
+ "name": "notyours", "control_plane": true,
+ }, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("member setting control_plane = %d %v, want 403", status, out)
+ }
+ has, err := fx.st.HasAppIn(OpsAccountID, "notyours")
+ if err != nil || has {
+ t.Fatalf("the app was claimed anyway (%v) — a refused flag must refuse the WHOLE create, like -place does", has)
+ }
+}
+
+// TestBuildProbeMustBeAnAbsolutePath: the probe is a URL path the deploy watch
+// dials. Anything else is a typo that would otherwise surface as a deploy that
+// times out for reasons nobody can see.
+func TestBuildProbeMustBeAnAbsolutePath(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-probe-bad")
+ for _, bad := range []string{"healthz", "http://x/healthz", "/healthz?x=1", "/health z"} {
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]any{
+ "name": "probe" + bad[:1], "build_probe": bad,
+ }, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("build_probe %q = %d %v, want 400", bad, status, out)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index b4f842c3e..de68a4af6 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -2394,6 +2394,19 @@ func normalizeAppName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
+// validBuildProbe bounds the deploy watch's probe path to what it actually
+// dials: an absolute path, no query, no spaces. The watch appends its own
+// cache-busting query parameter (deploy.go), so a probe carrying one would
+// produce a URL with two — and a probe that is a full URL would silently
+// point the watch at another host entirely, which is the one mistake here
+// that could report a DIFFERENT deployment's build as this one's.
+func validBuildProbe(p string) bool {
+ if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, "?# \t") {
+ return false
+ }
+ return len(p) <= 256
+}
+
// claimApp claims an app name for acct — once, ever. A name is refused when
// the apps table has it, when ANY owner exists for it (CLI-granted apps stay
// claim-proof), or when the release store already holds anything under