carlos / platform Public

console: control_plane and build_probe on the app row

Both in the ALTER block AND in migrateAppsKey's apps_new copy list. The
rebuild copies an enumerated column list, so a column in only the first
place is silently dropped on a database that has not been rebuilt yet —
which is a self-hoster jumping versions, not a hypothetical. deleted_at
and placement are already in both for this reason (store.go:280); the new
test builds a pre-rebuild database by hand and proves it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e54758be7cb2dee53f1805e53229a7196068cfcb parent 1258e9a
2 files changed, +141 −12
  • internal/console/migrate_test.go +73 −0
  • internal/console/store.go +68 −12
diff --git a/internal/console/migrate_test.go b/internal/console/migrate_test.go
index 6ab315691..5b6d3d2ef 100644
--- a/internal/console/migrate_test.go
+++ b/internal/console/migrate_test.go
@@ -105,3 +105,76 @@ func TestAppsRebuildPreservesEveryRow(t *testing.T) {
t.Fatalf("same name in another account = %v %v, want it to be free", created, err)
}
}
+
+// TestAppFlagsSurviveTheAppsKeyRebuild: control_plane and build_probe are
+// added by the ALTER block, which runs BEFORE migrateAppsKey's table rebuild
+// (store.go:189 vs :231). The rebuild copies an ENUMERATED column list, so a
+// column missing from apps_new is silently dropped on any database that has
+// not been rebuilt yet — a self-hoster jumping versions. deleted_at and
+// placement are in both places for this reason (store.go:280); these must be
+// too, and this test is what proves it rather than a comment.
+func TestAppFlagsSurviveTheAppsKeyRebuild(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "old.db")
+
+ // A pre-rebuild database: PRIMARY KEY(app), the shape migrateAppsKey exists
+ // to replace. Built by hand because no released binary produces it any more.
+ raw, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE apps (
+ app TEXT PRIMARY KEY,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT ''
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`INSERT INTO apps (app, account_id, created_by, created_at)
+VALUES ('console', 1, 'paul@keymail.dev', 1)`); err != nil {
+ t.Fatal(err)
+ }
+ if err := raw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ st, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open (which runs migrate) on a pre-rebuild database: %v", err)
+ }
+ defer st.Close()
+
+ if err := st.SetAppControlPlane(OpsAccountID, "console", true); err != nil {
+ t.Fatalf("SetAppControlPlane: %v — the column did not survive the rebuild", err)
+ }
+ if err := st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil {
+ t.Fatalf("SetAppBuildProbe: %v", err)
+ }
+ cp, probe, err := st.AppFlags(OpsAccountID, "console")
+ if err != nil {
+ t.Fatalf("AppFlags: %v", err)
+ }
+ if !cp || probe != "/healthz" {
+ t.Fatalf("AppFlags = %v %q, want true \"/healthz\"", cp, probe)
+ }
+}
+
+// TestAppFlagsDefaultOff: every row that existed before this change means
+// "not the control plane, no probe" — the additive-default idiom the kind and
+// service-principal columns used.
+func TestAppFlagsDefaultOff(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ cp, probe, err := fx.st.AppFlags(OpsAccountID, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cp || probe != "" {
+ t.Fatalf("a freshly claimed app = %v %q, want false \"\"", cp, probe)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index 8d950b2e4..8020ed06c 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -223,6 +223,22 @@ func (s *Store) migrate() error {
// identical to "unassigned" under the old tier-less reading. See
// SetAccountTier for the fixed set of non-empty values.
`ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`,
+ // Control-plane apps (console-as-an-app spec §6): the flag REFUSES
+ // destruction — trash, purge, rename, transfer — and refuses a
+ // service principal's promote. It deliberately does NOT gate a
+ // person's promote: `stable`'s fresh-auth 403 has no API-side
+ // step-up and `carlos deploy` log.Fatalfs on a failed promote AFTER
+ // the ship has landed (deploy.go:206-208), so gating promotion would
+ // break `carlos deploy -app console` — the command the flag exists to
+ // make safe. Deploying the console should be easy; destroying it
+ // should be hard.
+ `ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;`,
+ // The path `carlos deploy` polls for the build the app's own PROCESS
+ // reports, instead of X-Carlos-Version — which the edge stamps from
+ // the route row (edge.go:1336) and which therefore lies from the
+ // moment adoption swaps the symlink until something restarts the
+ // process. "" keeps today's header behaviour byte for byte.
+ `ALTER TABLE apps ADD COLUMN build_probe TEXT NOT NULL DEFAULT '';`,
} {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -269,21 +285,24 @@ func (s *Store) migrateAppsKey() error {
defer tx.Rollback()
for _, stmt := range []string{
`CREATE TABLE apps_new (
- app TEXT NOT NULL,
- account_id INTEGER NOT NULL DEFAULT 1,
- created_by TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- deleted_at INTEGER NOT NULL DEFAULT 0,
- placement TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '',
+ control_plane INTEGER NOT NULL DEFAULT 0,
+ build_probe TEXT NOT NULL DEFAULT '',
PRIMARY KEY (account_id, app)
);`,
// The ALTERs above run before migrateAppsKey in migrate(), so
- // deleted_at and placement already exist on the old table by the time
- // this rebuild copies them — a row already trashed, or already
- // placed, when the rebuild runs must not have that state silently
- // dropped.
- `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement)
- SELECT app, account_id, created_by, created_at, deleted_at, placement FROM apps;`,
+ // deleted_at, placement, control_plane and build_probe already exist
+ // on the old table by the time this rebuild copies them — a row
+ // already trashed, already placed, already the control plane, or
+ // already carrying a build probe, when the rebuild runs must not
+ // have that state silently dropped.
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe)
+ SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe FROM apps;`,
`DROP TABLE apps;`,
`ALTER TABLE apps_new RENAME TO apps;`,
} {
@@ -1173,6 +1192,43 @@ func (s *Store) SetAppPlacement(accountID int64, app, target string) error {
return err
}
+// AppFlags reads the two per-app deployment facts the control-plane work
+// added: whether this app IS the control plane, and the path `carlos deploy`
+// should poll for a build the app's own process reports.
+//
+// One query for both because both doors that read them read them together
+// (the ship gate and the deploy watch), and a second round trip to the same
+// row buys nothing.
+func (s *Store) AppFlags(accountID int64, app string) (controlPlane bool, buildProbe string, err error) {
+ err = s.db.QueryRow(`SELECT control_plane, build_probe FROM apps WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&controlPlane, &buildProbe)
+ if errors.Is(err, sql.ErrNoRows) {
+ // No row is not an error here: every caller has already been through
+ // apiAccountApp's byte-identical 404, so reaching this with no row
+ // means a race with a purge, and "not the control plane, no probe" is
+ // the right reading of an app that no longer exists.
+ return false, "", nil
+ }
+ return controlPlane, buildProbe, err
+}
+
+// SetAppControlPlane marks (or unmarks) an app as the control plane. The row
+// is guaranteed to exist: both callers reach this straight after a successful
+// CreateAppIn or behind apiAccountApp's gate.
+func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) error {
+ _, err := s.db.Exec(`UPDATE apps SET control_plane = ? WHERE account_id = ? AND app = ?`,
+ on, accountID, app)
+ return err
+}
+
+// SetAppBuildProbe stamps the deploy watch's probe path. "" clears it back to
+// header-watching.
+func (s *Store) SetAppBuildProbe(accountID int64, app, path string) error {
+ _, err := s.db.Exec(`UPDATE apps SET build_probe = ? WHERE account_id = ? AND app = ?`,
+ path, accountID, app)
+ return err
+}
+
// AppPlacement reads an app's stored placement fact: "" for unplaced (or a
// row this column predates — the ALTER above defaults every existing row to
// "", indistinguishable from "never placed", which is exactly right: the