@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
@@ -11,7 +11,12 @@
11
11
  * + AI calls + storage growth on top.
12
12
  */
13
13
 
14
- import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB } from "../stack-contract.js";
14
+ import {
15
+ ADMIN_MEMORY_DEFAULT,
16
+ memoryQuantityMiB,
17
+ parseSchedulerIntervalMinutes,
18
+ SERVICE_CPU_IDLE,
19
+ } from "../stack-contract.js";
15
20
 
16
21
  export interface CostLine {
17
22
  name: string;
@@ -41,6 +46,82 @@ export interface CostEstimateInputs {
41
46
  provider?: "gcp" | "gcp-firebase";
42
47
  /** #607 — the install region; shown in the table (prices are {@link PRICED_REGION} rates). */
43
48
  region: string;
49
+ /** #1140 — the `schedulerIntervalMinutes` knob (1–15); undefined: the default. */
50
+ schedulerIntervalMinutes?: number;
51
+ }
52
+
53
+ /**
54
+ * #1140 — Workflows steps one scheduler tick costs (scheduler-setup.ts
55
+ * schedulerWorkflowSource): the claims `assign`, the signJwt call (a Google
56
+ * API, billed as an internal step), the `return` — and the POST to the
57
+ * admin, the one external HTTP step.
58
+ */
59
+ export const SCHEDULER_TICK_STEPS = { internal: 3, external: 1 } as const;
60
+
61
+ /**
62
+ * Workflows list prices (USD, cloud.google.com/workflows/pricing, as of
63
+ * 2026-11) and the monthly free tier per billing account.
64
+ */
65
+ const WORKFLOWS_PRICE = {
66
+ internalPer1000: 0.01,
67
+ externalPer1000: 0.025,
68
+ freeInternal: 5_000,
69
+ freeExternal: 2_000,
70
+ } as const;
71
+
72
+ /** Cloud Scheduler: $0.10 per job a month; the first 3 per billing account are free. */
73
+ const SCHEDULER_JOB_USD = 0.1;
74
+
75
+ /**
76
+ * #1140 — what the scheduler tick costs a month at `minutes` between ticks:
77
+ * the Cloud Scheduler job (priced as paid — the 3 free jobs are shared
78
+ * with every other project on the billing account) plus the Workflows
79
+ * steps past the free tier. What the tick costs on the admin's side is
80
+ * priced on the admin's own line ({@link adminKeptWarmByTicks}).
81
+ */
82
+ export function schedulerTickMonthlyUsd(minutes: number): number {
83
+ const ticks = Math.ceil((30 * 24 * 60) / parseSchedulerIntervalMinutes(minutes));
84
+ const internal = Math.max(
85
+ 0,
86
+ ticks * SCHEDULER_TICK_STEPS.internal - WORKFLOWS_PRICE.freeInternal,
87
+ );
88
+ const external = Math.max(
89
+ 0,
90
+ ticks * SCHEDULER_TICK_STEPS.external - WORKFLOWS_PRICE.freeExternal,
91
+ );
92
+ const usd =
93
+ SCHEDULER_JOB_USD +
94
+ (internal / 1000) * WORKFLOWS_PRICE.internalPer1000 +
95
+ (external / 1000) * WORKFLOWS_PRICE.externalPer1000;
96
+ return Math.round(usd * 100) / 100;
97
+ }
98
+
99
+ /**
100
+ * Cloud Run instance-based billing (CPU always allocated) list rates, USD,
101
+ * tier-1 region, cloud.google.com/run/pricing as of 2026-11. The free tier
102
+ * is left out: it is shared by every service on the billing account.
103
+ */
104
+ const CLOUD_RUN_INSTANCE_PRICE = { vcpuSecond: 0.000018, gibSecond: 0.000002 } as const;
105
+
106
+ /** Seconds in the 30-day month the table prices. */
107
+ const MONTH_SECONDS = 30 * 24 * 60 * 60;
108
+
109
+ /**
110
+ * #1140 — whether the scheduler tick keeps a scale-to-zero admin instance
111
+ * up around the clock. Cloud Run keeps an idle instance for up to 15
112
+ * minutes after its last request, and the tick comes at most every 15
113
+ * minutes, so with CPU always allocated (`SERVICE_CPU_IDLE.admin` false
114
+ * until #1361) the instance never stops and is billed every second. With
115
+ * request-billed CPU an idle instance costs nothing between ticks.
116
+ */
117
+ export function adminKeptWarmByTicks(adminMinInstances: number): boolean {
118
+ return adminMinInstances === 0 && !SERVICE_CPU_IDLE.admin;
119
+ }
120
+
121
+ /** One always-on admin instance (1 vCPU, `gib` memory) a month at instance-based list rates. */
122
+ export function alwaysOnAdminInstanceUsd(gib: number): number {
123
+ const perSecond = CLOUD_RUN_INSTANCE_PRICE.vcpuSecond + gib * CLOUD_RUN_INSTANCE_PRICE.gibSecond;
124
+ return Math.round(perSecond * MONTH_SECONDS);
44
125
  }
45
126
 
46
127
  /** The region the price table's list rates are for. */
@@ -71,6 +152,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
71
152
  const sqlMonthly = Math.round(sqlBase * sqlMultiplier);
72
153
 
73
154
  const provider = inputs.provider ?? "gcp";
155
+ const schedulerMinutes = parseSchedulerIntervalMinutes(inputs.schedulerIntervalMinutes);
74
156
  const adminMemory = inputs.adminMemory ?? ADMIN_MEMORY_DEFAULT;
75
157
  const adminMiB = memoryQuantityMiB(adminMemory);
76
158
  if (adminMiB === null) {
@@ -91,23 +173,26 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
91
173
  },
92
174
  {
93
175
  name: `Cloud Run admin (${adminMemory})`,
94
- // FIXME(issue #1140): ~$1 once the admin runs request-billed CPU
95
- // (SERVICE_CPU_IDLE). Until then each editing session is billed for
96
- // as long as the instance stays up, ~15 minutes past its last request.
97
- monthlyUsd:
98
- inputs.adminMinInstances === 0
176
+ // FIXME(issue #1361): with request-billed CPU (SERVICE_CPU_IDLE) the
177
+ // ticks stop keeping the instance up and this line drops to ~$7 of
178
+ // editing sessions. Until then the scheduler tick (#1140) keeps one
179
+ // instance up around the clock, billed at the always-on rate.
180
+ monthlyUsd: adminKeptWarmByTicks(inputs.adminMinInstances)
181
+ ? alwaysOnAdminInstanceUsd(adminGiB)
182
+ : inputs.adminMinInstances === 0
99
183
  ? 7
100
184
  : Math.round(1 + inputs.adminMinInstances * perMinInstanceUsd),
101
- notes:
102
- inputs.adminMinInstances === 0
103
- ? "scale-to-zero, CPU always allocated while running; ~$7/mo light editorial use"
104
- : `${inputs.adminMinInstances} min-instance${inputs.adminMinInstances > 1 ? "s" : ""} (no cold start)`,
185
+ notes: adminKeptWarmByTicks(inputs.adminMinInstances)
186
+ ? `scale-to-zero, but CPU is always allocated and the scheduler tick (every ${schedulerMinutes} min) comes before Cloud Run's 15-minute idle shutdown, so one instance (1 vCPU, ${adminMemory}) runs around the clock until request-billed CPU lands (#1361)`
187
+ : inputs.adminMinInstances === 0
188
+ ? "scale-to-zero, request-billed CPU; ~$7/mo light editorial use, ticks inside the free tier"
189
+ : `${inputs.adminMinInstances} min-instance${inputs.adminMinInstances > 1 ? "s" : ""} (no cold start); the scheduler tick adds nothing, the instance is up anyway`,
105
190
  },
106
191
  {
107
192
  name: "Quality checks (Cloud Run audit job)",
108
193
  monthlyUsd: 2,
109
194
  notes:
110
- "one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each): ~$0.05 per audited Stage, partly in the free tier; results bucket ~$0",
195
+ "one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each; a page whose runs disagree gets 2 more): ~$0.05 per audited Stage, at most ~$0.08, partly in the free tier; results bucket ~$0",
111
196
  },
112
197
  {
113
198
  name: "Screenshots (Cloud Run capture job)",
@@ -192,7 +277,7 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
192
277
  name: "Secret Manager (runtime secrets)",
193
278
  monthlyUsd: 0.2,
194
279
  notes:
195
- "6 active versions free, then ~$0.06 each; the Google sign-in client (#914) and the remote-MCP seal key (#806)",
280
+ "6 active versions free, then ~$0.06 each; the Google sign-in client (#914), the remote-MCP seal key (#806) and the staging database login's password (#1329; the staging database itself shares the Cloud SQL instance at no extra cost)",
196
281
  },
197
282
  {
198
283
  name: "BigQuery edge log sink",
@@ -211,6 +296,11 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
211
296
  notes:
212
297
  "Hourly ~20 s job keeps Google IAP in step with the user list; within the Cloud Run free tier, 1 of 3 free scheduler jobs",
213
298
  },
299
+ {
300
+ name: `Scheduler tick (Cloud Scheduler + Workflows, every ${schedulerMinutes} min)`,
301
+ monthlyUsd: schedulerTickMonthlyUsd(schedulerMinutes),
302
+ notes: `one job starts a ${SCHEDULER_TICK_STEPS.internal + SCHEDULER_TICK_STEPS.external}-step workflow that calls the admin's periodic work and plugin workers; ~${Math.ceil((30 * 24 * 60) / schedulerMinutes)} ticks/mo; what the ticks cost on the admin is on the admin line`,
303
+ },
214
304
  ];
215
305
 
216
306
  // Cents, so a sub-dollar line (the KMS key) doesn't print float noise.
@@ -68,7 +68,11 @@ import {
68
68
  REQUIRED_API_LIST,
69
69
  serviceAccountExists,
70
70
  } from "../gcloud.js";
71
- import { GCP_STACK_ENV, upgraderServiceAccountEmail } from "../gcp-names.js";
71
+ import {
72
+ GCP_STACK_ENV,
73
+ schedulerServiceAccountEmail,
74
+ upgraderServiceAccountEmail,
75
+ } from "../gcp-names.js";
72
76
  import { INFRA_CONTRACT_VERSION } from "../infra-contract.js";
73
77
  import {
74
78
  INSTALL_JOB_NAME,
@@ -116,10 +120,12 @@ import {
116
120
  resolveStackRemoteMcpIdentity,
117
121
  } from "../remote-mcp.js";
118
122
  import type { RunMode } from "../run-mode.js";
123
+ import { ensureSchedulerTick } from "../scheduler-setup.js";
119
124
  import { selfCommand } from "../self-command.js";
120
125
  import {
121
126
  ABSENT_DEFAULT_FIREWALL_RULES,
122
127
  cloudSqlPrivateDnsName,
128
+ parseSchedulerIntervalMinutes,
123
129
  type RemoteMcpIdentity,
124
130
  sameRemoteMcpIdentity,
125
131
  } from "../stack-contract.js";
@@ -234,7 +240,15 @@ export async function runGcpWizard(opts: GcpWizardOpts): Promise<void> {
234
240
  // drops the LB / Cloud CDN / Cloud Armor lines (saves ~$19/mo
235
241
  // vs gcp). Default 'gcp' for backwards compatibility with
236
242
  // installs that don't set the field.
237
- const estimate = estimateGcpCost({ ...GCP_STACK_KNOB_DEFAULTS, provider, region });
243
+ const estimate = estimateGcpCost({
244
+ ...GCP_STACK_KNOB_DEFAULTS,
245
+ provider,
246
+ region,
247
+ // #1140 — the tick's line follows the knob the wizard recorded.
248
+ ...(meta?.schedulerIntervalMinutes !== undefined
249
+ ? { schedulerIntervalMinutes: meta.schedulerIntervalMinutes }
250
+ : {}),
251
+ });
238
252
  note(
239
253
  [
240
254
  bold(
@@ -523,6 +537,18 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
523
537
  // === 11.45. The converge job: releases that change the cloud setup (#819) ===
524
538
  const convergeReady = await stepConvergeJob(projectId, region, releaseVersion);
525
539
 
540
+ // === 11.47. The scheduler tick: periodic work + plugin workers (#1140) ===
541
+ // Before operator access, which keeps the scheduler's account on IAP.
542
+ const schedulerReady = await stepSchedulerTick(
543
+ projectId,
544
+ region,
545
+ provider,
546
+ ownerEmail,
547
+ identity?.identity.adminUrl ?? null,
548
+ meta.schedulerIntervalMinutes,
549
+ );
550
+ const keepScheduler = schedulerReady || (await schedulerExists(projectId));
551
+
526
552
  // === 11.5. Operator access: IAP follows the Caelo user list ===
527
553
  await stepOperatorAccessSync(
528
554
  projectId,
@@ -530,6 +556,7 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
530
556
  provider,
531
557
  ownerEmail,
532
558
  keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
559
+ keepScheduler ? [schedulerServiceAccountEmail(projectId, GCP_STACK_ENV)] : [],
533
560
  );
534
561
 
535
562
  // === 11.6. Remote MCP (#806): LB rules (gcp) + a check of the identity env ===
@@ -546,9 +573,10 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
546
573
  // A fresh install ran every converge step above, so it is at this CLI's
547
574
  // infrastructure contract — recorded only when the upgrader and the
548
575
  // converge job are in place (the steps contract bumps 2 and 9 added) and
549
- // remote MCP's steps (#806, bump 10) succeeded. With the release version,
550
- // the admin can install the next update itself.
551
- if (upgraderReady && convergeReady && remoteMcpReady) {
576
+ // remote MCP's steps (#806, bump 10) and the scheduler tick (#1140, bump
577
+ // 18) succeeded. With the release version, the admin can install the next
578
+ // update itself.
579
+ if (upgraderReady && convergeReady && remoteMcpReady && schedulerReady) {
552
580
  const version = releaseVersion;
553
581
  // #1025 — the linux/amd64 forms Cloud Run reports, next to the indexes,
554
582
  // so the upgrader recognises what serves without a registry lookup.
@@ -998,7 +1026,7 @@ async function stepGrantBaseRoles(
998
1026
  */
999
1027
  async function stepPublicRoleIsolation(projectId: string): Promise<void> {
1000
1028
  const s = spinner();
1001
- s.start("Removing public_role from the Cloud SQL superuser group...");
1029
+ s.start("Removing the gateway logins from the Cloud SQL superuser group...");
1002
1030
  const r = await revokePublicRoleMemberships({
1003
1031
  projectId,
1004
1032
  env: GCP_STACK_ENV,
@@ -1010,7 +1038,7 @@ async function stepPublicRoleIsolation(projectId: string): Promise<void> {
1010
1038
  cancel("Aborted.");
1011
1039
  process.exit(1);
1012
1040
  }
1013
- s.stop(green("public_role holds no database roles"));
1041
+ s.stop(green("The gateway logins (public_role, public_staging_role) hold no database roles"));
1014
1042
  }
1015
1043
 
1016
1044
  /**
@@ -1216,6 +1244,78 @@ async function upgraderExists(projectId: string): Promise<boolean> {
1216
1244
  return r.ok;
1217
1245
  }
1218
1246
 
1247
+ /** Whether the scheduler tick's account exists from an earlier run (it keeps its IAP access). */
1248
+ async function schedulerExists(projectId: string): Promise<boolean> {
1249
+ const r = await gcloud([
1250
+ "iam",
1251
+ "service-accounts",
1252
+ "describe",
1253
+ schedulerServiceAccountEmail(projectId, GCP_STACK_ENV),
1254
+ `--project=${projectId}`,
1255
+ "--format=value(email)",
1256
+ ]);
1257
+ return r.ok;
1258
+ }
1259
+
1260
+ /**
1261
+ * #1140 — set up the scheduler tick (scheduler-setup.ts): the one Cloud
1262
+ * Scheduler job that calls the admin's tick every `schedulerIntervalMinutes`
1263
+ * through IAP. After migrations (the admin answers the tick once its tables
1264
+ * exist) and before operator access. Not fatal: the site works without it,
1265
+ * but plugin workers, update checks and clean-up do not run, so a failure
1266
+ * warns loudly and the install is not recorded at this contract — the next
1267
+ * wizard pass or `upgrade` retries. Idempotent.
1268
+ *
1269
+ * @returns whether the tick is in place
1270
+ */
1271
+ async function stepSchedulerTick(
1272
+ projectId: string,
1273
+ region: string,
1274
+ provider: "gcp" | "gcp-firebase",
1275
+ ownerEmail: string,
1276
+ adminUrl: string | null,
1277
+ recordedInterval: number | undefined,
1278
+ ): Promise<boolean> {
1279
+ const s = spinner();
1280
+ s.start("Setting up the scheduler tick (periodic work and plugin workers)...");
1281
+ const failed = (error: string) => {
1282
+ s.stop(yellow(`Scheduler tick not set up: ${error}`));
1283
+ log.warn(
1284
+ `The install works, but plugin workers (newsletter sends, rating aggregates), update checks and clean-up do not run until this succeeds. Re-run the installer or ${bold(selfCommand("upgrade"))} once the cause is fixed.`,
1285
+ );
1286
+ return false;
1287
+ };
1288
+ let intervalMinutes: number;
1289
+ try {
1290
+ intervalMinutes = parseSchedulerIntervalMinutes(
1291
+ recordedInterval,
1292
+ "install.json schedulerIntervalMinutes",
1293
+ );
1294
+ } catch (e) {
1295
+ return failed((e as Error).message);
1296
+ }
1297
+ if (!adminUrl) return failed("the admin's URL could not be read (see the remote-MCP step above)");
1298
+ const resolved = await resolveOperatorAccessTarget({
1299
+ provider,
1300
+ projectId,
1301
+ region,
1302
+ env: GCP_STACK_ENV,
1303
+ ownerEmail,
1304
+ });
1305
+ if (!resolved.ok) return failed(resolved.error);
1306
+ const r = await ensureSchedulerTick({
1307
+ projectId,
1308
+ region,
1309
+ env: GCP_STACK_ENV,
1310
+ adminUrl,
1311
+ resource: resolved.target.resource,
1312
+ intervalMinutes,
1313
+ });
1314
+ if (!r.ok) return failed(r.error);
1315
+ s.stop(green(`Scheduler tick ready (every ${intervalMinutes} min, ${r.done.length} steps)`));
1316
+ return true;
1317
+ }
1318
+
1219
1319
  /**
1220
1320
  * Set up the operator-access sync job (operator-access.ts): the only
1221
1321
  * principal that may change who passes IAP, run as the operator's gcloud
@@ -1228,6 +1328,7 @@ async function stepOperatorAccessSync(
1228
1328
  provider: "gcp" | "gcp-firebase",
1229
1329
  ownerEmail: string,
1230
1330
  mcpSigners: readonly string[],
1331
+ iapServiceAccounts: readonly string[],
1231
1332
  ): Promise<void> {
1232
1333
  const s = spinner();
1233
1334
  s.start("Setting up the operator-access sync job (Google IAP follows the user list)...");
@@ -1239,7 +1340,7 @@ async function stepOperatorAccessSync(
1239
1340
  ownerEmail,
1240
1341
  });
1241
1342
  const r = resolved.ok
1242
- ? await ensureOperatorAccessSync({ ...resolved.target, mcpSigners })
1343
+ ? await ensureOperatorAccessSync({ ...resolved.target, mcpSigners, iapServiceAccounts })
1243
1344
  : { ok: false as const, done: [], error: resolved.error };
1244
1345
  if (!r.ok) {
1245
1346
  s.stop(red(`Failed: ${r.error}`));
@@ -34,7 +34,7 @@ config:
34
34
  description: RDS max_connections (parameter group); every service's pool size is computed from it (docs/dev/connection-budget.md).
35
35
  caelo-aws:adminMaxInstances:
36
36
  type: string
37
- default: "5"
37
+ default: "4"
38
38
  description: Most admin tasks at once; part of the connection budget.
39
39
  caelo-aws:gatewayMaxInstances:
40
40
  type: string
@@ -44,8 +44,10 @@ import {
44
44
  } from "../../dist/aws-task-roles.js";
45
45
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
46
46
  import {
47
+ DEFAULT_ADMIN_MAX_INSTANCES,
47
48
  DEFAULT_GATEWAY_MAX_INSTANCES,
48
49
  DEFAULT_MAX_CONNECTIONS,
50
+ DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
49
51
  stackPoolBudget,
50
52
  } from "../../dist/pool-budget-stack.js";
51
53
  import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB } from "../../dist/stack-contract.js";
@@ -86,7 +88,10 @@ const maxConnections = Number.parseInt(
86
88
  cfg.get("maxConnections") ?? String(DEFAULT_MAX_CONNECTIONS.aws),
87
89
  10,
88
90
  );
89
- const adminMaxInstances = Number.parseInt(cfg.get("adminMaxInstances") ?? "5", 10);
91
+ const adminMaxInstances = Number.parseInt(
92
+ cfg.get("adminMaxInstances") ?? String(DEFAULT_ADMIN_MAX_INSTANCES),
93
+ 10,
94
+ );
90
95
  const gatewayMaxInstances = Number.parseInt(
91
96
  cfg.get("gatewayMaxInstances") ?? String(DEFAULT_GATEWAY_MAX_INSTANCES),
92
97
  10,
@@ -95,6 +100,8 @@ const poolBudget = stackPoolBudget("aws", {
95
100
  maxConnections,
96
101
  adminMaxInstances,
97
102
  gatewayMaxInstances,
103
+ // #1329 — the staging gateway joins the budget with the staging database.
104
+ stagingGatewayMaxInstances: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
98
105
  });
99
106
  if (_adminMemoryMiB === null) {
100
107
  throw new Error(`caelo-aws:adminMemory "${adminMemory}" is not a memory quantity (e.g. 2Gi)`);
@@ -131,6 +138,9 @@ const postgresPassword = pulumi.secret(randomHex(32));
131
138
  // #635) never holds admin_role's. bootstrap.sh creates the roles with these
132
139
  // (ADMIN_ROLE_PASSWORD / PUBLIC_ROLE_PASSWORD).
133
140
  const publicRolePassword = pulumi.secret(randomHex(32));
141
+ // #1329 — the staging gateway's own login on cms_public_staging (created
142
+ // with the database by bootstrap.sh, PUBLIC_STAGING_ROLE_PASSWORD).
143
+ const publicStagingRolePassword = pulumi.secret(randomHex(32));
134
144
  const csrfSecret = pulumi.secret(randomHex(32));
135
145
  const cookieSecret = pulumi.secret(randomHex(32));
136
146
  const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
@@ -151,6 +161,8 @@ function secret(name: string, value: pulumi.Output<string>): aws.secretsmanager.
151
161
 
152
162
  const pgSecret = secret("postgres-password", postgresPassword);
153
163
  const publicRoleSecret = secret("public-role-password", publicRolePassword);
164
+ // #1329 — read by nothing yet: the staging gateway's task (#749) will.
165
+ const _publicStagingRoleSecret = secret("public-staging-role-password", publicStagingRolePassword);
154
166
  const csrfSecretRes = secret("csrf-secret", csrfSecret);
155
167
  const cookieSecretRes = secret("cookie-secret", cookieSecret);
156
168
  const anthropicSecretRes = secret("anthropic-api-key", anthropicApiKey);
@@ -213,6 +225,20 @@ const publicDatabaseUrl = pulumi
213
225
  .apply(
214
226
  ([host, port, pw]) => `postgresql://public_role:${pw}@${host}:${port}/cms_public?${RDS_TLS}`,
215
227
  );
228
+ // #1329 — the admin's pool on the staging database, and the staging
229
+ // gateway's only login (no task definition reads them before #749).
230
+ const publicStagingAdminDatabaseUrl = pulumi
231
+ .all([db.address, db.port, postgresPassword])
232
+ .apply(
233
+ ([host, port, pw]) =>
234
+ `postgresql://caelo_admin:${pw}@${host}:${port}/cms_public_staging?${RDS_TLS}`,
235
+ );
236
+ const publicStagingDatabaseUrl = pulumi
237
+ .all([db.address, db.port, publicStagingRolePassword])
238
+ .apply(
239
+ ([host, port, pw]) =>
240
+ `postgresql://public_staging_role:${pw}@${host}:${port}/cms_public_staging?${RDS_TLS}`,
241
+ );
216
242
 
217
243
  // === 4. S3 buckets ===
218
244
  const mediaBucket = new aws.s3.BucketV2(`${namePrefix}-media`, {
@@ -449,6 +475,16 @@ new aws.cloudwatch.LogDelivery(
449
475
  // refuses every server-side render with that gap
450
476
  // (capture/capture-backend.ts) instead of running a browser in its own task.
451
477
  //
478
+ // FIXME(issue #749): no scheduler tick yet (#1140). On GCP one Cloud
479
+ // Scheduler job calls the admin's /api/internal/scheduler/tick every
480
+ // `schedulerIntervalMinutes` (src/scheduler-setup.ts), and the admin runs
481
+ // its periodic work and plugin workers only on that tick; self-hosted has a
482
+ // scheduler container. The AWS equivalent is one EventBridge Scheduler
483
+ // schedule that runs a small Fargate task (or Lambda) POSTing the same
484
+ // endpoint through the ALB's Cognito gate, with the same 1–15 minute knob.
485
+ // Nothing calls the tick here because no admin task exists on AWS yet; it
486
+ // lands with the admin task in #749.
487
+ //
452
488
  // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
453
489
  // GCP Settings → Updates hands app-roll releases to the upgrader job and
454
490
  // cloud-setup releases to the converge job; on AWS the admin refuses a
@@ -551,6 +587,7 @@ const dnsRecordsRequired: DnsRecord[] = [
551
587
  const out: CloudAdapterOutputs = {
552
588
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
553
589
  publicDatabaseUrl: publicDatabaseUrl as unknown as string,
590
+ publicStagingDatabaseUrl: publicStagingDatabaseUrl as unknown as string,
554
591
  mediaStorageUrl: pulumi.interpolate`s3://${mediaBucket.bucket}` as unknown as string,
555
592
  mediaCdnBaseUrl:
556
593
  pulumi.interpolate`https://${distribution.domainName}/media` as unknown as string,
@@ -565,6 +602,9 @@ const out: CloudAdapterOutputs = {
565
602
 
566
603
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
567
604
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
605
+ export const publicStagingDatabaseUrlOut = out.publicStagingDatabaseUrl;
606
+ // #1329 — what the admin task definition (#749) sets as PUBLIC_STAGING_ADMIN_DATABASE_URL.
607
+ export const publicStagingAdminDatabaseUrlOut = publicStagingAdminDatabaseUrl;
568
608
  export const mediaStorageUrlOut = out.mediaStorageUrl;
569
609
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
570
610
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -41,10 +41,15 @@ import * as pulumi from "@pulumi/pulumi";
41
41
  import type { CloudAdapterOutputs, DnsRecord } from "../../dist/adapter.js";
42
42
  import { azureFrontDoorDnsRecords } from "../../dist/azure-front-door.js";
43
43
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
44
- import { DEFAULT_MAX_CONNECTIONS, stackPoolBudget } from "../../dist/pool-budget-stack.js";
44
+ import {
45
+ DEFAULT_MAX_CONNECTIONS,
46
+ DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
47
+ stackPoolBudget,
48
+ } from "../../dist/pool-budget-stack.js";
45
49
  import {
46
50
  ADMIN_MEMORY_DEFAULT,
47
51
  azureContainerAppResources,
52
+ GATEWAY_ENVIRONMENT_ENV,
48
53
  GATEWAY_TRUSTED_PROXY_HOPS,
49
54
  MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS,
50
55
  REMOTE_MCP_MODE,
@@ -88,6 +93,8 @@ const poolBudget = stackPoolBudget("azure", {
88
93
  maxConnections,
89
94
  adminMaxInstances: ADMIN_SIDE_APPS * MAX_REPLICAS,
90
95
  gatewayMaxInstances: MAX_REPLICAS,
96
+ // #1329 — the staging gateway joins the budget with the staging database.
97
+ stagingGatewayMaxInstances: DEFAULT_STAGING_GATEWAY_MAX_INSTANCES,
91
98
  });
92
99
  // #553 — the admin runs the Lighthouse quality audit; same knob + default
93
100
  // as every adapter (stack-contract.ts). Container Apps only accepts fixed
@@ -116,6 +123,9 @@ const postgresPassword = pulumi.secret(randomHex(32));
116
123
  // #635) never holds admin_role's. bootstrap.sh creates the roles with these
117
124
  // (ADMIN_ROLE_PASSWORD / PUBLIC_ROLE_PASSWORD).
118
125
  const publicRolePassword = pulumi.secret(randomHex(32));
126
+ // #1329 — the staging gateway's own login on cms_public_staging (created
127
+ // with the database by bootstrap.sh, PUBLIC_STAGING_ROLE_PASSWORD).
128
+ const publicStagingRolePassword = pulumi.secret(randomHex(32));
119
129
  const csrfSecret = pulumi.secret(randomHex(32));
120
130
  const cookieSecret = pulumi.secret(randomHex(32));
121
131
  const anthropicApiKey = pulumi.secret(process.env.ANTHROPIC_API_KEY ?? "");
@@ -145,6 +155,8 @@ function keyVaultSecret(shortName: string, value: pulumi.Output<string>): azure.
145
155
 
146
156
  const _pgSecret = keyVaultSecret("pg-password", postgresPassword);
147
157
  keyVaultSecret("public-role-password", publicRolePassword);
158
+ // #1329 — read by nothing yet: the staging gateway's app (#750) will.
159
+ keyVaultSecret("public-staging-role-password", publicStagingRolePassword);
148
160
  keyVaultSecret("csrf-secret", csrfSecret);
149
161
  keyVaultSecret("cookie-secret", cookieSecret);
150
162
  keyVaultSecret("anthropic-api-key", anthropicApiKey);
@@ -195,6 +207,13 @@ const _pgPublic = new azure.dbforpostgresql.Database(`${namePrefix}-cms-public-d
195
207
  databaseName: "cms_public",
196
208
  charset: "UTF8",
197
209
  });
210
+ // #1329 — staging's own data, the twin of cms_public on the same server.
211
+ const _pgPublicStaging = new azure.dbforpostgresql.Database(`${namePrefix}-cms-public-staging-db`, {
212
+ resourceGroupName: rg.name,
213
+ serverName: pgServer.name,
214
+ databaseName: "cms_public_staging",
215
+ charset: "UTF8",
216
+ });
198
217
 
199
218
  // #1338 — verify-full to the server's FQDN. Flexible Server's certificate chains
200
219
  // to public roots (DigiCert Global Root G2, Microsoft RSA Root CA 2017) that
@@ -213,6 +232,19 @@ const publicAdminDatabaseUrl = pulumi
213
232
  const publicDatabaseUrl = pulumi
214
233
  .all([pgServer.fullyQualifiedDomainName, publicRolePassword])
215
234
  .apply(([host, pw]) => `postgresql://public_role:${pw}@${host}:5432/cms_public?${AZURE_TLS}`);
235
+ // #1329 — the admin's pool on the staging database (plugin schemas, the
236
+ // staging half of the projection), and the staging gateway's only login.
237
+ const publicStagingAdminDatabaseUrl = pulumi
238
+ .all([pgServer.fullyQualifiedDomainName, postgresPassword])
239
+ .apply(
240
+ ([host, pw]) => `postgresql://caelo_admin:${pw}@${host}:5432/cms_public_staging?${AZURE_TLS}`,
241
+ );
242
+ const publicStagingDatabaseUrl = pulumi
243
+ .all([pgServer.fullyQualifiedDomainName, publicStagingRolePassword])
244
+ .apply(
245
+ ([host, pw]) =>
246
+ `postgresql://public_staging_role:${pw}@${host}:5432/cms_public_staging?${AZURE_TLS}`,
247
+ );
216
248
 
217
249
  // === 4. Storage account + containers ===
218
250
  const storage = new azure.storage.StorageAccount(`${namePrefix}-st`, {
@@ -409,6 +441,15 @@ const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs
409
441
  // refuses every server-side render with that gap
410
442
  // (capture/capture-backend.ts) instead of running a browser in its own app.
411
443
  //
444
+ // FIXME(issue #750): no scheduler tick yet (#1140). On GCP one Cloud
445
+ // Scheduler job calls the admin's /api/internal/scheduler/tick every
446
+ // `schedulerIntervalMinutes` (src/scheduler-setup.ts), and the admin runs
447
+ // its periodic work and plugin workers only on that tick; self-hosted has a
448
+ // scheduler container. The Azure equivalent is a scheduled Container Apps
449
+ // job (or Logic App recurrence) POSTing the same endpoint behind Easy Auth,
450
+ // with the same 1–15 minute knob. Nothing calls the tick here because no
451
+ // admin app exists on Azure yet; it lands with the admin in #750.
452
+ //
412
453
  // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
413
454
  // GCP Settings → Updates hands app-roll releases to the upgrader job and
414
455
  // cloud-setup releases to the converge job; on Azure the admin refuses a
@@ -503,6 +544,7 @@ function containerApp(args: ContainerAppArgs): azure.app.ContainerApp {
503
544
  const adminDatabaseEnv = [
504
545
  { name: "ADMIN_DATABASE_URL", value: adminDatabaseUrl },
505
546
  { name: "PUBLIC_ADMIN_DATABASE_URL", value: publicAdminDatabaseUrl },
547
+ { name: "PUBLIC_STAGING_ADMIN_DATABASE_URL", value: publicStagingAdminDatabaseUrl },
506
548
  ...poolBudget.env.admin,
507
549
  ];
508
550
  const adminApp = containerApp({
@@ -519,8 +561,12 @@ const _gatewayApp = containerApp({
519
561
  { name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl },
520
562
  ...poolBudget.env.gateway,
521
563
  ],
522
- // Which X-Forwarded-For entry is the visitor (api-gateway client-ip.ts).
523
- extraEnv: [{ name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS.azure) }],
564
+ // Which X-Forwarded-For entry is the visitor (api-gateway client-ip.ts),
565
+ // and (#1329) that this is the live site's gateway.
566
+ extraEnv: [
567
+ { name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS.azure) },
568
+ { name: GATEWAY_ENVIRONMENT_ENV, value: "live" },
569
+ ],
524
570
  });
525
571
  // #1328 — the MCP-Auth-Proxy, its own Container App and so its own FQDN:
526
572
  // remote MCP's sign-in pages never share an origin with the site's module
@@ -565,6 +611,7 @@ const dnsRecordsRequired = pulumi
565
611
  const out: CloudAdapterOutputs = {
566
612
  adminDatabaseUrl: adminDatabaseUrl as unknown as string,
567
613
  publicDatabaseUrl: publicDatabaseUrl as unknown as string,
614
+ publicStagingDatabaseUrl: publicStagingDatabaseUrl as unknown as string,
568
615
  mediaStorageUrl:
569
616
  pulumi.interpolate`https://${storage.name}.blob.core.windows.net/media` as unknown as string,
570
617
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
@@ -578,6 +625,7 @@ const out: CloudAdapterOutputs = {
578
625
 
579
626
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
580
627
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
628
+ export const publicStagingDatabaseUrlOut = out.publicStagingDatabaseUrl;
581
629
  export const mediaStorageUrlOut = out.mediaStorageUrl;
582
630
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
583
631
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -55,7 +55,7 @@ config:
55
55
  description: Admin Cloud Run memory. 2Gi leaves room for the Lighthouse quality audit next to editor requests; billed only while an instance runs.
56
56
  caelo-gcp:adminMaxInstances:
57
57
  type: string
58
- default: "5"
58
+ default: "4"
59
59
  description: |
60
60
  Admin Cloud Run max instances. Each instance gets an equal share of
61
61
  the connection budget (docs/dev/connection-budget.md): at the
@@ -12,6 +12,7 @@ Pulumi stack provisioning Caelo on GCP managed services. Implements the shared `
12
12
  | Container runtime | Four Cloud Run services (admin / gateway / orchestrator / runner) |
13
13
  | Secret store | Secret Manager: postgres-password (admin_role) + secret-kek (Pulumi); internal-secret, tool-approval-secret and public-role-password (created by the CLI before `pulumi up`, see `src/runtime-secrets.ts`). Cloud Run reads each as a `secretKeyRef`; database URLs carry no password. The gateway's service account reads only `public-role-password` (#613, #635: the gateway has no cms_admin login). |
14
14
  | Edge-log sink | Cloud Logging project sink → BigQuery dataset `<env>_edge_logs` (queryable by P12A analytics plugin) |
15
+ | Scheduler tick (CLI-owned, #1140) | One Cloud Scheduler job `caelo-<env>-scheduler-tick` starts the Workflows workflow of the same name every `schedulerIntervalMinutes` (1–15, default 5; `--scheduler-interval-minutes` on the installer, recorded in install.json). The workflow signs an IAP credential as `caelo-<env>-scheduler` and POSTs the admin's `/api/internal/scheduler/tick`, which runs the due periodic work and plugin workers. Set up by the installer and `upgrade` (`src/scheduler-setup.ts`), not by this stack. |
15
16
 
16
17
  The Caelo runtime never knows it's on GCP — every Cloud Run service consumes plain `DATABASE_URL` / `MEDIA_STORAGE_URL` / `SECRETS_PROVIDER` env vars.
17
18