@caelo-cms/provisioning 0.11.10 → 0.11.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/dist/adapter.d.ts +6 -0
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adopt-existing.d.ts +6 -2
  4. package/dist/adopt-existing.d.ts.map +1 -1
  5. package/dist/adopt-existing.js +57 -0
  6. package/dist/adopt-existing.js.map +1 -1
  7. package/dist/agent/command.d.ts +5 -2
  8. package/dist/agent/command.d.ts.map +1 -1
  9. package/dist/agent/command.js +1 -1
  10. package/dist/agent/command.js.map +1 -1
  11. package/dist/agent-db.js +196 -23
  12. package/dist/cli-routing.js +1 -1
  13. package/dist/cli-routing.js.map +1 -1
  14. package/dist/cli.js +59 -7
  15. package/dist/cli.js.map +1 -1
  16. package/dist/cloudsql-tls.d.ts +30 -0
  17. package/dist/cloudsql-tls.d.ts.map +1 -1
  18. package/dist/cloudsql-tls.js +7 -1
  19. package/dist/cloudsql-tls.js.map +1 -1
  20. package/dist/compose.d.ts +14 -0
  21. package/dist/compose.d.ts.map +1 -1
  22. package/dist/compose.js +54 -1
  23. package/dist/compose.js.map +1 -1
  24. package/dist/converge-db-trust.d.ts +44 -0
  25. package/dist/converge-db-trust.d.ts.map +1 -0
  26. package/dist/converge-db-trust.js +158 -0
  27. package/dist/converge-db-trust.js.map +1 -0
  28. package/dist/converge-job-gcp.d.ts +12 -2
  29. package/dist/converge-job-gcp.d.ts.map +1 -1
  30. package/dist/converge-job-gcp.js +42 -3
  31. package/dist/converge-job-gcp.js.map +1 -1
  32. package/dist/converge-job-preflight.d.ts +34 -2
  33. package/dist/converge-job-preflight.d.ts.map +1 -1
  34. package/dist/converge-job-preflight.js +76 -1
  35. package/dist/converge-job-preflight.js.map +1 -1
  36. package/dist/converge-job-setup.d.ts.map +1 -1
  37. package/dist/converge-job-setup.js +4 -1
  38. package/dist/converge-job-setup.js.map +1 -1
  39. package/dist/converge-job.d.ts +44 -3
  40. package/dist/converge-job.d.ts.map +1 -1
  41. package/dist/converge-job.js +105 -11
  42. package/dist/converge-job.js.map +1 -1
  43. package/dist/gcp-names.d.ts +13 -0
  44. package/dist/gcp-names.d.ts.map +1 -1
  45. package/dist/gcp-names.js +21 -0
  46. package/dist/gcp-names.js.map +1 -1
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/infra-contract.d.ts +1 -1
  52. package/dist/infra-contract.d.ts.map +1 -1
  53. package/dist/infra-contract.js +22 -1
  54. package/dist/infra-contract.js.map +1 -1
  55. package/dist/install-job.d.ts +10 -2
  56. package/dist/install-job.d.ts.map +1 -1
  57. package/dist/install-job.js +10 -1
  58. package/dist/install-job.js.map +1 -1
  59. package/dist/install-state.d.ts +7 -0
  60. package/dist/install-state.d.ts.map +1 -1
  61. package/dist/install-state.js.map +1 -1
  62. package/dist/lifecycle.d.ts.map +1 -1
  63. package/dist/lifecycle.js +3 -0
  64. package/dist/lifecycle.js.map +1 -1
  65. package/dist/mcp-auth-proxy-setup.d.ts +3 -1
  66. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  67. package/dist/mcp-auth-proxy-setup.js +9 -4
  68. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  69. package/dist/migration-runner.d.ts.map +1 -1
  70. package/dist/migration-runner.js +8 -3
  71. package/dist/migration-runner.js.map +1 -1
  72. package/dist/migration-targets.d.ts +11 -0
  73. package/dist/migration-targets.d.ts.map +1 -0
  74. package/dist/migration-targets.js +12 -0
  75. package/dist/migration-targets.js.map +1 -0
  76. package/dist/operator-access.d.ts +10 -0
  77. package/dist/operator-access.d.ts.map +1 -1
  78. package/dist/operator-access.js +5 -0
  79. package/dist/operator-access.js.map +1 -1
  80. package/dist/pool-budget-stack.d.ts +13 -1
  81. package/dist/pool-budget-stack.d.ts.map +1 -1
  82. package/dist/pool-budget-stack.js +11 -1
  83. package/dist/pool-budget-stack.js.map +1 -1
  84. package/dist/public-role-isolation.d.ts +7 -3
  85. package/dist/public-role-isolation.d.ts.map +1 -1
  86. package/dist/public-role-isolation.js +25 -13
  87. package/dist/public-role-isolation.js.map +1 -1
  88. package/dist/release-contract.d.ts.map +1 -1
  89. package/dist/release-contract.js +3 -0
  90. package/dist/release-contract.js.map +1 -1
  91. package/dist/runtime-secrets.d.ts +2 -0
  92. package/dist/runtime-secrets.d.ts.map +1 -1
  93. package/dist/runtime-secrets.js +19 -15
  94. package/dist/runtime-secrets.js.map +1 -1
  95. package/dist/scheduler-setup.d.ts +75 -0
  96. package/dist/scheduler-setup.d.ts.map +1 -0
  97. package/dist/scheduler-setup.js +331 -0
  98. package/dist/scheduler-setup.js.map +1 -0
  99. package/dist/self-hosted-roles.d.ts +16 -9
  100. package/dist/self-hosted-roles.d.ts.map +1 -1
  101. package/dist/self-hosted-roles.js +49 -15
  102. package/dist/self-hosted-roles.js.map +1 -1
  103. package/dist/self-hosted-scheduler-interval.d.ts +21 -0
  104. package/dist/self-hosted-scheduler-interval.d.ts.map +1 -0
  105. package/dist/self-hosted-scheduler-interval.js +68 -0
  106. package/dist/self-hosted-scheduler-interval.js.map +1 -0
  107. package/dist/self-hosted-upgrade.d.ts +7 -4
  108. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  109. package/dist/self-hosted-upgrade.js +9 -5
  110. package/dist/self-hosted-upgrade.js.map +1 -1
  111. package/dist/stack-contract.d.ts +67 -6
  112. package/dist/stack-contract.d.ts.map +1 -1
  113. package/dist/stack-contract.js +87 -6
  114. package/dist/stack-contract.js.map +1 -1
  115. package/dist/staging-database.d.ts +22 -0
  116. package/dist/staging-database.d.ts.map +1 -0
  117. package/dist/staging-database.js +117 -0
  118. package/dist/staging-database.js.map +1 -0
  119. package/dist/upgrade/converge.d.ts +5 -0
  120. package/dist/upgrade/converge.d.ts.map +1 -1
  121. package/dist/upgrade/converge.js +110 -10
  122. package/dist/upgrade/converge.js.map +1 -1
  123. package/dist/upgrade/upgrade.d.ts +6 -0
  124. package/dist/upgrade/upgrade.d.ts.map +1 -1
  125. package/dist/upgrade/upgrade.js +3 -0
  126. package/dist/upgrade/upgrade.js.map +1 -1
  127. package/dist/upgrader-gcp.d.ts +5 -4
  128. package/dist/upgrader-gcp.d.ts.map +1 -1
  129. package/dist/upgrader-gcp.js +5 -4
  130. package/dist/upgrader-gcp.js.map +1 -1
  131. package/dist/upgrader-iam-preflight.d.ts +8 -6
  132. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  133. package/dist/upgrader-iam-preflight.js +5 -1
  134. package/dist/upgrader-iam-preflight.js.map +1 -1
  135. package/dist/wizard.d.ts +7 -0
  136. package/dist/wizard.d.ts.map +1 -1
  137. package/dist/wizard.js +11 -0
  138. package/dist/wizard.js.map +1 -1
  139. package/dist/wizards/gcp-cost.d.ts +31 -0
  140. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  141. package/dist/wizards/gcp-cost.js +82 -12
  142. package/dist/wizards/gcp-cost.js.map +1 -1
  143. package/dist/wizards/gcp.d.ts.map +1 -1
  144. package/dist/wizards/gcp.js +87 -11
  145. package/dist/wizards/gcp.js.map +1 -1
  146. package/package.json +2 -2
  147. package/src/adapter.ts +6 -0
  148. package/src/admin-memory.test.ts +1 -1
  149. package/src/adopt-existing.test.ts +46 -0
  150. package/src/adopt-existing.ts +58 -2
  151. package/src/agent/command.ts +6 -3
  152. package/src/caddy-redirects.test.ts +1 -1
  153. package/src/cli-routing.test.ts +1 -1
  154. package/src/cli-routing.ts +1 -1
  155. package/src/cli.ts +63 -8
  156. package/src/cloudsql-tls.test.ts +30 -0
  157. package/src/cloudsql-tls.ts +10 -3
  158. package/src/compose.ts +68 -0
  159. package/src/converge-db-trust.test.ts +237 -0
  160. package/src/converge-db-trust.ts +212 -0
  161. package/src/converge-job-gcp.ts +42 -2
  162. package/src/converge-job-preflight.test.ts +106 -0
  163. package/src/converge-job-preflight.ts +97 -3
  164. package/src/converge-job-setup.ts +4 -1
  165. package/src/converge-job.test.ts +213 -0
  166. package/src/converge-job.ts +175 -19
  167. package/src/database-tls-builders.test.ts +3 -2
  168. package/src/deployed-release.test.ts +3 -0
  169. package/src/gateway-env-isolation.test.ts +40 -7
  170. package/src/gcp-names.ts +25 -0
  171. package/src/index.test.ts +3 -3
  172. package/src/index.ts +6 -0
  173. package/src/infra-contract.test.ts +5 -3
  174. package/src/infra-contract.ts +22 -1
  175. package/src/install-job.ts +10 -1
  176. package/src/install-state.ts +7 -0
  177. package/src/lifecycle.ts +3 -0
  178. package/src/mcp-auth-proxy-setup.test.ts +33 -1
  179. package/src/mcp-auth-proxy-setup.ts +11 -4
  180. package/src/migration-runner.ts +14 -9
  181. package/src/migration-targets.ts +12 -0
  182. package/src/operator-access.test.ts +15 -0
  183. package/src/operator-access.ts +14 -0
  184. package/src/pool-budget-stack.ts +15 -0
  185. package/src/pool-budget-stacks.test.ts +38 -13
  186. package/src/public-role-isolation.test.ts +18 -8
  187. package/src/public-role-isolation.ts +31 -17
  188. package/src/release-contract.ts +3 -0
  189. package/src/runtime-secrets.test.ts +18 -8
  190. package/src/runtime-secrets.ts +37 -18
  191. package/src/scheduler-setup.test.ts +201 -0
  192. package/src/scheduler-setup.ts +375 -0
  193. package/src/self-hosted-backup.test.ts +1 -0
  194. package/src/self-hosted-compose.test.ts +72 -1
  195. package/src/self-hosted-roles.integration.test.ts +2 -0
  196. package/src/self-hosted-roles.test.ts +49 -19
  197. package/src/self-hosted-roles.ts +60 -20
  198. package/src/self-hosted-scheduler-interval.test.ts +66 -0
  199. package/src/self-hosted-scheduler-interval.ts +79 -0
  200. package/src/self-hosted-start.test.ts +1 -0
  201. package/src/self-hosted-upgrade.test.ts +2 -1
  202. package/src/self-hosted-upgrade.ts +14 -8
  203. package/src/stack-contract.test.ts +31 -3
  204. package/src/stack-contract.ts +113 -6
  205. package/src/stack-converge-secrets.test.ts +2 -0
  206. package/src/stack-converge.test.ts +6 -4
  207. package/src/staging-database.test.ts +114 -0
  208. package/src/staging-database.ts +148 -0
  209. package/src/upgrade/converge.ts +130 -9
  210. package/src/upgrade/upgrade.test.ts +85 -2
  211. package/src/upgrade/upgrade.ts +9 -0
  212. package/src/upgrader-gcp.ts +5 -4
  213. package/src/upgrader-iam-preflight.ts +15 -10
  214. package/src/wizard.ts +19 -0
  215. package/src/wizards/gcp-cost.test.ts +54 -1
  216. package/src/wizards/gcp-cost.ts +102 -12
  217. package/src/wizards/gcp.ts +109 -8
  218. package/stacks/aws/Pulumi.yaml +1 -1
  219. package/stacks/aws/index.ts +41 -1
  220. package/stacks/azure/index.ts +51 -3
  221. package/stacks/gcp/Pulumi.yaml +1 -1
  222. package/stacks/gcp/README.md +1 -0
  223. package/stacks/gcp/index.ts +50 -1
  224. package/stacks/gcp-firebase/Pulumi.yaml +1 -1
  225. package/stacks/gcp-firebase/index.ts +50 -1
  226. package/stacks/self-hosted/Pulumi.yaml +3 -0
  227. package/stacks/self-hosted/README.md +4 -0
  228. package/stacks/self-hosted/index.ts +29 -4
@@ -49,6 +49,7 @@ import {
49
49
  mcpIapServiceAccountEmail,
50
50
  operatorAccessJobResource,
51
51
  runServiceAccountEmail,
52
+ schedulerServiceAccountEmail,
52
53
  staticPublisherServiceAccountId,
53
54
  upgraderJobResource,
54
55
  } from "./gcp-names.js";
@@ -161,6 +162,16 @@ export interface DatabaseUrls<V = string> {
161
162
  * state the admin publishes into cms_public.
162
163
  */
163
164
  readonly public: V;
165
+ /**
166
+ * #1329 — admin_role on cms_public_staging: the admin's third pool, for
167
+ * the staging database's plugin schemas and its half of the projection.
168
+ */
169
+ readonly publicStagingAdmin: V;
170
+ /**
171
+ * #1329 — public_staging_role on cms_public_staging, the staging
172
+ * gateway's only pool (served from P3b-2 on). It reaches no live data.
173
+ */
174
+ readonly publicStaging: V;
164
175
  }
165
176
 
166
177
  /** Port Cloud SQL Postgres listens on (private IP). */
@@ -191,6 +202,8 @@ export function databaseUrls(host: string): DatabaseUrls {
191
202
  admin: url("admin_role", "cms_admin"),
192
203
  publicAdmin: url("admin_role", "cms_public"),
193
204
  public: url("public_role", "cms_public"),
205
+ publicStagingAdmin: url("admin_role", "cms_public_staging"),
206
+ publicStaging: url("public_staging_role", "cms_public_staging"),
194
207
  };
195
208
  }
196
209
 
@@ -238,7 +251,7 @@ export function cloudSqlPrivateDnsName(
238
251
 
239
252
  /** The database URL vars each service carries (the env contract below sets them). */
240
253
  export const DATABASE_URL_VARS = {
241
- admin: ["ADMIN_DATABASE_URL", "PUBLIC_ADMIN_DATABASE_URL"],
254
+ admin: ["ADMIN_DATABASE_URL", "PUBLIC_ADMIN_DATABASE_URL", "PUBLIC_STAGING_ADMIN_DATABASE_URL"],
242
255
  gateway: ["PUBLIC_DATABASE_URL"],
243
256
  // #1328 — no database login at all. On gcp-firebase the service still
244
257
  // carries the gateway's URL until its roll removes it with the rest of
@@ -328,6 +341,54 @@ export const CAPTURE_JOB_ENV_VAR = "CAELO_CAPTURE_JOB";
328
341
  export const CAPTURE_BUCKET_ENV_VAR = "CAELO_CAPTURE_BUCKET";
329
342
  export const MEDIA_BUCKET_ENV_VAR = "CAELO_MEDIA_BUCKET";
330
343
 
344
+ /**
345
+ * #1140 — the admin env var naming the service account of the scheduler
346
+ * tick (scheduler-setup.ts): the one IAP identity the admin's
347
+ * `/api/internal/scheduler/tick` accepts. `upgrade` leaves it untouched when
348
+ * it could not set the tick up.
349
+ */
350
+ export const SCHEDULER_SERVICE_ACCOUNT_ENV_VAR = "CAELO_SCHEDULER_SERVICE_ACCOUNT";
351
+
352
+ /**
353
+ * #1140 — how often the one scheduler job calls the admin's tick, in
354
+ * minutes (the `schedulerIntervalMinutes` install knob). Each task's own
355
+ * cadence lives in the admin's due-table; this only bounds how soon a due
356
+ * task runs. 1 is Cloud Scheduler's floor; above 15 Cloud Run has stopped
357
+ * the idle admin, so every tick would pay a cold start without saving
358
+ * anything (maintainer decision on #1140, 2026-10-26).
359
+ */
360
+ export const SCHEDULER_INTERVAL_MINUTES = { default: 5, min: 1, max: 15 } as const;
361
+
362
+ /**
363
+ * Parse the `schedulerIntervalMinutes` knob. Undefined (not configured) is
364
+ * the default; anything else must be an integer in the bounds — an
365
+ * out-of-range or non-numeric value throws (CLAUDE.md §2: no silent
366
+ * substitute).
367
+ *
368
+ * @param raw - the configured value (CLI flag, Pulumi config, install.json).
369
+ * @param source - names where the value came from, for the error.
370
+ */
371
+ export function parseSchedulerIntervalMinutes(
372
+ raw: string | number | undefined,
373
+ source = "schedulerIntervalMinutes",
374
+ ): number {
375
+ if (raw === undefined || raw === "") return SCHEDULER_INTERVAL_MINUTES.default;
376
+ const n = typeof raw === "number" ? raw : /^\s*\d+\s*$/.test(raw) ? Number(raw) : Number.NaN;
377
+ const { min, max } = SCHEDULER_INTERVAL_MINUTES;
378
+ if (!Number.isInteger(n) || n < min || n > max) {
379
+ throw new Error(
380
+ `${source} ${JSON.stringify(raw)} is not a whole number of minutes between ${min} and ${max} (default ${SCHEDULER_INTERVAL_MINUTES.default})`,
381
+ );
382
+ }
383
+ return n;
384
+ }
385
+
386
+ /** The Cloud Scheduler / cron expression of a tick every `minutes` minutes (UTC). */
387
+ export function schedulerCron(minutes: number): string {
388
+ const m = parseSchedulerIntervalMinutes(minutes);
389
+ return m === 1 ? "* * * * *" : `*/${m} * * * *`;
390
+ }
391
+
331
392
  /** Where the admin image ships the static-generator CLI. */
332
393
  const GENERATOR_CLI = "/app/apps/static-generator/src/cli.ts";
333
394
 
@@ -397,6 +458,8 @@ export function adminMediaVolumeTemplate(volume: MediaVolumeContract) {
397
458
  export type RuntimeSecret =
398
459
  | "postgres-password"
399
460
  | "public-role-password"
461
+ /** #1329 — public_staging_role's password, the staging gateway's login. */
462
+ | "public-staging-role-password"
400
463
  | "secret-kek"
401
464
  | "internal-secret"
402
465
  | "tool-approval-secret"
@@ -418,7 +481,7 @@ export type RuntimeSecret =
418
481
  | "mcp-oauth-seal-key";
419
482
 
420
483
  /** The Postgres roles Caelo's services log in as, with their passwords. */
421
- export type DatabaseRole = "admin_role" | "public_role";
484
+ export type DatabaseRole = "admin_role" | "public_role" | "public_staging_role";
422
485
 
423
486
  /**
424
487
  * Each database role's password secret — one per role (#613), so the
@@ -430,6 +493,8 @@ export type DatabaseRole = "admin_role" | "public_role";
430
493
  export const DATABASE_ROLE_SECRET: Readonly<Record<DatabaseRole, RuntimeSecret>> = {
431
494
  admin_role: "postgres-password",
432
495
  public_role: "public-role-password",
496
+ // #1329 — its own, so neither gateway can ever log in as the other.
497
+ public_staging_role: "public-staging-role-password",
433
498
  };
434
499
 
435
500
  /**
@@ -445,6 +510,9 @@ export const CLI_GENERATED_SECRETS = [
445
510
  // #613 — the gateway's own role password. Generated (never seeded from
446
511
  // `postgres-password`), so the gateway never learns admin_role's.
447
512
  "public-role-password",
513
+ // #1329 — the staging gateway's role password, never shared with the
514
+ // live gateway's (a test asserts the two public logins share no secret).
515
+ "public-staging-role-password",
448
516
  // #650 — the staging access gate's key: signs "Open staging" links and
449
517
  // session cookies, derives the crawler credentials. Read by the admin
450
518
  // (issues) and the gateway (verifies).
@@ -457,6 +525,19 @@ export const CLI_GENERATED_SECRETS = [
457
525
  "mcp-oauth-seal-key",
458
526
  ] as const satisfies readonly RuntimeSecret[];
459
527
 
528
+ /**
529
+ * CLI-generated secrets no running service reads yet, each with what reads
530
+ * it until then. Everything else in {@link CLI_GENERATED_SECRETS} must be
531
+ * read by a service (stack-contract.test.ts), so an orphaned secret cannot
532
+ * ship by accident.
533
+ */
534
+ export const SECRETS_SET_UP_AHEAD_OF_THEIR_READER: Readonly<
535
+ Partial<Record<(typeof CLI_GENERATED_SECRETS)[number], string>>
536
+ > = {
537
+ "public-staging-role-password":
538
+ "#1329 — public_staging_role's password. The stacks (gcp.sql.User) and the converge (staging-database.ts) set the role's password from it; the staging gateway service (#1330) is its first runtime reader.",
539
+ };
540
+
460
541
  /** A CLI-generated secret's first value: random hex, random base64, or a fixed placeholder. */
461
542
  export type GeneratedSecretFormat =
462
543
  | { readonly kind: "hex32" }
@@ -473,6 +554,7 @@ export const GENERATED_SECRET_FORMAT: Readonly<
473
554
  "internal-secret": { kind: "hex32" },
474
555
  "tool-approval-secret": { kind: "hex32" },
475
556
  "public-role-password": { kind: "hex32" },
557
+ "public-staging-role-password": { kind: "hex32" },
476
558
  "staging-gate-secret": { kind: "hex32" },
477
559
  // The gateway's seal key contract (#806): base64 of exactly 32 bytes.
478
560
  "mcp-oauth-seal-key": { kind: "base64-32" },
@@ -510,6 +592,8 @@ export const SERVICE_SECRET_ENV: Readonly<
510
592
  admin: {
511
593
  ADMIN_DATABASE_PASSWORD: "postgres-password",
512
594
  PUBLIC_ADMIN_DATABASE_PASSWORD: "postgres-password",
595
+ // #1329 — admin_role on the staging database.
596
+ PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD: "postgres-password",
513
597
  // P18 — encrypts AI provider keys and other at-rest secrets in cms_admin.
514
598
  CAELO_SECRET_KEK: "secret-kek",
515
599
  // P15.1 — HMAC key of the /api/internal/* bearer tokens (internal-jwt.ts).
@@ -747,6 +831,15 @@ function commonEnv(inputs: ServiceEnvInputs): CloudRunEnvVar[] {
747
831
  */
748
832
  export const TRUSTED_PROXY_HOPS_ENV = "CAELO_TRUSTED_PROXY_HOPS";
749
833
 
834
+ /**
835
+ * #1329 — the env var naming which environment a gateway serves, `live` or
836
+ * `staging` (apps/api-gateway database-env.ts): its database and login
837
+ * follow from it, and it refuses anything of the other environment's. The
838
+ * gateway refuses to boot without it. Every adapter sets `live` on the
839
+ * gateway it runs today; the staging gateway (P3b-2) gets `staging`.
840
+ */
841
+ export const GATEWAY_ENVIRONMENT_ENV = "CAELO_GATEWAY_ENVIRONMENT";
842
+
750
843
  /**
751
844
  * {@link TRUSTED_PROXY_HOPS_ENV} per provider — a property of each
752
845
  * adapter's topology (CLAUDE.md §11.B), so it is declared here once and every
@@ -785,6 +878,9 @@ export const GATEWAY_TRUSTED_PROXY_HOPS: Readonly<Record<SupportedProvider, numb
785
878
  export function gatewayEnvContract<V>(inputs: RuntimeEnvInputs<V>): CloudRunEnvVar<V | string>[] {
786
879
  return [
787
880
  ...commonEnv(inputs),
881
+ // #1329 — this service is the live site's gateway: it may reach
882
+ // cms_public as public_role and refuses anything of the staging data.
883
+ { name: GATEWAY_ENVIRONMENT_ENV, value: "live" },
788
884
  { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS[inputs.provider]) },
789
885
  { name: "PUBLIC_DATABASE_URL", value: inputs.databaseUrls.public },
790
886
  ...secretEnv("gateway", inputs.env),
@@ -900,12 +996,14 @@ export const DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES = 10;
900
996
  * anyone choosing it — billed for every idle minute an instance stayed up.
901
997
  *
902
998
  * The gateway and the MCP-Auth-Proxy only do work inside requests. The
903
- * admin still runs its background work (Tier 5: audit worker, importer,
904
- * plugin cron, GC) on in-process timers that need CPU between requests.
999
+ * admin's periodic work and plugin workers run on the scheduler tick
1000
+ * (#1140), but the quality audit and the site-import crawl still run on
1001
+ * in-process timers that need CPU between requests (#1359, #1360), and the
1002
+ * plugin runtime heartbeat would read an idling instance as down.
905
1003
  */
906
1004
  export const SERVICE_CPU_IDLE: Readonly<Record<CloudRunSlug, boolean>> = {
907
- // FIXME(issue #1140): true once the admin's background work no longer
908
- // depends on CPU between requests (jobs, scheduler, plugin host).
1005
+ // FIXME(issue #1361): true once nothing in the admin depends on CPU
1006
+ // between requests (the category-A jobs, the heartbeat at request start).
909
1007
  admin: false,
910
1008
  gateway: true,
911
1009
  "mcp-auth-proxy": true,
@@ -1180,6 +1278,9 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
1180
1278
  // admin_role on cms_public: cross-DB reads, plugin DDL, migrations.
1181
1279
  // Not public_role (write-limited).
1182
1280
  { name: "PUBLIC_ADMIN_DATABASE_URL", value: inputs.databaseUrls.publicAdmin },
1281
+ // #1329 — admin_role on cms_public_staging: the staging database's
1282
+ // plugin schemas and its half of the projection.
1283
+ { name: "PUBLIC_STAGING_ADMIN_DATABASE_URL", value: inputs.databaseUrls.publicStagingAdmin },
1183
1284
  // #551 — the admin seeds site_defaults.site_base_url from it (canonical,
1184
1285
  // og:url, sitemap) when it is not configured yet.
1185
1286
  { name: "CAELO_SITE_URL", value: publicSiteUrl(domain) },
@@ -1211,6 +1312,12 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
1211
1312
  { name: CAPTURE_JOB_ENV_VAR, value: captureJobResource(projectId, inputs.region, env) },
1212
1313
  { name: CAPTURE_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "capture") },
1213
1314
  { name: MEDIA_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "media") },
1315
+ // #1140 — the only IAP identity the admin's scheduler tick endpoint
1316
+ // accepts (scheduler-setup.ts).
1317
+ {
1318
+ name: SCHEDULER_SERVICE_ACCOUNT_ENV_VAR,
1319
+ value: schedulerServiceAccountEmail(projectId, env),
1320
+ },
1214
1321
  // #914 — where Settings → Google sign-in writes the install's Google
1215
1322
  // sign-in client (add-version only, see the run SA's secretVersionAdder
1216
1323
  // invariant).
@@ -360,6 +360,8 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
360
360
  expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
361
361
  "ADMIN_DATABASE_PASSWORD",
362
362
  "ADMIN_DATABASE_URL",
363
+ // #1329 — the gateway says it is the live site's.
364
+ "CAELO_GATEWAY_ENVIRONMENT",
363
365
  // No remote-MCP env: the broker is the MCP-Auth-Proxy's (#1328).
364
366
  // #650 — the staging gate key arrives in the same roll.
365
367
  "CAELO_STAGING_GATE_SECRET",
@@ -335,14 +335,16 @@ describe("planContractEnv", () => {
335
335
  // #650 — the same roll converges the protected staging domain's host
336
336
  // and gate key onto an install that predates it.
337
337
  expect(plan.services.admin.flags).toEqual([
338
- "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_CAPTURE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-capture,CAELO_CAPTURE_BUCKET=acme-caelo-production-capture,CAELO_MEDIA_BUCKET=acme-caelo-production-media,CAELO_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-oauth-client,CAELO_GATEWAY_SERVICE=caelo-production-data-gateway,CAELO_STAGING_HOST=staging.acme.com",
339
- "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
338
+ // #1329 — and the staging database's pool onto one that predates it.
339
+ "--update-env-vars=PUBLIC_STAGING_ADMIN_DATABASE_URL=postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public_staging?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem,CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_CONVERGE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-converge,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_CAPTURE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-capture,CAELO_CAPTURE_BUCKET=acme-caelo-production-capture,CAELO_MEDIA_BUCKET=acme-caelo-production-media,CAELO_SCHEDULER_SERVICE_ACCOUNT=caelo-production-scheduler@acme.iam.gserviceaccount.com,CAELO_OPERATOR_OAUTH_CLIENT_STORE=projects/acme/secrets/caelo-production-operator-oauth-client,CAELO_GATEWAY_SERVICE=caelo-production-data-gateway,CAELO_STAGING_HOST=staging.acme.com",
340
+ "--update-secrets=PUBLIC_STAGING_ADMIN_DATABASE_PASSWORD=caelo-production-postgres-password:latest,CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
340
341
  ]);
341
342
  // #1328 — the Firebase rewrite follows the data gateway (above), which
342
343
  // gets no remote-MCP env.
343
344
  expect(plan.services.gateway.flags).toEqual([
344
- // The gateway learns which forwarded address is the visitor (#845).
345
- "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
345
+ // The gateway learns which forwarded address is the visitor (#845),
346
+ // and (#1329) that it is the live site's.
347
+ "--update-env-vars=CAELO_GATEWAY_ENVIRONMENT=live,CAELO_TRUSTED_PROXY_HOPS=1",
346
348
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
347
349
  ]);
348
350
  // The old gateway service becomes the proxy in one revision: every
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import type { GcloudResult } from "./gcloud.js";
5
+ import type { GcloudRunner } from "./gcloud-retry.js";
6
+ import type { HttpFetch } from "./runtime-secrets.js";
7
+ import { ensureStagingDatabase } from "./staging-database.js";
8
+
9
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
10
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
11
+ const noSleep = async () => {};
12
+ const install = { projectId: "acme", env: "production", credentials: "operator" } as const;
13
+
14
+ /** gcloud with an instance `pg-1`, the databases and users given, and a staging password secret. */
15
+ function fakeGcloud(state: { databases: string[]; users: string[] }) {
16
+ const calls: string[] = [];
17
+ const run: GcloudRunner = async (args) => {
18
+ const line = args.join(" ");
19
+ calls.push(line);
20
+ if (line.startsWith("sql instances list")) return ok("pg-1\n");
21
+ if (line.startsWith("sql databases list")) return ok(`${state.databases.join("\n")}\n`);
22
+ if (line.startsWith("sql users list")) return ok(`${state.users.join("\n")}\n`);
23
+ if (line.startsWith("sql databases create")) return ok();
24
+ if (line.startsWith("secrets versions access latest")) return ok("s3cret-staging");
25
+ if (line.startsWith("auth print-access-token")) return ok("tok\n");
26
+ return fail(`unexpected: ${line}`);
27
+ };
28
+ return { run, calls };
29
+ }
30
+
31
+ /** The Cloud SQL Admin API, finishing every operation at once. */
32
+ function fakeHttp() {
33
+ const requests: { method: string; url: string; body?: string }[] = [];
34
+ const http: HttpFetch = async (url, init) => {
35
+ requests.push({ method: init.method, url, ...(init.body ? { body: init.body } : {}) });
36
+ return { ok: true, status: 200, text: async () => JSON.stringify({ status: "DONE" }) };
37
+ };
38
+ return { http, requests };
39
+ }
40
+
41
+ describe("ensureStagingDatabase (#1329)", () => {
42
+ it("creates the database and the login on an install from before them", async () => {
43
+ const { run, calls } = fakeGcloud({
44
+ databases: ["cms_admin", "cms_public"],
45
+ users: ["admin_role", "public_role"],
46
+ });
47
+ const { http, requests } = fakeHttp();
48
+ const outcomes = await ensureStagingDatabase(install, { run, http, sleep: noSleep });
49
+ expect(outcomes.map((o) => [o.id, o.status])).toEqual([
50
+ ["database cms_public_staging", "applied"],
51
+ ["database role public_staging_role", "applied"],
52
+ ]);
53
+ expect(calls).toContain(
54
+ "sql databases create cms_public_staging --instance=pg-1 --project=acme --quiet",
55
+ );
56
+ // The password goes in the request body, read from its own secret.
57
+ expect(calls).toContain(
58
+ "secrets versions access latest --secret=caelo-production-public-staging-role-password --project=acme",
59
+ );
60
+ expect(requests).toEqual([
61
+ {
62
+ method: "POST",
63
+ url: "https://sqladmin.googleapis.com/v1/projects/acme/instances/pg-1/users",
64
+ body: JSON.stringify({ name: "public_staging_role", password: "s3cret-staging" }),
65
+ },
66
+ ]);
67
+ expect(calls.some((c) => c.includes("s3cret-staging"))).toBe(false);
68
+ });
69
+
70
+ it("leaves an install that has both alone, re-applying the password", async () => {
71
+ const { run, calls } = fakeGcloud({
72
+ databases: ["cms_admin", "cms_public", "cms_public_staging"],
73
+ users: ["admin_role", "public_role", "public_staging_role"],
74
+ });
75
+ const { http, requests } = fakeHttp();
76
+ const outcomes = await ensureStagingDatabase(install, { run, http, sleep: noSleep });
77
+ expect(outcomes.map((o) => o.status)).toEqual(["present", "present"]);
78
+ expect(calls.some((c) => c.startsWith("sql databases create"))).toBe(false);
79
+ expect(requests.map((r) => [r.method, r.url])).toEqual([
80
+ [
81
+ "PUT",
82
+ "https://sqladmin.googleapis.com/v1/projects/acme/instances/pg-1/users?name=public_staging_role",
83
+ ],
84
+ ]);
85
+ });
86
+
87
+ it("reports a refused create with the permission it needs", async () => {
88
+ const calls: string[] = [];
89
+ const run: GcloudRunner = async (args) => {
90
+ const line = args.join(" ");
91
+ calls.push(line);
92
+ if (line.startsWith("sql instances list")) return ok("pg-1\n");
93
+ if (line.startsWith("sql databases list")) return ok("cms_admin\n");
94
+ if (line.startsWith("sql databases create")) return fail("PERMISSION_DENIED: nope");
95
+ return ok("public_staging_role\n");
96
+ };
97
+ const { http } = fakeHttp();
98
+ const [db] = await ensureStagingDatabase(install, { run, http, sleep: noSleep });
99
+ expect(db?.status).toBe("failed");
100
+ expect(db?.error).toContain("cloudsql.databases.create");
101
+ });
102
+
103
+ it("fails without an instance and creates nothing", async () => {
104
+ const calls: string[] = [];
105
+ const run: GcloudRunner = async (args) => {
106
+ calls.push(args.join(" "));
107
+ return ok("");
108
+ };
109
+ const outcomes = await ensureStagingDatabase(install, { run, sleep: noSleep });
110
+ expect(outcomes).toHaveLength(1);
111
+ expect(outcomes[0]?.status).toBe("failed");
112
+ expect(calls).toHaveLength(1);
113
+ });
114
+ });
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The staging database on an existing GCP install (issue #1329, gateway
5
+ * hardening P3b-1, epic #763 E10).
6
+ *
7
+ * A new install gets `cms_public_staging` and its gateway login
8
+ * `public_staging_role` from the stack (`gcp.sql.Database` / `gcp.sql.User`).
9
+ * An install from before has neither, and an update must bring them
10
+ * without a terminal (CLAUDE.md §11.C): the converge — `cms-provision
11
+ * upgrade` or the in-cloud converge job behind Settings → Updates — runs
12
+ * {@link ensureStagingDatabase} before the migrations, which need the
13
+ * database (migrate.ts `public-staging`, cms_admin migration 0530).
14
+ *
15
+ * Both go through the Cloud SQL Admin API, the way the stack creates them,
16
+ * so the next `pulumi up` adopts the same objects. The database is created
17
+ * with gcloud (no secret involved); the user through the Admin API with the
18
+ * password in the request body (runtime-secrets.ts `ensureDatabaseRolePassword`),
19
+ * read from its CLI-generated secret `public-staging-role-password`, never
20
+ * on argv. The Admin API makes the new user a member of cloudsqlsuperuser;
21
+ * the revoke that follows (public-role-isolation.ts) and the role audit
22
+ * take care of it, as for public_role. Idempotent: an install that has both
23
+ * only gets the password re-applied.
24
+ */
25
+
26
+ import { findSqlInstance } from "./gateway-credentials.js";
27
+ import { gcloud as defaultGcloud } from "./gcloud.js";
28
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
29
+ import {
30
+ type EnsureOutcome,
31
+ ensureDatabaseRolePassword,
32
+ type HttpFetch,
33
+ } from "./runtime-secrets.js";
34
+ import { type GcloudCredentials, lookupFailure } from "./upgrade/gcp-lookup.js";
35
+
36
+ /** The staging site's database (#1329). */
37
+ export const STAGING_DATABASE = "cms_public_staging";
38
+
39
+ /** The staging gateway's login (#1329). */
40
+ export const STAGING_DATABASE_ROLE = "public_staging_role";
41
+
42
+ /** The names gcloud lists, one per line. */
43
+ function names(stdout: string): string[] {
44
+ return stdout
45
+ .split("\n")
46
+ .map((l) => l.trim())
47
+ .filter(Boolean);
48
+ }
49
+
50
+ /**
51
+ * Create `cms_public_staging` and `public_staging_role` where the install
52
+ * lacks them, and set the role's password to its secret. Reports instead of
53
+ * throwing; a `failed` outcome names the next step.
54
+ */
55
+ export async function ensureStagingDatabase(
56
+ install: {
57
+ readonly projectId: string;
58
+ readonly env: string;
59
+ readonly credentials: GcloudCredentials;
60
+ },
61
+ deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep; readonly http?: HttpFetch } = {},
62
+ ): Promise<EnsureOutcome[]> {
63
+ const run = deps.run ?? defaultGcloud;
64
+ const sleep = deps.sleep ?? realSleep;
65
+ const lookup = await findSqlInstance(install, { run });
66
+ if (lookup.status !== "found") {
67
+ return [
68
+ {
69
+ id: `database ${STAGING_DATABASE}`,
70
+ status: "failed",
71
+ error: lookupFailure(lookup, "Cloud SQL instance", install.credentials),
72
+ },
73
+ ];
74
+ }
75
+ const instance = lookup.name;
76
+ const where = [`--instance=${instance}`, `--project=${install.projectId}`];
77
+ return [
78
+ await ensureDatabase(run, sleep, instance, where),
79
+ await ensureRole(run, deps, install, instance, where),
80
+ ];
81
+ }
82
+
83
+ async function ensureDatabase(
84
+ run: GcloudRunner,
85
+ sleep: Sleep,
86
+ instance: string,
87
+ where: readonly string[],
88
+ ): Promise<EnsureOutcome> {
89
+ const id = `database ${STAGING_DATABASE}`;
90
+ const listed = await run(["sql", "databases", "list", ...where, "--format=value(name)"]);
91
+ if (!listed.ok) {
92
+ return {
93
+ id,
94
+ status: "failed",
95
+ error: `gcloud sql databases list on ${instance}: ${listed.stderr.trim()}`,
96
+ };
97
+ }
98
+ if (names(listed.stdout).includes(STAGING_DATABASE)) return { id, status: "present" };
99
+ const created = await runWithRetry(run, sleep, [
100
+ "sql",
101
+ "databases",
102
+ "create",
103
+ STAGING_DATABASE,
104
+ ...where,
105
+ "--quiet",
106
+ ]);
107
+ if (!created.ok) {
108
+ return {
109
+ id,
110
+ status: "failed",
111
+ error: `gcloud sql databases create ${STAGING_DATABASE} on ${instance}: ${created.stderr.trim()}. The account running this needs cloudsql.databases.create on the project; re-run once it has.`,
112
+ };
113
+ }
114
+ return { id, status: "applied", detail: `created on ${instance}` };
115
+ }
116
+
117
+ async function ensureRole(
118
+ run: GcloudRunner,
119
+ deps: { readonly sleep?: Sleep; readonly http?: HttpFetch },
120
+ install: { readonly projectId: string; readonly env: string },
121
+ instance: string,
122
+ where: readonly string[],
123
+ ): Promise<EnsureOutcome> {
124
+ const id = `database role ${STAGING_DATABASE_ROLE}`;
125
+ const listed = await run(["sql", "users", "list", ...where, "--format=value(name)"]);
126
+ if (!listed.ok) {
127
+ return {
128
+ id,
129
+ status: "failed",
130
+ error: `gcloud sql users list on ${instance}: ${listed.stderr.trim()}`,
131
+ };
132
+ }
133
+ const exists = names(listed.stdout).includes(STAGING_DATABASE_ROLE);
134
+ const outcome = await ensureDatabaseRolePassword(
135
+ { projectId: install.projectId, env: install.env, sqlInstance: instance },
136
+ STAGING_DATABASE_ROLE,
137
+ {
138
+ run,
139
+ ...(deps.sleep ? { sleep: deps.sleep } : {}),
140
+ ...(deps.http ? { http: deps.http } : {}),
141
+ create: !exists,
142
+ },
143
+ );
144
+ if (outcome.status === "failed") return { ...outcome, id };
145
+ return exists
146
+ ? { id, status: "present", detail: "password re-applied" }
147
+ : { id, status: "applied", detail: `created on ${instance}` };
148
+ }