| 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 |