@caelo-cms/provisioning 0.11.8 → 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 +65 -3
  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
@@ -16,6 +16,7 @@ import { describe, expect, it } from "bun:test";
16
16
  import { databaseUrlFromEnv } from "@caelo-cms/shared";
17
17
  import type { GcloudResult } from "./gcloud.js";
18
18
  import {
19
+ CLOUD_RUN_SLUGS,
19
20
  type CloudRunSlug,
20
21
  OPERATOR_OAUTH_CLIENT_FILE,
21
22
  RETIRED_SECRET_FILES,
@@ -32,7 +33,7 @@ import {
32
33
  } from "./stack-converge.js";
33
34
 
34
35
  const PW = "4f2a9c0d1e2b3a4f5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b";
35
- const HOST = "10.20.0.3";
36
+ const HOST = "db1.p-label.europe-west1.sql-psa.goog";
36
37
 
37
38
  /** Secret Manager as the fake revisions resolve it. */
38
39
  const SECRET_VALUES: Record<string, string> = {
@@ -141,7 +142,7 @@ function expectGatewayOnItsOwnLogins(env: Record<string, string>): void {
141
142
  expect(Object.values(env).join("\n")).not.toContain("g".repeat(64));
142
143
  expect(Object.values(env).join("\n")).not.toContain("cms_admin");
143
144
  expect(credentials(databaseUrlFromEnv(["PUBLIC_DATABASE_URL"], env))).toBe(
144
- `public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=require`,
145
+ `public_role:${"p".repeat(64)}@${HOST}:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem`,
145
146
  );
146
147
  }
147
148
 
@@ -157,7 +158,7 @@ const serviceJson = (env: unknown[]) =>
157
158
  /** What `gcloud run services describe` showed on a live v0.10.29 gcp install. */
158
159
  function v01029Install(scheme: "postgresql" | "postgres") {
159
160
  const url = (role: string, db: string) =>
160
- `${scheme}://${role}:${PW}@${HOST}:5432/${db}?sslmode=require`;
161
+ `${scheme}://${role}:${PW}@${HOST}:5432/${db}?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem`;
161
162
  const common = {
162
163
  CAELO_PROVIDER: "gcp",
163
164
  CAELO_ENV: "production",
@@ -200,20 +201,30 @@ const INSTALL = {
200
201
  stagingHost: "staging.acme.com",
201
202
  region: "europe-west1",
202
203
  };
204
+ /** The fixtures' services; the proxy is added by {@link upgrade} when absent. */
205
+ type InstallServices = Record<"admin" | "gateway", FakeService> & {
206
+ "mcp-auth-proxy"?: FakeService;
207
+ };
208
+
203
209
  const SERVICE_ACCOUNTS: Record<CloudRunSlug, string> = {
204
210
  admin: "caelo-production-run-sa@acme.iam.gserviceaccount.com",
205
211
  gateway: "caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
212
+ "mcp-auth-proxy": "caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com",
206
213
  };
207
214
 
208
215
  /** Plan + apply one upgrade roll on the fake services, as upgradeCommand does. */
209
- function upgrade(services: Record<CloudRunSlug, FakeService>) {
216
+ function upgrade(services: InstallServices) {
217
+ // #1328 — the gcp MCP-Auth-Proxy: created by the converge before the plan,
218
+ // with no env yet in these fixtures (they are about database logins).
219
+ services["mcp-auth-proxy"] ??= new FakeService(new Map(), SERVICE_ACCOUNTS["mcp-auth-proxy"]);
210
220
  const plan = planContractEnv(INSTALL, {
211
221
  admin: { serviceName: "adm", liveEnv: services.admin.env },
212
222
  gateway: { serviceName: "gw", liveEnv: services.gateway.env },
223
+ "mcp-auth-proxy": { serviceName: "proxy", liveEnv: services["mcp-auth-proxy"].env },
213
224
  });
214
225
  if (!plan.ok) throw new Error(plan.error);
215
- for (const slug of ["admin", "gateway"] as const) {
216
- services[slug].update(
226
+ for (const slug of CLOUD_RUN_SLUGS) {
227
+ (services[slug] as FakeService).update(
217
228
  serviceRollArgs({
218
229
  serviceName: slug,
219
230
  region: INSTALL.region,
@@ -297,7 +308,7 @@ describe.each(["postgresql", "postgres"] as const)(
297
308
  */
298
309
  function v01035Install() {
299
310
  const url = (role: string, db: string) =>
300
- `postgresql://${role}@${HOST}:5432/${db}?sslmode=require`;
311
+ `postgresql://${role}@${HOST}:5432/${db}?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem`;
301
312
  const ref = (name: string, secret: string) => ({
302
313
  name,
303
314
  valueFrom: { secretKeyRef: { name: `caelo-production-${secret}`, key: "latest" } },
@@ -349,10 +360,7 @@ describe("upgrading a v0.10.35 install: the gateway leaves admin_role (#613)", (
349
360
  expect(plan.ok && plan.services.gateway.changes.map((c) => c.name).sort()).toEqual([
350
361
  "ADMIN_DATABASE_PASSWORD",
351
362
  "ADMIN_DATABASE_URL",
352
- // #806 — the remote-MCP broker's seal key and (#914) the Google
353
- // sign-in client file path.
354
- "CAELO_MCP_OAUTH_SEAL_KEY",
355
- "CAELO_OPERATOR_OAUTH_CLIENT_FILE",
363
+ // No remote-MCP env: the broker is the MCP-Auth-Proxy's (#1328).
356
364
  // #650 — the staging gate key arrives in the same roll.
357
365
  "CAELO_STAGING_GATE_SECRET",
358
366
  // #845 — and which forwarded address is the visitor.
@@ -396,7 +404,7 @@ function v01050Install() {
396
404
  const env = new Map(services.gateway.env);
397
405
  env.set("GATEWAY_DATABASE_URL", {
398
406
  kind: "value",
399
- value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=require`,
407
+ value: `postgresql://gateway_role@${HOST}:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem`,
400
408
  });
401
409
  env.set("GATEWAY_DATABASE_PASSWORD", {
402
410
  kind: "secret",
@@ -488,7 +496,12 @@ describe("retiring a secret file removes its volume too (#954)", () => {
488
496
  },
489
497
  };
490
498
  const plan = (live: Parameters<typeof planSecretFiles>[0]) =>
491
- planSecretFiles(live, [OPERATOR_OAUTH_CLIENT_FILE], "production", RETIRED_SECRET_FILES.gateway);
499
+ planSecretFiles(
500
+ live,
501
+ [OPERATOR_OAUTH_CLIENT_FILE],
502
+ "production",
503
+ RETIRED_SECRET_FILES["mcp-auth-proxy"],
504
+ );
492
505
 
493
506
  it("a retired mount: removes the file, the mount and the volume behind it in the roll", () => {
494
507
  const r = plan({
@@ -13,9 +13,11 @@ import {
13
13
  type LiveEnvValue,
14
14
  liveContainerEnv,
15
15
  liveDatabaseHost,
16
+ liveDirectVpc,
16
17
  liveServiceAccount,
17
18
  planContractEnv,
18
19
  planEnvUpdate,
20
+ planNoVpc,
19
21
  policyGrants,
20
22
  removeRetiredIamBindings,
21
23
  rollService,
@@ -56,6 +58,7 @@ const policy = (bindings: { role: string; members: string[]; condition?: unknown
56
58
 
57
59
  const RUN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
58
60
  const GW_SA = "serviceAccount:caelo-production-gateway-sa@acme.iam.gserviceaccount.com";
61
+ const PROXY_SA = "serviceAccount:caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com";
59
62
  const GW_TELEMETRY = [
60
63
  { role: "roles/logging.logWriter", members: [GW_SA] },
61
64
  { role: "roles/monitoring.metricWriter", members: [GW_SA] },
@@ -67,7 +70,11 @@ const firebase: InstallTarget = {
67
70
  projectId: "acme",
68
71
  region: "europe-west1",
69
72
  env: "production",
70
- services: { admin: "caelo-production-admin-aaa", gateway: "caelo-production-gateway-bbb" },
73
+ services: {
74
+ admin: "caelo-production-admin-aaa",
75
+ gateway: "caelo-production-data-gateway",
76
+ "mcp-auth-proxy": "caelo-production-gateway-bbb",
77
+ },
71
78
  };
72
79
  const gcp: InstallTarget = { ...firebase, provider: "gcp" };
73
80
 
@@ -126,13 +133,13 @@ describe("liveContainerEnv / liveServiceAccount / liveDatabaseHost", () => {
126
133
 
127
134
  it("finds the Cloud SQL host in ADMIN_DATABASE_URL with or without an inline password", () => {
128
135
  for (const url of [
129
- "postgresql://admin_role:pw@10.20.0.3:5432/cms_admin?sslmode=require",
130
- "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
136
+ "postgresql://admin_role:pw@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
137
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
131
138
  ]) {
132
139
  const host = liveDatabaseHost(
133
140
  new Map([["ADMIN_DATABASE_URL", { kind: "value", value: url }]]),
134
141
  );
135
- expect(host).toEqual({ ok: true, host: "10.20.0.3" });
142
+ expect(host).toEqual({ ok: true, host: "db1.p-label.europe-west1.sql-psa.goog" });
136
143
  }
137
144
  expect(liveDatabaseHost(new Map()).ok).toBe(false);
138
145
  });
@@ -261,11 +268,13 @@ const secretRef = (name: string, secret: string) => ({
261
268
  const CONVERGED_ADMIN_DB = [
262
269
  {
263
270
  name: "ADMIN_DATABASE_URL",
264
- value: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
271
+ value:
272
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
265
273
  },
266
274
  {
267
275
  name: "PUBLIC_ADMIN_DATABASE_URL",
268
- value: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
276
+ value:
277
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
269
278
  },
270
279
  secretRef("ADMIN_DATABASE_PASSWORD", "postgres-password"),
271
280
  secretRef("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
@@ -276,7 +285,8 @@ const CONVERGED_ADMIN_DB = [
276
285
  const CONVERGED_GATEWAY_DB = [
277
286
  {
278
287
  name: "PUBLIC_DATABASE_URL",
279
- value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
288
+ value:
289
+ "postgresql://public_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem",
280
290
  },
281
291
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
282
292
  ];
@@ -315,21 +325,33 @@ describe("planContractEnv", () => {
315
325
  },
316
326
  {
317
327
  admin: { serviceName: "caelo-production-admin-aaa", liveEnv: adminLive },
318
- gateway: { serviceName: "caelo-production-gateway-bbb", liveEnv: gatewayLive },
328
+ // #1328 — the data gateway, a copy of the old gateway service.
329
+ gateway: { serviceName: "caelo-production-data-gateway", liveEnv: gatewayLive },
330
+ // The old gateway service, which becomes the MCP-Auth-Proxy (E13 a).
331
+ "mcp-auth-proxy": { serviceName: "caelo-production-gateway-bbb", liveEnv: gatewayLive },
319
332
  },
320
333
  );
321
334
  if (!plan.ok) throw new Error(plan.error);
322
335
  // #650 — the same roll converges the protected staging domain's host
323
336
  // and gate key onto an install that predates it.
324
337
  expect(plan.services.admin.flags).toEqual([
325
- "--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_STAGING_HOST=staging.acme.com",
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",
326
339
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
327
340
  ]);
341
+ // #1328 — the Firebase rewrite follows the data gateway (above), which
342
+ // gets no remote-MCP env.
328
343
  expect(plan.services.gateway.flags).toEqual([
329
344
  // The gateway learns which forwarded address is the visitor (#845).
330
- // #806 — and where the remote-MCP broker's client file and seal key are.
331
- "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json",
332
- "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest,CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest",
345
+ "--update-env-vars=CAELO_TRUSTED_PROXY_HOPS=1",
346
+ "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
347
+ ]);
348
+ // The old gateway service becomes the proxy in one revision: every
349
+ // database and site var goes, its own contract arrives.
350
+ expect(plan.services["mcp-auth-proxy"].flags).toEqual([
351
+ "--remove-env-vars=PUBLIC_DATABASE_URL,CAELO_ENV,MEDIA_STORAGE_URL",
352
+ "--update-env-vars=CAELO_REMOTE_MCP=available,CAELO_TRUSTED_PROXY_HOPS=1,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json",
353
+ "--remove-secrets=PUBLIC_DATABASE_PASSWORD",
354
+ "--update-secrets=CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest",
333
355
  ]);
334
356
  });
335
357
 
@@ -352,9 +374,10 @@ describe("planContractEnv", () => {
352
374
  {
353
375
  admin: { serviceName: "caelo-production-admin-aaa", liveEnv: live },
354
376
  gateway: {
355
- serviceName: "caelo-production-gateway-bbb",
377
+ serviceName: "caelo-production-data-gateway",
356
378
  liveEnv: liveContainerEnv(serviceJson(CONVERGED_GATEWAY_DB)),
357
379
  },
380
+ "mcp-auth-proxy": { serviceName: "caelo-production-gateway-bbb", liveEnv: new Map() },
358
381
  },
359
382
  { leaveUntouched: ["CAELO_MCP_IAP_SERVICE_ACCOUNT", "CAELO_OPERATOR_ACCESS_JOB"] },
360
383
  );
@@ -375,18 +398,72 @@ describe("planContractEnv", () => {
375
398
  {
376
399
  admin: { serviceName: "adm", liveEnv: liveContainerEnv(serviceJson(CONVERGED_ADMIN_DB)) },
377
400
  gateway: { serviceName: "gw", liveEnv: new Map() },
401
+ "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
378
402
  },
379
403
  );
380
404
  expect(plan.ok).toBe(false);
381
405
  expect(!plan.ok && plan.error).toContain("CAELO_FIREBASE_SITE");
382
406
  });
383
407
 
408
+ it("#1338: an install on the instance's IP plans its database URLs only with the converge's host", () => {
409
+ const onIp = (db: { name: string; value?: string }[]) =>
410
+ db.map((e) =>
411
+ e.value
412
+ ? {
413
+ ...e,
414
+ value: e.value
415
+ .replace("db1.p-label.europe-west1.sql-psa.goog", "10.20.0.3")
416
+ .replace(/\?.*$/, "?sslmode=require"),
417
+ }
418
+ : e,
419
+ );
420
+ const services = {
421
+ admin: {
422
+ serviceName: "adm",
423
+ liveEnv: liveContainerEnv(serviceJson(onIp(CONVERGED_ADMIN_DB) as never)),
424
+ },
425
+ gateway: {
426
+ serviceName: "gw",
427
+ liveEnv: liveContainerEnv(serviceJson(onIp(CONVERGED_GATEWAY_DB) as never)),
428
+ },
429
+ // #1328 — no database URL, so the TLS step has nothing to move on it.
430
+ "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
431
+ };
432
+ const install = {
433
+ provider: "gcp" as const,
434
+ projectId: "a",
435
+ env: "production",
436
+ domain: "a.com",
437
+ stagingHost: "staging.a.com",
438
+ region: "r",
439
+ };
440
+ // Read-only plan: the URLs stay as they are and the plan says why.
441
+ const pending = planContractEnv(install, services);
442
+ if (!pending.ok) throw new Error(pending.error);
443
+ expect(pending.databaseHostPending).toBe(true);
444
+ for (const s of ["admin", "gateway"] as const) {
445
+ expect(pending.services[s].changes.map((c) => c.name)).not.toContainEqual(
446
+ expect.stringMatching(/DATABASE_URL$/),
447
+ );
448
+ }
449
+ // After the converge's TLS step: verify-full URLs on the DNS name.
450
+ const host = "db1.p-label.europe-west1.sql-psa.goog";
451
+ const moved = planContractEnv(install, services, { databaseHost: host });
452
+ if (!moved.ok) throw new Error(moved.error);
453
+ expect(moved.databaseHostPending).toBe(false);
454
+ const url = moved.services.gateway.changes.find((c) => c.name === "PUBLIC_DATABASE_URL");
455
+ expect(url?.to).toBe(
456
+ `postgresql://public_role@${host}:5432/cms_public?sslmode=verify-full&sslrootcert=/etc/caelo/db-ca/cloudsql-global.pem`,
457
+ );
458
+ });
459
+
384
460
  it("fails loudly when the Cloud SQL host can't be discovered", () => {
385
461
  const plan = planContractEnv(
386
462
  { provider: "gcp", projectId: "a", env: "production", domain: "a.com", region: "r" },
387
463
  {
388
464
  admin: { serviceName: "adm", liveEnv: new Map() },
389
465
  gateway: { serviceName: "gw", liveEnv: new Map() },
466
+ "mcp-auth-proxy": { serviceName: "px", liveEnv: new Map() },
390
467
  },
391
468
  );
392
469
  expect(plan.ok).toBe(false);
@@ -486,7 +563,7 @@ describe("ensureStackInvariants", () => {
486
563
  ],
487
564
  "secrets get-iam-policy": [
488
565
  policy([
489
- { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
566
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA, PROXY_SA] },
490
567
  // #806 — the admin adds remote-MCP OAuth client versions.
491
568
  { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
492
569
  ]),
@@ -494,17 +571,23 @@ describe("ensureStackInvariants", () => {
494
571
  "storage buckets get-iam-policy": [
495
572
  policy([{ role: "roles/storage.objectAdmin", members: [RUN_SA] }]),
496
573
  ],
497
- "run services get-iam-policy caelo-production-gateway-bbb": [
574
+ // #1328 — the data gateway: the Firebase rewrite target.
575
+ "run services get-iam-policy caelo-production-data-gateway": [
498
576
  policy([
499
577
  { role: "roles/run.invoker", members: ["allUsers"] },
500
578
  { role: "roles/run.viewer", members: [RUN_SA] },
501
579
  ]),
502
580
  ],
581
+ // The MCP-Auth-Proxy: public for MCP clients.
582
+ "run services get-iam-policy caelo-production-gateway-bbb": [
583
+ policy([{ role: "roles/run.invoker", members: ["allUsers"] }]),
584
+ ],
503
585
  "run services get-iam-policy caelo-production-admin-aaa": [
504
586
  policy([{ role: "roles/run.invoker", members: [IAP_AGENT] }]),
505
587
  ],
506
588
  "compute firewall-rules list": [ok("[]")],
507
589
  "run services describe caelo-production-gateway-bbb": [ok(ingressJson("all"))],
590
+ "run services describe caelo-production-data-gateway": [ok(ingressJson("all"))],
508
591
  });
509
592
 
510
593
  it("is a read-only no-op on an install that is already in shape", async () => {
@@ -513,7 +596,8 @@ describe("ensureStackInvariants", () => {
513
596
  expect(report.mustAbort).toBe(false);
514
597
  expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
515
598
  expect(report.outcomes).toHaveLength(
516
- stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length + 1,
599
+ // The two public services' ingress (#732, #1328).
600
+ stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length + 2,
517
601
  );
518
602
  expect(calls.some((c) => c.startsWith("compute firewall-rules delete"))).toBe(false);
519
603
  expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
@@ -628,7 +712,7 @@ describe("ensureStackInvariants", () => {
628
712
  answers["projects get-iam-policy"] = [
629
713
  policy([{ role: "roles/firebasehosting.admin", members: [RUN_SA] }, ...GW_TELEMETRY]),
630
714
  ];
631
- answers["run services get-iam-policy caelo-production-gateway-bbb"] = [
715
+ answers["run services get-iam-policy caelo-production-data-gateway"] = [
632
716
  policy([{ role: "roles/run.invoker", members: ["allUsers"] }]),
633
717
  ];
634
718
  const { run, calls } = fakeGcloud(answers);
@@ -636,7 +720,7 @@ describe("ensureStackInvariants", () => {
636
720
 
637
721
  expect(report.mustAbort).toBe(false);
638
722
  expect(calls).toContain(
639
- `run services add-iam-policy-binding caelo-production-gateway-bbb --region=europe-west1 --project=acme --member=${RUN_SA} --role=roles/run.viewer --quiet --format=none`,
723
+ `run services add-iam-policy-binding caelo-production-data-gateway --region=europe-west1 --project=acme --member=${RUN_SA} --role=roles/run.viewer --quiet --format=none`,
640
724
  );
641
725
  for (const role of ["roles/logging.logWriter", "roles/monitoring.metricWriter"]) {
642
726
  expect(calls).toContain(
@@ -650,7 +734,7 @@ describe("ensureStackInvariants", () => {
650
734
 
651
735
  it("aborts when a binding the install needs can't be added", async () => {
652
736
  const answers = fullFirebase();
653
- answers["run services get-iam-policy caelo-production-gateway-bbb"] = [
737
+ answers["run services get-iam-policy caelo-production-data-gateway"] = [
654
738
  policy([{ role: "roles/run.invoker", members: ["allUsers"] }]),
655
739
  ];
656
740
  answers["run services add-iam-policy-binding"] = [
@@ -739,7 +823,7 @@ describe("ensureStackInvariants", () => {
739
823
  ],
740
824
  "secrets get-iam-policy": [
741
825
  policy([
742
- { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA] },
826
+ { role: "roles/secretmanager.secretAccessor", members: [RUN_SA, GW_SA, PROXY_SA] },
743
827
  { role: "roles/secretmanager.secretVersionAdder", members: [RUN_SA] },
744
828
  ]),
745
829
  ],
@@ -871,10 +955,19 @@ describe("ensureStackInvariants", () => {
871
955
  expect(gatewayGrants).toEqual([
872
956
  `secrets add-iam-policy-binding caelo-production-public-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
873
957
  `secrets add-iam-policy-binding caelo-production-staging-gate-secret --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
874
- // #806 — the remote-MCP broker's seal key and OAuth client file.
875
- `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
876
- `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
877
958
  ]);
959
+ // #1328 — the remote-MCP broker's seal key and OAuth client file are
960
+ // the MCP-Auth-Proxy's, and those two secrets are all its SA reads.
961
+ expect(
962
+ calls.filter((c) => c.startsWith("secrets add-iam-policy-binding") && c.includes(PROXY_SA)),
963
+ ).toEqual([
964
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=${PROXY_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
965
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=${PROXY_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
966
+ ]);
967
+ // …and it holds no project role.
968
+ expect(
969
+ calls.some((c) => c.startsWith("projects add-iam-policy-binding") && c.includes(PROXY_SA)),
970
+ ).toBe(false);
878
971
  // #806 — the admin may add versions to the OAuth client secret, and to no other.
879
972
  const adders = calls.filter((c) => c.includes("roles/secretmanager.secretVersionAdder"));
880
973
  expect(adders).toEqual([
@@ -900,7 +993,13 @@ describe("removeRetiredIamBindings (#613)", () => {
900
993
  });
901
994
  const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
902
995
  // postgres-password removed; gateway-role-password (#635) already clean.
903
- expect(outcomes.map((o) => o.status)).toEqual(["removed", "absent"]);
996
+ expect(outcomes.map((o) => o.status)).toEqual([
997
+ "removed",
998
+ "absent",
999
+ "absent",
1000
+ "absent",
1001
+ "absent",
1002
+ ]);
904
1003
  expect(calls).toContain(REMOVE);
905
1004
  // The admin's own binding on the same secret is never touched.
906
1005
  expect(calls.some((c) => c.includes("remove") && c.includes(RUN_SA))).toBe(false);
@@ -913,7 +1012,13 @@ describe("removeRetiredIamBindings (#613)", () => {
913
1012
  ],
914
1013
  });
915
1014
  const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
916
- expect(outcomes.map((o) => o.status)).toEqual(["absent", "absent"]);
1015
+ expect(outcomes.map((o) => o.status)).toEqual([
1016
+ "absent",
1017
+ "absent",
1018
+ "absent",
1019
+ "absent",
1020
+ "absent",
1021
+ ]);
917
1022
  expect(calls.filter((c) => c.includes("remove-iam-policy-binding"))).toEqual([]);
918
1023
  });
919
1024
 
@@ -924,12 +1029,51 @@ describe("removeRetiredIamBindings (#613)", () => {
924
1029
  ],
925
1030
  });
926
1031
  const outcomes = await removeRetiredIamBindings(gcp, { run, sleep: async () => {} });
927
- expect(outcomes.map((o) => o.status)).toEqual(["absent", "removed"]);
1032
+ expect(outcomes.map((o) => o.status)).toEqual([
1033
+ "absent",
1034
+ "removed",
1035
+ "absent",
1036
+ "absent",
1037
+ "absent",
1038
+ ]);
928
1039
  expect(calls).toContain(
929
1040
  `secrets remove-iam-policy-binding caelo-production-gateway-role-password --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
930
1041
  );
931
1042
  });
932
1043
 
1044
+ it("#1328: takes the gateway SA off the broker's secrets and the admin's viewer off the proxy", async () => {
1045
+ const { run, calls } = fakeGcloud({
1046
+ "secrets get-iam-policy caelo-production-mcp-oauth-seal-key": [
1047
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA, PROXY_SA] }]),
1048
+ ],
1049
+ "secrets get-iam-policy caelo-production-operator-oauth-client": [
1050
+ policy([{ role: "roles/secretmanager.secretAccessor", members: [GW_SA, PROXY_SA] }]),
1051
+ ],
1052
+ "run services get-iam-policy caelo-production-gateway-bbb": [
1053
+ policy([
1054
+ { role: "roles/run.invoker", members: ["allUsers"] },
1055
+ { role: "roles/run.viewer", members: [RUN_SA] },
1056
+ ]),
1057
+ ],
1058
+ });
1059
+ const outcomes = await removeRetiredIamBindings(firebase, { run, sleep: async () => {} });
1060
+ expect(outcomes.map((o) => o.status)).toEqual([
1061
+ "absent",
1062
+ "absent",
1063
+ "removed",
1064
+ "removed",
1065
+ "removed",
1066
+ ]);
1067
+ for (const secret of ["mcp-oauth-seal-key", "operator-oauth-client"]) {
1068
+ expect(calls).toContain(
1069
+ `secrets remove-iam-policy-binding caelo-production-${secret} --project=acme --member=${GW_SA} --role=roles/secretmanager.secretAccessor --quiet --format=none`,
1070
+ );
1071
+ }
1072
+ // The proxy keeps its own access and its public invoker.
1073
+ expect(calls.some((c) => c.includes("remove") && c.includes(PROXY_SA))).toBe(false);
1074
+ expect(calls.some((c) => c.includes("remove") && c.includes("allUsers"))).toBe(false);
1075
+ });
1076
+
933
1077
  it("reports a failed removal instead of throwing", async () => {
934
1078
  const { run } = fakeGcloud({
935
1079
  "secrets get-iam-policy caelo-production-postgres-password": [
@@ -942,3 +1086,32 @@ describe("removeRetiredIamBindings (#613)", () => {
942
1086
  expect(o?.error).toContain("PERMISSION_DENIED");
943
1087
  });
944
1088
  });
1089
+
1090
+ describe("liveDirectVpc / planNoVpc (#1328)", () => {
1091
+ const withNetwork = JSON.stringify({
1092
+ spec: {
1093
+ template: {
1094
+ metadata: {
1095
+ annotations: {
1096
+ "run.googleapis.com/network-interfaces": '[{"network":"caelo-production-vpc"}]',
1097
+ "run.googleapis.com/vpc-access-egress": "private-ranges-only",
1098
+ },
1099
+ },
1100
+ },
1101
+ },
1102
+ });
1103
+
1104
+ it("reads Direct VPC egress from the revision template", () => {
1105
+ expect(liveDirectVpc(withNetwork)).toBe(true);
1106
+ expect(liveDirectVpc(JSON.stringify({ spec: { template: { metadata: {} } } }))).toBe(false);
1107
+ });
1108
+
1109
+ it("takes a service off the VPC in its roll, and leaves one that is off alone", () => {
1110
+ expect(planNoVpc(true)).toEqual({
1111
+ ok: true,
1112
+ flags: ["--clear-network"],
1113
+ changes: ["VPC egress (private ranges) → none"],
1114
+ });
1115
+ expect(planNoVpc(false)).toEqual({ ok: true, flags: [], changes: [] });
1116
+ });
1117
+ });