@caelo-cms/provisioning 0.11.9 → 0.11.10

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 (261) hide show
  1. package/dist/admin-domain.d.ts +10 -1
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +13 -3
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +6 -1
  6. package/dist/adopt-existing.d.ts.map +1 -1
  7. package/dist/adopt-existing.js +86 -0
  8. package/dist/adopt-existing.js.map +1 -1
  9. package/dist/agent/command.d.ts +20 -0
  10. package/dist/agent/command.d.ts.map +1 -1
  11. package/dist/agent/command.js +32 -9
  12. package/dist/agent/command.js.map +1 -1
  13. package/dist/agent/run.d.ts +2 -0
  14. package/dist/agent/run.d.ts.map +1 -1
  15. package/dist/agent/run.js +5 -1
  16. package/dist/agent/run.js.map +1 -1
  17. package/dist/agent-db.js +44 -2
  18. package/dist/aws-task-roles.d.ts +4 -2
  19. package/dist/aws-task-roles.d.ts.map +1 -1
  20. package/dist/aws-task-roles.js +4 -1
  21. package/dist/aws-task-roles.js.map +1 -1
  22. package/dist/caddy.d.ts +21 -4
  23. package/dist/caddy.d.ts.map +1 -1
  24. package/dist/caddy.js +27 -3
  25. package/dist/caddy.js.map +1 -1
  26. package/dist/cli.js +12 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloudsql-tls.d.ts +63 -0
  29. package/dist/cloudsql-tls.d.ts.map +1 -0
  30. package/dist/cloudsql-tls.js +289 -0
  31. package/dist/cloudsql-tls.js.map +1 -0
  32. package/dist/compose.d.ts +29 -6
  33. package/dist/compose.d.ts.map +1 -1
  34. package/dist/compose.js +95 -23
  35. package/dist/compose.js.map +1 -1
  36. package/dist/converge-checkpoint.d.ts +1 -0
  37. package/dist/converge-checkpoint.d.ts.map +1 -1
  38. package/dist/converge-checkpoint.js +8 -1
  39. package/dist/converge-checkpoint.js.map +1 -1
  40. package/dist/converge-job-gcp.d.ts.map +1 -1
  41. package/dist/converge-job-gcp.js +2 -11
  42. package/dist/converge-job-gcp.js.map +1 -1
  43. package/dist/deployed-release.d.ts +4 -1
  44. package/dist/deployed-release.d.ts.map +1 -1
  45. package/dist/deployed-release.js +18 -8
  46. package/dist/deployed-release.js.map +1 -1
  47. package/dist/gcp-names.d.ts +21 -0
  48. package/dist/gcp-names.d.ts.map +1 -1
  49. package/dist/gcp-names.js +24 -0
  50. package/dist/gcp-names.js.map +1 -1
  51. package/dist/gcp-url-map.d.ts.map +1 -1
  52. package/dist/gcp-url-map.js +6 -2
  53. package/dist/gcp-url-map.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/infra-contract.d.ts +1 -1
  59. package/dist/infra-contract.d.ts.map +1 -1
  60. package/dist/infra-contract.js +24 -1
  61. package/dist/infra-contract.js.map +1 -1
  62. package/dist/install-state.d.ts +6 -0
  63. package/dist/install-state.d.ts.map +1 -1
  64. package/dist/install-state.js +17 -4
  65. package/dist/install-state.js.map +1 -1
  66. package/dist/lifecycle.d.ts.map +1 -1
  67. package/dist/lifecycle.js +17 -8
  68. package/dist/lifecycle.js.map +1 -1
  69. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  70. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  71. package/dist/mcp-auth-proxy-setup.js +280 -0
  72. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  73. package/dist/release-manifest.d.ts +25 -1
  74. package/dist/release-manifest.d.ts.map +1 -1
  75. package/dist/release-manifest.js +36 -3
  76. package/dist/release-manifest.js.map +1 -1
  77. package/dist/remote-mcp.d.ts +55 -18
  78. package/dist/remote-mcp.d.ts.map +1 -1
  79. package/dist/remote-mcp.js +307 -77
  80. package/dist/remote-mcp.js.map +1 -1
  81. package/dist/runtime-secrets.d.ts +9 -0
  82. package/dist/runtime-secrets.d.ts.map +1 -1
  83. package/dist/runtime-secrets.js +21 -10
  84. package/dist/runtime-secrets.js.map +1 -1
  85. package/dist/seal-key-rotation.d.ts +7 -7
  86. package/dist/seal-key-rotation.d.ts.map +1 -1
  87. package/dist/seal-key-rotation.js +22 -22
  88. package/dist/seal-key-rotation.js.map +1 -1
  89. package/dist/self-hosted-db-tls.d.ts +37 -0
  90. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  91. package/dist/self-hosted-db-tls.js +220 -0
  92. package/dist/self-hosted-db-tls.js.map +1 -0
  93. package/dist/self-hosted-release.d.ts +3 -1
  94. package/dist/self-hosted-release.d.ts.map +1 -1
  95. package/dist/self-hosted-release.js +33 -9
  96. package/dist/self-hosted-release.js.map +1 -1
  97. package/dist/self-hosted-roles.d.ts +34 -0
  98. package/dist/self-hosted-roles.d.ts.map +1 -1
  99. package/dist/self-hosted-roles.js +52 -0
  100. package/dist/self-hosted-roles.js.map +1 -1
  101. package/dist/self-hosted-start.d.ts +0 -21
  102. package/dist/self-hosted-start.d.ts.map +1 -1
  103. package/dist/self-hosted-start.js +39 -1
  104. package/dist/self-hosted-start.js.map +1 -1
  105. package/dist/self-hosted-upgrade.d.ts +15 -6
  106. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  107. package/dist/self-hosted-upgrade.js +72 -23
  108. package/dist/self-hosted-upgrade.js.map +1 -1
  109. package/dist/stack-contract.d.ts +170 -25
  110. package/dist/stack-contract.d.ts.map +1 -1
  111. package/dist/stack-contract.js +314 -37
  112. package/dist/stack-contract.js.map +1 -1
  113. package/dist/stack-converge.d.ts +31 -0
  114. package/dist/stack-converge.d.ts.map +1 -1
  115. package/dist/stack-converge.js +71 -30
  116. package/dist/stack-converge.js.map +1 -1
  117. package/dist/upgrade/app-roll.d.ts +25 -4
  118. package/dist/upgrade/app-roll.d.ts.map +1 -1
  119. package/dist/upgrade/app-roll.js +63 -17
  120. package/dist/upgrade/app-roll.js.map +1 -1
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +99 -12
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/health.d.ts +31 -7
  125. package/dist/upgrade/health.d.ts.map +1 -1
  126. package/dist/upgrade/health.js +41 -17
  127. package/dist/upgrade/health.js.map +1 -1
  128. package/dist/upgrade/image-forms.d.ts +1 -1
  129. package/dist/upgrade/image-forms.d.ts.map +1 -1
  130. package/dist/upgrade/image-forms.js +24 -7
  131. package/dist/upgrade/image-forms.js.map +1 -1
  132. package/dist/upgrade/release-images.d.ts +12 -3
  133. package/dist/upgrade/release-images.d.ts.map +1 -1
  134. package/dist/upgrade/release-images.js +13 -4
  135. package/dist/upgrade/release-images.js.map +1 -1
  136. package/dist/upgrade/upgrade.d.ts +13 -3
  137. package/dist/upgrade/upgrade.d.ts.map +1 -1
  138. package/dist/upgrade/upgrade.js +34 -2
  139. package/dist/upgrade/upgrade.js.map +1 -1
  140. package/dist/upgrader-gcp.d.ts +12 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -1
  142. package/dist/upgrader-gcp.js +49 -1
  143. package/dist/upgrader-gcp.js.map +1 -1
  144. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  145. package/dist/upgrader-iam-preflight.js +6 -1
  146. package/dist/upgrader-iam-preflight.js.map +1 -1
  147. package/dist/upgrader-job.d.ts +1 -1
  148. package/dist/upgrader-job.d.ts.map +1 -1
  149. package/dist/upgrader-job.js +20 -14
  150. package/dist/upgrader-job.js.map +1 -1
  151. package/dist/upgrader-records.d.ts +1 -1
  152. package/dist/upgrader-records.d.ts.map +1 -1
  153. package/dist/upgrader-records.js +26 -7
  154. package/dist/upgrader-records.js.map +1 -1
  155. package/dist/upgrader-setup.d.ts.map +1 -1
  156. package/dist/upgrader-setup.js +10 -5
  157. package/dist/upgrader-setup.js.map +1 -1
  158. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  159. package/dist/wizards/gcp-cost.js +22 -7
  160. package/dist/wizards/gcp-cost.js.map +1 -1
  161. package/dist/wizards/gcp.d.ts.map +1 -1
  162. package/dist/wizards/gcp.js +43 -17
  163. package/dist/wizards/gcp.js.map +1 -1
  164. package/package.json +2 -2
  165. package/src/admin-domain.test.ts +9 -8
  166. package/src/admin-domain.ts +13 -3
  167. package/src/admin-memory.test.ts +1 -0
  168. package/src/adopt-existing.test.ts +39 -0
  169. package/src/adopt-existing.ts +83 -1
  170. package/src/agent/command.test.ts +49 -1
  171. package/src/agent/command.ts +36 -9
  172. package/src/agent/run.test.ts +19 -0
  173. package/src/agent/run.ts +11 -2
  174. package/src/aws-task-roles.ts +5 -2
  175. package/src/caddy-redirects.docker.test.ts +2 -0
  176. package/src/caddy-redirects.test.ts +3 -0
  177. package/src/caddy-validate.test.ts +7 -1
  178. package/src/caddy.test.ts +39 -1
  179. package/src/caddy.ts +36 -4
  180. package/src/checkpoint-secrets.test.ts +14 -6
  181. package/src/cli.ts +12 -1
  182. package/src/cloudsql-tls.test.ts +225 -0
  183. package/src/cloudsql-tls.ts +352 -0
  184. package/src/compose.ts +125 -24
  185. package/src/converge-checkpoint.ts +8 -1
  186. package/src/converge-job-gcp.ts +2 -11
  187. package/src/database-tls-builders.test.ts +55 -0
  188. package/src/deployed-release.test.ts +104 -40
  189. package/src/deployed-release.ts +30 -9
  190. package/src/gateway-env-isolation.test.ts +10 -5
  191. package/src/gcp-names.ts +31 -0
  192. package/src/gcp-url-map.test.ts +6 -8
  193. package/src/gcp-url-map.ts +6 -2
  194. package/src/index.test.ts +7 -1
  195. package/src/index.ts +1 -0
  196. package/src/infra-contract.test.ts +5 -3
  197. package/src/infra-contract.ts +24 -1
  198. package/src/install-state.ts +26 -4
  199. package/src/lifecycle.ts +20 -11
  200. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  201. package/src/mcp-auth-proxy-setup.ts +374 -0
  202. package/src/mcp-iap-stacks.test.ts +1 -1
  203. package/src/media-volume.test.ts +1 -1
  204. package/src/operator-access-stacks.test.ts +1 -1
  205. package/src/operator-access.test.ts +5 -4
  206. package/src/pool-budget-stacks.test.ts +1 -1
  207. package/src/release-manifest.test.ts +25 -0
  208. package/src/release-manifest.ts +59 -4
  209. package/src/remote-mcp-stack-config.test.ts +19 -9
  210. package/src/remote-mcp.test.ts +252 -57
  211. package/src/remote-mcp.ts +336 -77
  212. package/src/runtime-secrets.test.ts +20 -7
  213. package/src/runtime-secrets.ts +50 -9
  214. package/src/seal-key-rotation.test.ts +2 -2
  215. package/src/seal-key-rotation.ts +25 -25
  216. package/src/self-hosted-compose.test.ts +108 -5
  217. package/src/self-hosted-db-tls.test.ts +111 -0
  218. package/src/self-hosted-db-tls.ts +287 -0
  219. package/src/self-hosted-release-resolve.test.ts +71 -0
  220. package/src/self-hosted-release.ts +41 -11
  221. package/src/self-hosted-roles.test.ts +54 -0
  222. package/src/self-hosted-roles.ts +58 -0
  223. package/src/self-hosted-start.test.ts +34 -2
  224. package/src/self-hosted-start.ts +20 -2
  225. package/src/self-hosted-upgrade.test.ts +103 -14
  226. package/src/self-hosted-upgrade.ts +115 -29
  227. package/src/stack-contract-parity.test.ts +79 -12
  228. package/src/stack-contract.test.ts +169 -29
  229. package/src/stack-contract.ts +362 -42
  230. package/src/stack-converge-secrets.test.ts +26 -13
  231. package/src/stack-converge.test.ts +199 -26
  232. package/src/stack-converge.ts +92 -30
  233. package/src/state-store.test.ts +2 -1
  234. package/src/upgrade/app-roll.ts +97 -19
  235. package/src/upgrade/converge.ts +120 -11
  236. package/src/upgrade/health.test.ts +63 -25
  237. package/src/upgrade/health.ts +54 -22
  238. package/src/upgrade/image-forms.test.ts +34 -0
  239. package/src/upgrade/image-forms.ts +23 -8
  240. package/src/upgrade/release-images.test.ts +5 -1
  241. package/src/upgrade/release-images.ts +22 -6
  242. package/src/upgrade/upgrade.test.ts +353 -44
  243. package/src/upgrade/upgrade.ts +55 -6
  244. package/src/upgrader-gcp.ts +65 -3
  245. package/src/upgrader-iam-preflight.test.ts +19 -0
  246. package/src/upgrader-iam-preflight.ts +6 -0
  247. package/src/upgrader-job.test.ts +101 -27
  248. package/src/upgrader-job.ts +27 -14
  249. package/src/upgrader-records.test.ts +97 -12
  250. package/src/upgrader-records.ts +28 -11
  251. package/src/upgrader-setup.test.ts +2 -0
  252. package/src/upgrader-setup.ts +10 -4
  253. package/src/wizards/gcp-cost.test.ts +50 -0
  254. package/src/wizards/gcp-cost.ts +24 -7
  255. package/src/wizards/gcp.ts +55 -14
  256. package/stacks/aws/index.ts +32 -14
  257. package/stacks/azure/index.ts +57 -18
  258. package/stacks/gcp/index.ts +83 -23
  259. package/stacks/gcp-firebase/index.ts +162 -33
  260. package/stacks/self-hosted/README.md +2 -1
  261. package/stacks/self-hosted/index.ts +11 -1
@@ -23,12 +23,13 @@
23
23
  * Storage volume + mount), added in the same `services update` when
24
24
  * missing.
25
25
  * - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
26
- * gateway's secret file (the remote-MCP OAuth client) and its request
26
+ * MCP-Auth-Proxy.s secret file (the remote-MCP OAuth client) and its request
27
27
  * timeout, in the same `services update`.
28
28
  * - {@link planCpuIdle}: request-based CPU where the contract wants it
29
29
  * (`SERVICE_CPU_IDLE`), in the same `services update`.
30
30
  */
31
31
 
32
+ import { isIpLiteral } from "@caelo-cms/shared";
32
33
  import { removeDefaultFirewallRules } from "./default-firewall.js";
33
34
  import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
34
35
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
@@ -38,8 +39,10 @@ import {
38
39
  ADMIN_MEMORY_DEFAULT,
39
40
  type AdminEnvInputs,
40
41
  adminEnvContract,
42
+ CLOUD_RUN_SLUGS,
41
43
  type CloudRunEnvVar,
42
44
  type CloudRunSlug,
45
+ DATABASE_URL_VARS,
43
46
  databaseUrls,
44
47
  type GcpProvider,
45
48
  gatewayEnvContract,
@@ -47,6 +50,7 @@ import {
47
50
  type IamTarget,
48
51
  iamMember,
49
52
  type MediaVolumeContract,
53
+ mcpAuthProxyEnvContract,
50
54
  memoryQuantityMiB,
51
55
  RETIRED_IAM_BINDINGS,
52
56
  RETIRED_RUNTIME_SECRETS,
@@ -167,7 +171,7 @@ function maskUrlPassword(value: string): string {
167
171
  * gcloud's list flags split on `,`; a value containing one needs the
168
172
  * `^<delim>^` escape syntax (`gcloud topic escaping`).
169
173
  */
170
- function listFlag(flag: string, pairs: string[]): string {
174
+ export function listFlag(flag: string, pairs: string[]): string {
171
175
  const joined = pairs.join(",");
172
176
  if (!pairs.some((p) => p.includes(","))) return `${flag}=${joined}`;
173
177
  const delim = ["@", "|", "#", "~", ";"].find((d) => !pairs.some((p) => p.includes(d)));
@@ -306,21 +310,42 @@ export function planContractEnv(
306
310
  * refused rather than silently doubled.
307
311
  */
308
312
  readonly extraEnv?: Readonly<Partial<Record<CloudRunSlug, readonly CloudRunEnvVar[]>>>;
313
+ /**
314
+ * #1338 — the database host the converge just set up (the instance's
315
+ * DNS name, cloudsql-tls.ts), in place of the one the services dialled
316
+ * when the plan was read.
317
+ */
318
+ readonly databaseHost?: string;
309
319
  } = {},
310
320
  ):
311
321
  | {
312
322
  readonly ok: true;
313
323
  readonly services: Record<CloudRunSlug, { flags: string[]; changes: EnvChange[] }>;
324
+ /**
325
+ * #1338 — the services still dial the instance's IP, which no
326
+ * verify-full connection can use; their database URLs were left as
327
+ * they are. Only a converge moves them (cloudsql-tls.ts); a roll must
328
+ * not start while this is true.
329
+ */
330
+ readonly databaseHostPending: boolean;
314
331
  }
315
332
  | { readonly ok: false; readonly error: string } {
316
- const host = liveDatabaseHost(services.admin.liveEnv);
317
- if (!host.ok) {
333
+ const live = liveDatabaseHost(services.admin.liveEnv);
334
+ if (!opts.databaseHost && !live.ok) {
318
335
  return {
319
336
  ok: false,
320
- error: `the Cloud SQL host is unknown: the admin service (${services.admin.serviceName}) ${host.error}`,
337
+ error: `the Cloud SQL host is unknown: the admin service (${services.admin.serviceName}) ${live.error}`,
321
338
  };
322
339
  }
323
- const runtime: RuntimeEnvInputs = { ...install, databaseUrls: databaseUrls(host.host) };
340
+ const host = opts.databaseHost ?? (live.ok ? live.host : "");
341
+ const databaseHostPending = isIpLiteral(host);
342
+ // An IP cannot carry a verify-full URL (databaseUrls refuses it): the URLs
343
+ // are planned from a placeholder name and then left untouched, so the
344
+ // rest of the env still plans and the caller can see what is pending.
345
+ const runtime: RuntimeEnvInputs = {
346
+ ...install,
347
+ databaseUrls: databaseUrls(databaseHostPending ? "pending.invalid" : host),
348
+ };
324
349
  let inputs: AdminEnvInputs;
325
350
  if (install.provider === "gcp-firebase") {
326
351
  // The site id carries a random suffix Pulumi generated at install time.
@@ -340,36 +365,33 @@ export function planContractEnv(
340
365
  } else {
341
366
  inputs = { ...runtime, provider: "gcp" };
342
367
  }
343
- const skip = new Set(opts.leaveUntouched ?? []);
368
+ const skip = new Set([
369
+ ...(opts.leaveUntouched ?? []),
370
+ ...(databaseHostPending ? [...DATABASE_URL_VARS.admin, ...DATABASE_URL_VARS.gateway] : []),
371
+ ]);
344
372
  const desired = (slug: CloudRunSlug, contract: readonly CloudRunEnvVar[]) => {
345
373
  const extra = opts.extraEnv?.[slug] ?? [];
346
374
  const clash = extra.find((e) => contract.some((c) => c.name === e.name));
347
375
  if (clash) throw new Error(`${slug}: ${clash.name} is both in the contract and extra env`);
348
376
  return [...contract, ...extra].filter((e) => !skip.has(e.name));
349
377
  };
350
- let adminDesired: CloudRunEnvVar[];
351
- let gatewayDesired: CloudRunEnvVar[];
378
+ let contracts: Record<CloudRunSlug, CloudRunEnvVar[]>;
352
379
  try {
353
- adminDesired = desired("admin", adminEnvContract(inputs));
354
- gatewayDesired = desired("gateway", gatewayEnvContract(runtime));
380
+ contracts = {
381
+ admin: desired("admin", adminEnvContract(inputs)),
382
+ gateway: desired("gateway", gatewayEnvContract(runtime)),
383
+ "mcp-auth-proxy": desired("mcp-auth-proxy", mcpAuthProxyEnvContract(install)),
384
+ };
355
385
  } catch (e) {
356
386
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
357
387
  }
358
- const admin = planEnvUpdate(services.admin.liveEnv, adminDesired, RETIRED_SERVICE_ENV.admin);
359
- if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
360
- const gateway = planEnvUpdate(
361
- services.gateway.liveEnv,
362
- gatewayDesired,
363
- RETIRED_SERVICE_ENV.gateway,
364
- );
365
- if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
366
- return {
367
- ok: true,
368
- services: {
369
- admin: { flags: admin.flags, changes: admin.changes },
370
- gateway: { flags: gateway.flags, changes: gateway.changes },
371
- },
372
- };
388
+ const out = {} as Record<CloudRunSlug, { flags: string[]; changes: EnvChange[] }>;
389
+ for (const slug of CLOUD_RUN_SLUGS) {
390
+ const plan = planEnvUpdate(services[slug].liveEnv, contracts[slug], RETIRED_SERVICE_ENV[slug]);
391
+ if (!plan.ok) return { ok: false, error: `${slug}: ${plan.error}` };
392
+ out[slug] = { flags: plan.flags, changes: plan.changes };
393
+ }
394
+ return { ok: true, services: out, databaseHostPending };
373
395
  }
374
396
 
375
397
  // ===========================================================================
@@ -618,16 +640,24 @@ export function liveIngress(serviceJson: string): string | null {
618
640
  * the serving one and the app-roll's rollback target stay as they are. On
619
641
  * `gcp` the stack declares load-balancer-only and `staging-domain enable`
620
642
  * (admin-domain.ts) opens it deliberately, so this never runs there.
643
+ *
644
+ * #1328 — both public services of a gcp-firebase install: the data gateway
645
+ * (the Firebase rewrite, the health probe) and the MCP-Auth-Proxy (remote-MCP
646
+ * clients, and until #1331 the staging mapping it forwards).
621
647
  */
622
- async function ensureGatewayIngress(
648
+ async function ensurePublicIngress(
623
649
  install: InstallTarget,
650
+ slug: "gateway" | "mcp-auth-proxy",
624
651
  run: GcloudRunner,
625
652
  ): Promise<InvariantOutcome> {
626
- const service = install.services.gateway;
653
+ const service = install.services[slug];
627
654
  const base = {
628
655
  id: `ingress all on ${service}`,
629
656
  onFailure: "abort" as const,
630
- why: "the gcp-firebase stack's gateway ingress (#650); the health check, the staging domain and remote MCP reach its run.app URL",
657
+ why:
658
+ slug === "gateway"
659
+ ? "the gcp-firebase stack's gateway ingress (#650); Firebase Hosting and the health check reach its run.app URL"
660
+ : "the gcp-firebase stack's MCP-Auth-Proxy ingress (#1328); remote-MCP clients, the health check and the staging domain reach its run.app URL",
631
661
  };
632
662
  const where = [`--region=${install.region}`, `--project=${install.projectId}`];
633
663
  const describe = await run(["run", "services", "describe", service, ...where, "--format=json"]);
@@ -697,7 +727,10 @@ export async function ensureStackInvariants(
697
727
  outcomes.push(await ensureCdnPolicy(install, run));
698
728
  outcomes.push(await ensureSvgDownloads(install, run));
699
729
  }
700
- if (install.provider === "gcp-firebase") outcomes.push(await ensureGatewayIngress(install, run));
730
+ if (install.provider === "gcp-firebase") {
731
+ outcomes.push(await ensurePublicIngress(install, "gateway", run));
732
+ outcomes.push(await ensurePublicIngress(install, "mcp-auth-proxy", run));
733
+ }
701
734
  outcomes.push(...(await removeDefaultFirewallRules(install.projectId, { run })));
702
735
 
703
736
  return {
@@ -1183,6 +1216,35 @@ export function planCpuIdle(live: boolean, desired: boolean): RollAdditionsPlan
1183
1216
  };
1184
1217
  }
1185
1218
 
1219
+ /**
1220
+ * Whether the service's revision template egresses into a VPC (Direct VPC
1221
+ * egress, the stacks' `vpcAccess.networkInterfaces`).
1222
+ */
1223
+ export function liveDirectVpc(serviceJson: string): boolean {
1224
+ const svc = JSON.parse(serviceJson) as {
1225
+ spec?: { template?: { metadata?: { annotations?: Record<string, string> } } };
1226
+ };
1227
+ return Boolean(
1228
+ svc.spec?.template?.metadata?.annotations?.["run.googleapis.com/network-interfaces"],
1229
+ );
1230
+ }
1231
+
1232
+ /**
1233
+ * #1328 — `--clear-network` when a service the contract keeps off the VPC
1234
+ * still egresses into it. The MCP-Auth-Proxy on gcp-firebase is the service
1235
+ * that was the gateway, which reached Cloud SQL's private IP that way; the
1236
+ * proxy talks to the admin's public URL only, so it loses the route to the
1237
+ * database's network together with its database credentials.
1238
+ */
1239
+ export function planNoVpc(live: boolean): RollAdditionsPlan {
1240
+ if (!live) return { ok: true, flags: [], changes: [] };
1241
+ return {
1242
+ ok: true,
1243
+ flags: ["--clear-network"],
1244
+ changes: ["VPC egress (private ranges) → none"],
1245
+ };
1246
+ }
1247
+
1186
1248
  /**
1187
1249
  * The single `gcloud run services update` that rolls a service to a new
1188
1250
  * image, its run SA and its env changes, so all land in one new revision.
@@ -36,7 +36,8 @@ function fakeBucket() {
36
36
  return { objects, calls, run };
37
37
  }
38
38
 
39
- const PASSWORD_URL = "postgres://admin_role:hunter2hunter2@10.0.0.3:5432/cms_admin";
39
+ const PASSWORD_URL =
40
+ "postgres://admin_role:hunter2hunter2@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin";
40
41
 
41
42
  let root = "";
42
43
  beforeEach(() => {
@@ -25,15 +25,23 @@
25
25
  */
26
26
 
27
27
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
28
- import { gatewayServiceAccountEmail, runServiceAccountEmail } from "../gcp-names.js";
28
+ import {
29
+ gatewayServiceAccountEmail,
30
+ mcpAuthProxyServiceAccountEmail,
31
+ runServiceAccountEmail,
32
+ } from "../gcp-names.js";
29
33
  import type { ImageDigests } from "../install-state.js";
30
34
  import {
31
35
  adminMediaVolume,
36
+ CLOUD_RUN_SLUGS,
32
37
  type CloudRunEnvVar,
33
- GATEWAY_TIMEOUT_SECONDS,
38
+ type CloudRunSlug,
39
+ cloudRunServicePrefix,
40
+ MCP_AUTH_PROXY_TIMEOUT_SECONDS,
34
41
  RETIRED_SECRET_FILES,
35
42
  SERVICE_CPU_IDLE,
36
43
  SERVICE_SECRET_FILES,
44
+ SERVICE_VPC_EGRESS,
37
45
  } from "../stack-contract.js";
38
46
  import {
39
47
  type DeployedService,
@@ -44,12 +52,14 @@ import {
44
52
  liveContainerEnv,
45
53
  liveContainerMemory,
46
54
  liveCpuIdle,
55
+ liveDirectVpc,
47
56
  liveTimeoutSeconds,
48
57
  liveVolumes,
49
58
  planAdminMemory,
50
59
  planContractEnv,
51
60
  planCpuIdle,
52
61
  planMediaVolume,
62
+ planNoVpc,
53
63
  planRequestTimeout,
54
64
  planSecretFiles,
55
65
  rollService,
@@ -69,10 +79,27 @@ import {
69
79
  import type { HealthProbe } from "./health.js";
70
80
  import type { UpgradeReporter } from "./reporter.js";
71
81
 
72
- export type RollSlug = "admin" | "gateway";
82
+ export type RollSlug = CloudRunSlug;
73
83
 
74
- /** The roll order: the gateway follows the admin, never the other way round. */
75
- export const ROLL_ORDER: readonly RollSlug[] = ["admin", "gateway"];
84
+ /**
85
+ * The roll order: the gateway follows the admin, never the other way round,
86
+ * and the MCP-Auth-Proxy comes last (#1328) — on gcp-firebase it forwards
87
+ * the visitor traffic still addressed to it to the gateway, which must
88
+ * already serve the new release by then.
89
+ */
90
+ export const ROLL_ORDER: readonly RollSlug[] = CLOUD_RUN_SLUGS;
91
+
92
+ /** The service account each service's new revision runs as. */
93
+ export function rollServiceAccount(slug: RollSlug, projectId: string, env: string): string {
94
+ switch (slug) {
95
+ case "admin":
96
+ return runServiceAccountEmail(projectId, env);
97
+ case "gateway":
98
+ return gatewayServiceAccountEmail(projectId, env);
99
+ case "mcp-auth-proxy":
100
+ return mcpAuthProxyServiceAccountEmail(projectId, env);
101
+ }
102
+ }
76
103
 
77
104
  /** The install an app-roll acts on. */
78
105
  export interface AppRollInstall {
@@ -110,6 +137,8 @@ export interface ServicePlan {
110
137
  readonly liveTimeout: number | null;
111
138
  /** Whether the service gets CPU only during requests now. */
112
139
  readonly liveCpuIdle: boolean;
140
+ /** Whether the service egresses into the install's VPC now. */
141
+ readonly liveDirectVpc: boolean;
113
142
  }
114
143
 
115
144
  /** A service plan plus the changes its roll applies. */
@@ -139,6 +168,13 @@ export interface AppRollPlan {
139
168
  readonly services: readonly RollPlan[];
140
169
  /** Notes for the operator (e.g. a memory limit left as is). */
141
170
  readonly warnings: readonly string[];
171
+ /**
172
+ * #1338 — the services dial the Cloud SQL instance's IP, which no
173
+ * verify-full connection can use, so their database URLs are not planned.
174
+ * A converge moves them onto the instance's DNS name
175
+ * ({@link EnvAdjustments.databaseHost}); a roll refuses while this holds.
176
+ */
177
+ readonly databaseHostPending?: boolean;
142
178
  }
143
179
 
144
180
  export type PlanResult =
@@ -166,7 +202,7 @@ export async function planAppRoll(
166
202
  ): Promise<PlanResult> {
167
203
  const services: ServicePlan[] = [];
168
204
  for (const slug of ROLL_ORDER) {
169
- const prefix = `caelo-${install.env}-${slug}`;
205
+ const prefix = cloudRunServicePrefix(install.provider, install.env, slug);
170
206
  const lookup = await resolveGcpResourceName(
171
207
  run,
172
208
  "run-service",
@@ -210,6 +246,7 @@ export async function planAppRoll(
210
246
  liveVolumes: liveVolumes(json),
211
247
  liveTimeout: liveTimeoutSeconds(json),
212
248
  liveCpuIdle: liveCpuIdle(json),
249
+ liveDirectVpc: liveDirectVpc(json),
213
250
  });
214
251
  }
215
252
  const env = planEnv(install, services, {});
@@ -265,29 +302,45 @@ export async function planAppRoll(
265
302
  // stack's memory default (never lowered; an unparsable value is kept).
266
303
  const memory = s.slug === "admin" ? planAdminMemory(s.liveMemory) : null;
267
304
  if (memory && !memory.ok) warnings.push(`admin memory left as is: ${memory.error}`);
268
- // #806 — the gateway streams remote-MCP chat turns (stack-contract.ts
269
- // GATEWAY_TIMEOUT_SECONDS). The admin's timeout is the stacks' alone.
305
+ // #806 — the MCP-Auth-Proxy streams remote-MCP chat turns
306
+ // (stack-contract.ts MCP_AUTH_PROXY_TIMEOUT_SECONDS). The other services'
307
+ // timeouts are the stacks' alone.
270
308
  const timeout =
271
- s.slug === "gateway" ? planRequestTimeout(s.liveTimeout, GATEWAY_TIMEOUT_SECONDS) : null;
309
+ s.slug === "mcp-auth-proxy"
310
+ ? planRequestTimeout(s.liveTimeout, MCP_AUTH_PROXY_TIMEOUT_SECONDS)
311
+ : null;
272
312
  const memoryFlags = memory?.ok ? memory.flags : [];
273
313
  const timeoutFlags = timeout?.ok ? timeout.flags : [];
274
314
  const cpu = planCpuIdle(s.liveCpuIdle, SERVICE_CPU_IDLE[s.slug]);
275
315
  const cpuFlags = cpu.ok ? cpu.flags : [];
316
+ // #1328 — the gcp-firebase proxy is the former gateway service: it
317
+ // leaves the VPC in the same revision that drops its database env.
318
+ const vpc = SERVICE_VPC_EGRESS[s.slug] ? null : planNoVpc(s.liveDirectVpc);
319
+ const vpcFlags = vpc?.ok ? vpc.flags : [];
276
320
  return {
277
321
  ...s,
278
322
  envFlags: env.services[s.slug].flags,
279
323
  envChanges: env.services[s.slug].changes,
280
- resourceFlags: [...memoryFlags, ...timeoutFlags, ...cpuFlags],
324
+ resourceFlags: [...memoryFlags, ...timeoutFlags, ...cpuFlags, ...vpcFlags],
281
325
  resourceChanges: [
282
326
  ...memoryFlags.map((f) => `${s.liveMemory ?? "(default)"} → ${f}`),
283
327
  ...(timeout?.ok ? timeout.changes : []),
284
328
  ...(cpu.ok ? cpu.changes : []),
329
+ ...(vpc?.ok ? vpc.changes : []),
285
330
  ],
286
331
  volumeFlags: [...(s.slug === "admin" ? media.flags : []), ...secretFiles[s.slug].flags],
287
332
  volumeChanges: [...(s.slug === "admin" ? media.changes : []), ...secretFiles[s.slug].changes],
288
333
  };
289
334
  });
290
- return { ok: true, plan: { install, services: rolls, warnings } };
335
+ if (env.databaseHostPending) {
336
+ warnings.push(
337
+ "the services still dial the Cloud SQL instance's IP; the converge moves them onto its DNS name for verify-full TLS (#1338) before anything rolls",
338
+ );
339
+ }
340
+ return {
341
+ ok: true,
342
+ plan: { install, services: rolls, warnings, databaseHostPending: env.databaseHostPending },
343
+ };
291
344
  }
292
345
 
293
346
  /** Env the converge half adds to, or keeps out of, the rolls ({@link AppRollHooks.afterMigrations}). */
@@ -299,6 +352,11 @@ export interface EnvAdjustments {
299
352
  * identity env, which only the converge (operator identity) can resolve.
300
353
  */
301
354
  readonly extraEnv?: Readonly<Partial<Record<RollSlug, readonly CloudRunEnvVar[]>>>;
355
+ /**
356
+ * #1338 — the database host the converge moved the services onto (the
357
+ * Cloud SQL instance's DNS name, cloudsql-tls.ts).
358
+ */
359
+ readonly databaseHost?: string;
302
360
  }
303
361
 
304
362
  function planEnv(
@@ -322,6 +380,7 @@ function planEnv(
322
380
  {
323
381
  leaveUntouched: adjust.leaveUntouched ?? [],
324
382
  ...(adjust.extraEnv ? { extraEnv: adjust.extraEnv } : {}),
383
+ ...(adjust.databaseHost ? { databaseHost: adjust.databaseHost } : {}),
325
384
  },
326
385
  );
327
386
  }
@@ -333,11 +392,12 @@ function planEnv(
333
392
  */
334
393
  export function adjustingEnv(plan: AppRollPlan, adjust: EnvAdjustments): AppRollPlan {
335
394
  const extra = Object.values(adjust.extraEnv ?? {}).some((v) => (v?.length ?? 0) > 0);
336
- if ((adjust.leaveUntouched?.length ?? 0) === 0 && !extra) return plan;
395
+ if ((adjust.leaveUntouched?.length ?? 0) === 0 && !extra && !adjust.databaseHost) return plan;
337
396
  const env = planEnv(plan.install, plan.services, adjust);
338
397
  if (!env.ok) return plan;
339
398
  return {
340
399
  ...plan,
400
+ databaseHostPending: env.databaseHostPending,
341
401
  services: plan.services.map((s) => ({
342
402
  ...s,
343
403
  envFlags: env.services[s.slug].flags,
@@ -370,7 +430,7 @@ export interface AppRollHooks {
370
430
  afterMigrations?(): Promise<
371
431
  | ({ readonly ok: true; readonly leaveUntouched: readonly string[] } & Pick<
372
432
  EnvAdjustments,
373
- "extraEnv"
433
+ "extraEnv" | "databaseHost"
374
434
  >)
375
435
  | { readonly ok: false }
376
436
  >;
@@ -470,6 +530,17 @@ export async function executeAppRoll(
470
530
  r.warn("--skip-health-check set — the new revisions get Cloud Run's readiness check only.");
471
531
  }
472
532
 
533
+ // #1338 — the new images refuse a database URL without verify-full, and
534
+ // only a converge can move the services off the instance's IP. Without
535
+ // one (the in-cloud upgrader) the roll stops before it changes anything.
536
+ if (plan.databaseHostPending && !hooks.afterMigrations) {
537
+ r.error(
538
+ "The services still dial the Cloud SQL instance's IP address. This release connects with verify-full TLS, which needs the instance's DNS name: run the converge first (Settings → Updates installs it as a cloud-setup release, or `cms-provision upgrade` from a terminal).",
539
+ );
540
+ r.warn("No migrations ran and no traffic moved.");
541
+ return fail("migrations", false);
542
+ }
543
+
473
544
  // Migrations BEFORE any traffic moves, with the NEW admin image: the old
474
545
  // image does not carry the release's migration files (v0.9.2).
475
546
  const admin = plan.services.find((s) => s.slug === "admin") as RollPlan;
@@ -490,8 +561,15 @@ export async function executeAppRoll(
490
561
  plan = adjustingEnv(plan, {
491
562
  leaveUntouched: converged.leaveUntouched,
492
563
  ...(converged.extraEnv ? { extraEnv: converged.extraEnv } : {}),
564
+ ...(converged.databaseHost ? { databaseHost: converged.databaseHost } : {}),
493
565
  });
494
566
  }
567
+ if (plan.databaseHostPending) {
568
+ r.error(
569
+ "The converge did not move the services onto the Cloud SQL instance's DNS name, so the new revisions could not reach the database with verify-full TLS (#1338). Nothing rolled; fix the database TLS step above and re-run.",
570
+ );
571
+ return fail("converge", true);
572
+ }
495
573
 
496
574
  const rolled: RollPlan[] = [];
497
575
  /** Jobs this roll moved onto the admin's image (#875, #1217), with their prior image. */
@@ -540,10 +618,7 @@ export async function executeAppRoll(
540
618
  region: install.region,
541
619
  projectId: install.projectId,
542
620
  imageRef: p.imageRef,
543
- serviceAccount:
544
- p.slug === "admin"
545
- ? runServiceAccountEmail(install.projectId, install.env)
546
- : gatewayServiceAccountEmail(install.projectId, install.env),
621
+ serviceAccount: rollServiceAccount(p.slug, install.projectId, install.env),
547
622
  envFlags: p.envFlags,
548
623
  resourceFlags: p.resourceFlags,
549
624
  volumeFlags: p.volumeFlags,
@@ -637,9 +712,12 @@ export async function executeAppRoll(
637
712
  s.done(`Healthy: ${deep.detail}`);
638
713
  }
639
714
 
715
+ const digestOf = (slug: RollSlug) =>
716
+ (plan.services.find((s) => s.slug === slug) as RollPlan).digest;
640
717
  const digests: ImageDigests = {
641
- admin: (plan.services.find((s) => s.slug === "admin") as RollPlan).digest,
642
- gateway: (plan.services.find((s) => s.slug === "gateway") as RollPlan).digest,
718
+ admin: digestOf("admin"),
719
+ gateway: digestOf("gateway"),
720
+ "mcp-auth-proxy": digestOf("mcp-auth-proxy"),
643
721
  };
644
722
  await deps.recordDigests(digests);
645
723
  return { ok: true, digests };