@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
@@ -17,11 +17,15 @@ import { IMAGE_DIGEST } from "@caelo-cms/shared";
17
17
  import { confirm, isCancel, log, spinner } from "@clack/prompts";
18
18
  import { green, red } from "kleur/colors";
19
19
 
20
- /** The two Caelo services shipped as release images. */
21
- export type ReleaseService = "admin" | "gateway";
20
+ /** The Caelo services shipped as release images (#1328 added the MCP-Auth-Proxy). */
21
+ export type ReleaseService = "admin" | "gateway" | "mcp-auth-proxy";
22
22
 
23
- /** The services every install runs, in roll order (admin first, then gateway). */
24
- export const RELEASE_SERVICES: readonly ReleaseService[] = ["admin", "gateway"];
23
+ /**
24
+ * The services every install runs, in roll order: the admin first, then the
25
+ * gateway, then the MCP-Auth-Proxy (its own service since #1328, so a hole in
26
+ * a visitor handler never shares a process with the operators' tokens).
27
+ */
28
+ export const RELEASE_SERVICES: readonly ReleaseService[] = ["admin", "gateway", "mcp-auth-proxy"];
25
29
 
26
30
  /** GHCR — the canonical registry (release-images.yml), used by self-hosted Compose. */
27
31
  export const GHCR_REGISTRY = "ghcr.io";
@@ -116,7 +120,13 @@ export function releaseTag(opts: {
116
120
  return "latest";
117
121
  }
118
122
 
119
- export type DigestResult = { ok: true; digest: string } | { ok: false; reason: string };
123
+ /**
124
+ * `notFound`: the registry answered that the tag does not exist (HTTP 404)
125
+ * — a verdict, unlike an outage, a refused token or a malformed answer.
126
+ */
127
+ export type DigestResult =
128
+ | { ok: true; digest: string }
129
+ | { ok: false; reason: string; notFound?: true };
120
130
 
121
131
  /** Manifest media types: single-arch manifests and multi-arch indexes, Docker + OCI. */
122
132
  const MANIFEST_ACCEPT = [
@@ -155,7 +165,13 @@ export async function resolveRegistryDigest(
155
165
  headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token.token}` },
156
166
  });
157
167
  }
158
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
168
+ if (!res.ok) {
169
+ return {
170
+ ok: false,
171
+ reason: `HTTP ${res.status} ${res.statusText}`,
172
+ ...(res.status === 404 ? { notFound: true as const } : {}),
173
+ };
174
+ }
159
175
  const header = res.headers.get("docker-content-digest");
160
176
  const digest = header ? canonicalDigest(header) : null;
161
177
  if (!digest) {
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, it } from "bun:test";
11
11
  import type { GcloudResult } from "../gcloud.js";
12
12
  import type { GcloudRunner } from "../gcloud-retry.js";
13
+ import { MCP_ARMOR_RULE } from "../remote-mcp-edge.js";
13
14
  import type { HttpFetch } from "../runtime-secrets.js";
14
15
  import {
15
16
  type AppRollInstall,
@@ -34,13 +35,26 @@ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitC
34
35
  const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
35
36
 
36
37
  const ADMIN = "caelo-production-admin-aaa";
37
- const GATEWAY = "caelo-production-gateway-bbb";
38
+ /** gcp-firebase: the visitor gateway, under its fixed name since #1328. */
39
+ const GATEWAY = "caelo-production-data-gateway";
40
+ /** gcp-firebase: the service that was the gateway and is the MCP-Auth-Proxy (E13 a). */
41
+ const PROXY = "caelo-production-gateway-bbb";
42
+ /** gcp: the proxy's fixed name (the gateway keeps its auto-named service). */
43
+ const GCP_PROXY = "caelo-production-mcp-auth-proxy";
38
44
  const SQL = "caelo-production-pg-abc";
39
45
  const BACKUP_ID = "1760000000000";
40
- const DIGEST = { admin: `sha256:${"a".repeat(64)}`, gateway: `sha256:${"b".repeat(64)}` };
46
+ const DIGEST = {
47
+ admin: `sha256:${"a".repeat(64)}`,
48
+ gateway: `sha256:${"b".repeat(64)}`,
49
+ "mcp-auth-proxy": `sha256:${"d".repeat(64)}`,
50
+ };
41
51
  const IMAGES: Record<RollSlug, ReleaseImage> = {
42
52
  admin: { digest: DIGEST.admin, imageRef: `registry/admin@${DIGEST.admin}` },
43
53
  gateway: { digest: DIGEST.gateway, imageRef: `registry/gateway@${DIGEST.gateway}` },
54
+ "mcp-auth-proxy": {
55
+ digest: DIGEST["mcp-auth-proxy"],
56
+ imageRef: `registry/mcp-auth-proxy@${DIGEST["mcp-auth-proxy"]}`,
57
+ },
44
58
  };
45
59
 
46
60
  const secretRef = (name: string, secret: string) => ({
@@ -53,7 +67,7 @@ const secretRef = (name: string, secret: string) => ({
53
67
  * returns it. `ingress`: the service-level setting (#732). Always-on CPU,
54
68
  * as every install the stacks created before `cpuIdle` was set runs.
55
69
  */
56
- const serviceJson = (image: string, env: unknown[], ingress = "all") =>
70
+ const serviceJson = (image: string, env: unknown[], ingress = "all", vpc = true) =>
57
71
  JSON.stringify({
58
72
  metadata: { annotations: { "run.googleapis.com/ingress": ingress } },
59
73
  spec: {
@@ -61,9 +75,13 @@ const serviceJson = (image: string, env: unknown[], ingress = "all") =>
61
75
  metadata: {
62
76
  annotations: {
63
77
  "run.googleapis.com/cpu-throttling": "false",
64
- "run.googleapis.com/network-interfaces": JSON.stringify([
65
- { network: "caelo-production-vpc", subnetwork: "caelo-production-subnet" },
66
- ]),
78
+ ...(vpc
79
+ ? {
80
+ "run.googleapis.com/network-interfaces": JSON.stringify([
81
+ { network: "caelo-production-vpc", subnetwork: "caelo-production-subnet" },
82
+ ]),
83
+ }
84
+ : {}),
67
85
  },
68
86
  },
69
87
  spec: { containers: [{ image, env }] },
@@ -77,11 +95,13 @@ const ADMIN_JSON = serviceJson(OLD_ADMIN_IMAGE, [
77
95
  { name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
78
96
  {
79
97
  name: "ADMIN_DATABASE_URL",
80
- value: "postgresql://admin_role@10.20.0.3:5432/cms_admin?sslmode=require",
98
+ value:
99
+ "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",
81
100
  },
82
101
  {
83
102
  name: "PUBLIC_ADMIN_DATABASE_URL",
84
- value: "postgresql://admin_role@10.20.0.3:5432/cms_public?sslmode=require",
103
+ value:
104
+ "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",
85
105
  },
86
106
  secretRef("ADMIN_DATABASE_PASSWORD", "postgres-password"),
87
107
  secretRef("PUBLIC_ADMIN_DATABASE_PASSWORD", "postgres-password"),
@@ -89,10 +109,30 @@ const ADMIN_JSON = serviceJson(OLD_ADMIN_IMAGE, [
89
109
  const GATEWAY_JSON = serviceJson("registry/gateway@sha256:old", [
90
110
  {
91
111
  name: "PUBLIC_DATABASE_URL",
92
- value: "postgresql://public_role@10.20.0.3:5432/cms_public?sslmode=require",
112
+ value:
113
+ "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",
114
+ },
115
+ secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
116
+ ]);
117
+
118
+ /**
119
+ * gcp-firebase: the service that was the gateway, before this upgrade turns
120
+ * it into the MCP-Auth-Proxy — the visitor gateway's database login, the
121
+ * staging gate key and the remote-MCP broker's env, on the VPC.
122
+ */
123
+ const PROXY_JSON = serviceJson("registry/gateway@sha256:old", [
124
+ {
125
+ name: "PUBLIC_DATABASE_URL",
126
+ value:
127
+ "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",
93
128
  },
94
129
  secretRef("PUBLIC_DATABASE_PASSWORD", "public-role-password"),
130
+ secretRef("CAELO_STAGING_GATE_SECRET", "staging-gate-secret"),
131
+ secretRef("CAELO_MCP_OAUTH_SEAL_KEY", "mcp-oauth-seal-key"),
132
+ { name: "CAELO_OPERATOR_OAUTH_CLIENT_FILE", value: "/secrets/operator-oauth-client/client.json" },
95
133
  ]);
134
+ /** gcp: the MCP-Auth-Proxy the converge deployed (no VPC, no database). */
135
+ const GCP_PROXY_JSON = serviceJson("registry/mcp-auth-proxy@sha256:old", [], "all", false);
96
136
 
97
137
  type Answer = GcloudResult | ((line: string) => GcloudResult);
98
138
 
@@ -106,10 +146,15 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
106
146
  const rules: [RegExp, Answer][] = [
107
147
  ...overrides,
108
148
  [/^run services list .*caelo-production-admin/, ok(`${ADMIN}\n`)],
109
- [/^run services list .*caelo-production-gateway/, ok(`${GATEWAY}\n`)],
149
+ [/^run services list .*caelo-production-data-gateway/, ok(`${GATEWAY}\n`)],
150
+ // On gcp this prefix is the gateway's, on gcp-firebase the proxy's.
151
+ [/^run services list .*caelo-production-gateway/, ok(`${PROXY}\n`)],
152
+ [/^run services list .*caelo-production-mcp-auth-proxy/, ok(`${GCP_PROXY}\n`)],
110
153
  [/describe (\S+) .*revisionName/, (l) => ok(`${l.split(" ")[3]}-00001-old\n`)],
111
154
  [new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
112
155
  [new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
156
+ [new RegExp(`describe ${PROXY} .*--format=json`), ok(PROXY_JSON)],
157
+ [new RegExp(`describe ${GCP_PROXY} .*--format=json`), ok(GCP_PROXY_JSON)],
113
158
  [/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
114
159
  [
115
160
  /^run jobs describe caelo-production-(audit|capture) .*containers\[0\]\.image/,
@@ -247,6 +292,14 @@ const APP_ROLL_WRITE =
247
292
  const ANY_WRITE =
248
293
  /\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
249
294
 
295
+ /**
296
+ * #1328 — the only writes allowed before the backup: the MCP-Auth-Proxy's
297
+ * account, its secretAccessor on its two secrets, and the services an older
298
+ * install lacks (mcp-auth-proxy-setup.ts). Additive; nothing serves on them.
299
+ */
300
+ const PRE_PLAN_WRITE =
301
+ /^(iam service-accounts create caelo-production-mcp-proxy-sa |secrets add-iam-policy-binding caelo-production-(mcp-oauth-seal-key|operator-oauth-client) .*--member=serviceAccount:caelo-production-mcp-proxy-sa@|run services replace |run deploy caelo-production-mcp-auth-proxy )/;
302
+
250
303
  const indexOf = (events: string[], pattern: RegExp): number => {
251
304
  const i = events.findIndex((e) => pattern.test(e));
252
305
  if (i < 0) throw new Error(`no event matches ${pattern}:\n${events.join("\n")}`);
@@ -260,25 +313,31 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
260
313
  expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
261
314
 
262
315
  const backup = indexOf(events, /^gcloud sql backups create/);
263
- const firstConvergeWrite = indexOf(events, /^gcloud .*(add-iam-policy-binding|secrets create)/);
316
+ const firstConvergeWrite =
317
+ backup + indexOf(events.slice(backup), /^gcloud .*(add-iam-policy-binding|secrets create)/);
264
318
  const baseline = indexOf(events, /^health baseline/);
265
319
  const migrate = indexOf(events, /^migrate registry\/admin@/);
266
320
  const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
267
321
  const gatewayRoll = indexOf(events, new RegExp(`^gcloud run services update ${GATEWAY} `));
322
+ const proxyRoll = indexOf(events, new RegExp(`^gcloud run services update ${PROXY} `));
268
323
  const deep = indexOf(events, /^health deep 0\.11\.0/);
269
324
  const digests = indexOf(events, /^record digests/);
270
325
 
271
- // Nothing writes before the backup.
326
+ // Before the backup only the additive pre-plan step writes (#1328): the
327
+ // MCP-Auth-Proxy's account and its two secret grants.
272
328
  for (const e of events.slice(0, backup)) expect(e).not.toMatch(/^(migrate|record)/);
273
329
  for (const e of events.slice(0, backup).filter((e) => e.startsWith("gcloud "))) {
274
- expect(e.slice("gcloud ".length)).not.toMatch(ANY_WRITE);
330
+ const call = e.slice("gcloud ".length);
331
+ if (ANY_WRITE.test(call)) expect(call).toMatch(PRE_PLAN_WRITE);
275
332
  }
276
333
  expect(backup).toBeLessThan(firstConvergeWrite);
277
334
  expect(firstConvergeWrite).toBeLessThan(baseline);
278
335
  expect(baseline).toBeLessThan(migrate);
279
336
  expect(migrate).toBeLessThan(adminRoll);
280
337
  expect(adminRoll).toBeLessThan(gatewayRoll);
281
- expect(gatewayRoll).toBeLessThan(deep);
338
+ // The proxy rolls last: on gcp-firebase it forwards to the new gateway.
339
+ expect(gatewayRoll).toBeLessThan(proxyRoll);
340
+ expect(proxyRoll).toBeLessThan(deep);
282
341
  expect(deep).toBeLessThan(digests);
283
342
  expect(recorded.digests).toEqual([DIGEST]);
284
343
  expect(lines).toContain(`done: Pre-upgrade backup ${BACKUP_ID} of ${SQL} taken`);
@@ -462,7 +521,8 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
462
521
  });
463
522
  expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
464
523
 
465
- const writes = calls.filter((c) => ANY_WRITE.test(c));
524
+ // Only the additive pre-plan grants (#1328) came before it.
525
+ const writes = calls.filter((c) => ANY_WRITE.test(c) && !PRE_PLAN_WRITE.test(c));
466
526
  expect(writes).toEqual([
467
527
  expect.stringMatching(/^sql backups create --instance caelo-production-pg-abc/),
468
528
  ]);
@@ -485,7 +545,12 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
485
545
  const rollbacks = events
486
546
  .slice(deep)
487
547
  .filter((e) => e.includes("update-traffic") && e.includes("--to-revisions"));
548
+ // Newest first: the proxy (back to the gateway release it was), the
549
+ // gateway, the admin.
488
550
  expect(rollbacks).toEqual([
551
+ expect.stringContaining(
552
+ `${PROXY} --region europe-west1 --project acme --to-revisions ${PROXY}-00001-old=100`,
553
+ ),
489
554
  expect.stringContaining(
490
555
  `${GATEWAY} --region europe-west1 --project acme --to-revisions ${GATEWAY}-00001-old=100`,
491
556
  ),
@@ -624,7 +689,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
624
689
  const { deps, lines } = upgradeDeps(events, {
625
690
  deep: { ok: false, error: "unhealthy" },
626
691
  });
627
- const before = { admin: `${ADMIN}-00000-before`, gateway: `${GATEWAY}-00000-before` };
692
+ const before = {
693
+ admin: `${ADMIN}-00000-before`,
694
+ gateway: `${GATEWAY}-00000-before`,
695
+ "mcp-auth-proxy": `${PROXY}-00000-before`,
696
+ };
628
697
  expect(
629
698
  await upgradeGcpInstall(TARGET, {
630
699
  ...deps,
@@ -642,6 +711,27 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
642
711
  );
643
712
  const rollbacks = events.filter((e) => e.includes("--to-revisions"));
644
713
  expect(rollbacks).toEqual([
714
+ expect.stringContaining(`--to-revisions ${before["mcp-auth-proxy"]}=100`),
715
+ expect.stringContaining(`--to-revisions ${before.gateway}=100`),
716
+ expect.stringContaining(`--to-revisions ${before.admin}=100`),
717
+ ]);
718
+ });
719
+
720
+ it("#1328 — a checkpoint without the proxy's revision (an older release wrote it) keeps the planned one", async () => {
721
+ const events: string[] = [];
722
+ const { deps } = upgradeDeps(events, { deep: { ok: false, error: "unhealthy" } });
723
+ const before = { admin: `${ADMIN}-00000-before`, gateway: `${GATEWAY}-00000-before` };
724
+ expect(
725
+ await upgradeGcpInstall(TARGET, {
726
+ ...deps,
727
+ resume: {
728
+ backup: { projectId: "acme", instance: SQL, backupId: "1759999999999" },
729
+ priorRevisions: before,
730
+ },
731
+ }),
732
+ ).toBe(false);
733
+ expect(events.filter((e) => e.includes("--to-revisions"))).toEqual([
734
+ expect.stringContaining(`--to-revisions ${PROXY}-00001-old=100`),
645
735
  expect.stringContaining(`--to-revisions ${before.gateway}=100`),
646
736
  expect.stringContaining(`--to-revisions ${before.admin}=100`),
647
737
  ]);
@@ -714,7 +804,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
714
804
  expect(recorded.contract).toEqual([]);
715
805
  });
716
806
 
717
- it("#806 — the rolls carry remote MCP: identity env, the gateway's client file and timeout", async () => {
807
+ it("#806/#1328 — the rolls carry remote MCP: identity env, the proxy's client file and timeout", async () => {
718
808
  const events: string[] = [];
719
809
  const { deps, calls } = upgradeDeps(events);
720
810
  expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
@@ -723,19 +813,129 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
723
813
  );
724
814
  const admin = rolls.find((c) => c.startsWith(`run services update ${ADMIN} `)) ?? "";
725
815
  const gateway = rolls.find((c) => c.startsWith(`run services update ${GATEWAY} `)) ?? "";
816
+ const proxy = rolls.find((c) => c.startsWith(`run services update ${PROXY} `)) ?? "";
726
817
  expect(admin).toContain(
727
818
  `CAELO_IAP_AUDIENCE=/projects/42/locations/europe-west1/services/${ADMIN}`,
728
819
  );
729
- expect(admin).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
730
- expect(gateway).toContain(`CAELO_ADMIN_URL=https://${ADMIN}-xyz.a.run.app`);
731
- expect(gateway).toContain(`CAELO_MCP_PUBLIC_URL=https://${GATEWAY}-xyz.a.run.app`);
732
- expect(gateway).toContain(
733
- "--update-secrets=/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
820
+ // E13 a: the MCP address stays the run.app URL of the service that was the gateway.
821
+ expect(admin).toContain(`CAELO_MCP_PUBLIC_URL=https://${PROXY}-xyz.a.run.app`);
822
+ // The Firebase rewrite follows the data gateway.
823
+ expect(admin).toContain(`CAELO_GATEWAY_SERVICE=${GATEWAY}`);
824
+ expect(proxy).toContain(`--image ${IMAGES["mcp-auth-proxy"].imageRef}`);
825
+ expect(proxy).toContain(`CAELO_ADMIN_URL=https://${ADMIN}-xyz.a.run.app`);
826
+ expect(proxy).toContain(`CAELO_MCP_PUBLIC_URL=https://${PROXY}-xyz.a.run.app`);
827
+ expect(proxy).toContain(
828
+ "/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
734
829
  );
735
- expect(gateway).toContain("--timeout=3600");
830
+ expect(proxy).toContain("--timeout=3600");
831
+ // The gateway carries none of it any more.
832
+ expect(gateway).not.toContain("CAELO_MCP_PUBLIC_URL=");
833
+ expect(gateway).not.toContain("operator-oauth-client");
834
+ expect(gateway).not.toContain("--timeout");
736
835
  expect(admin).not.toContain("--timeout");
737
836
  // No extra revision: everything rides the image rolls.
738
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
837
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
838
+ });
839
+
840
+ it("#1328 — the gcp-firebase proxy roll leaves the VPC, drops every database login and forwards to the data gateway", async () => {
841
+ const events: string[] = [];
842
+ const { deps, calls } = upgradeDeps(events);
843
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
844
+ const proxy =
845
+ calls.find((c) => new RegExp(`^run services update ${PROXY} .*--image`).test(c)) ?? "";
846
+ expect(proxy).toContain(
847
+ "--service-account=caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com",
848
+ );
849
+ expect(proxy).toContain("--clear-network");
850
+ expect(proxy).toContain("CAELO_REMOTE_MCP=available");
851
+ expect(proxy).toContain(`CAELO_INTERIM_FORWARD_ORIGIN=https://${GATEWAY}-xyz.a.run.app`);
852
+ const removedEnv = proxy.match(/--remove-env-vars=(\S+)/)?.[1]?.split(",") ?? [];
853
+ const removedSecrets = proxy.match(/--remove-secrets=(\S+)/)?.[1]?.split(",") ?? [];
854
+ expect(removedEnv).toContain("PUBLIC_DATABASE_URL");
855
+ expect(removedSecrets).toEqual(
856
+ expect.arrayContaining(["PUBLIC_DATABASE_PASSWORD", "CAELO_STAGING_GATE_SECRET"]),
857
+ );
858
+ // The gateway keeps its VPC route to Cloud SQL.
859
+ const gateway =
860
+ calls.find((c) => new RegExp(`^run services update ${GATEWAY} .*--image`).test(c)) ?? "";
861
+ expect(gateway).not.toContain("--clear-network");
862
+ });
863
+
864
+ it("#1328 — gcp-firebase: the pre-plan step copies the data gateway from the old gateway when it is missing", async () => {
865
+ const events: string[] = [];
866
+ const { deps, calls, lines } = upgradeDeps(events, {
867
+ overrides: [
868
+ [
869
+ new RegExp(`^run services describe ${GATEWAY} .*metadata\\.name`),
870
+ fail(`ERROR: Cannot find service [${GATEWAY}]. NOT_FOUND`),
871
+ ],
872
+ ],
873
+ });
874
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
875
+ const replace = indexOf(events, /^gcloud run services replace /);
876
+ expect(replace).toBeLessThan(indexOf(events, /^gcloud sql backups create/));
877
+ expect(lines).toContain(
878
+ `info: created: Cloud Run service ${GATEWAY} (the visitor gateway) (copied from ${PROXY})`,
879
+ );
880
+ // Nothing else is deployed before the plan: the proxy is rolled later.
881
+ expect(calls.some((c) => /^run deploy /.test(c))).toBe(false);
882
+ });
883
+
884
+ it("#1328 — gcp-firebase: an existing data gateway makes the pre-plan step a no-op", async () => {
885
+ const events: string[] = [];
886
+ const { deps, calls } = upgradeDeps(events);
887
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
888
+ expect(calls.some((c) => /^run services replace |^run deploy /.test(c))).toBe(false);
889
+ });
890
+
891
+ it("#1328 — a failed pre-plan step stops before the backup, with nothing rolled", async () => {
892
+ const events: string[] = [];
893
+ const { deps, calls, lines } = upgradeDeps(events, {
894
+ overrides: [[/^secrets add-iam-policy-binding/, fail("PERMISSION_DENIED: setIamPolicy")]],
895
+ });
896
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
897
+ expect(calls.some((c) => /^sql backups create/.test(c))).toBe(false);
898
+ expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
899
+ expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
900
+ expect(
901
+ lines.some((l) => l.startsWith("failed: The services this release runs could not be set up")),
902
+ ).toBe(true);
903
+ });
904
+
905
+ it("#1328 — gcp: the proxy is deployed only after its image's signature verifies", async () => {
906
+ const missingProxy: [RegExp, Answer] = [
907
+ new RegExp(`^run services describe ${GCP_PROXY} .*metadata\\.name`),
908
+ fail(`ERROR: Cannot find service [${GCP_PROXY}]. NOT_FOUND`),
909
+ ];
910
+ const events: string[] = [];
911
+ const { deps } = upgradeDeps(events, { overrides: [missingProxy] });
912
+ expect(
913
+ await upgradeGcpInstall(GCP_TARGET, {
914
+ ...deps,
915
+ verifyImages: async (images) => {
916
+ events.push(`verify ${images.map((i) => i.slug).join(",")}`);
917
+ return true;
918
+ },
919
+ }),
920
+ ).toBe(true);
921
+ const verify = indexOf(events, /^verify mcp-auth-proxy$/);
922
+ const deploy = indexOf(events, new RegExp(`^gcloud run deploy ${GCP_PROXY} `));
923
+ expect(verify).toBeLessThan(deploy);
924
+ expect(events[deploy]).toContain(`--image=${IMAGES["mcp-auth-proxy"].imageRef}`);
925
+ expect(events[deploy]).toContain("--ingress=internal-and-cloud-load-balancing");
926
+ expect(deploy).toBeLessThan(indexOf(events, /^gcloud sql backups create/));
927
+
928
+ // A signature that does not verify: nothing is deployed, nothing backed up.
929
+ const refused: string[] = [];
930
+ const bad = upgradeDeps(refused, { overrides: [missingProxy] });
931
+ expect(
932
+ await upgradeGcpInstall(GCP_TARGET, {
933
+ ...bad.deps,
934
+ verifyImages: async (images) => !images.some((i) => i.slug === "mcp-auth-proxy"),
935
+ }),
936
+ ).toBe(false);
937
+ expect(bad.calls.some((c) => /^run deploy /.test(c))).toBe(false);
938
+ expect(bad.calls.some((c) => /^sql backups create/.test(c))).toBe(false);
739
939
  });
740
940
 
741
941
  it("moves the gateway to request-based CPU in its image roll; the admin keeps its CPU", async () => {
@@ -747,44 +947,120 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
747
947
  );
748
948
  const admin = rolls.find((c) => c.startsWith(`run services update ${ADMIN} `)) ?? "";
749
949
  const gateway = rolls.find((c) => c.startsWith(`run services update ${GATEWAY} `)) ?? "";
950
+ const proxy = rolls.find((c) => c.startsWith(`run services update ${PROXY} `)) ?? "";
750
951
  expect(gateway).toContain("--cpu-throttling");
952
+ expect(proxy).toContain("--cpu-throttling");
751
953
  expect(admin).not.toContain("cpu-throttling");
752
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
954
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
753
955
  });
754
956
 
755
- it("#806 — on gcp, routes the discovery paths and adds the Cloud Armor rule before the roll", async () => {
957
+ it("#806/#1328 — on gcp, moves the remote-MCP paths and the Cloud Armor rule to the proxy's backend before the roll", async () => {
756
958
  const events: string[] = [];
959
+ // The #806 shape: the discovery paths on the gateway's rule, the MCP
960
+ // rule on the gateway's policy.
961
+ const gatewayBackend =
962
+ "https://www.googleapis.com/compute/v1/projects/acme/global/backendServices/caelo-production-gateway-backend-1";
757
963
  const urlMap = {
758
964
  name: "caelo-production-url-map-1",
759
965
  hostRules: [{ hosts: ["acme.com"], pathMatcher: "public" }],
760
- pathMatchers: [{ name: "public", pathRules: [{ paths: ["/api/*"], service: "gw" }] }],
966
+ pathMatchers: [
967
+ {
968
+ name: "public",
969
+ pathRules: [
970
+ {
971
+ paths: [
972
+ "/api/*",
973
+ "/.well-known/oauth-protected-resource/*",
974
+ "/.well-known/oauth-authorization-server/*",
975
+ ],
976
+ service: gatewayBackend,
977
+ },
978
+ ],
979
+ },
980
+ ],
761
981
  };
982
+ const r = MCP_ARMOR_RULE;
983
+ const gatewayMcpRule = {
984
+ priority: r.priority,
985
+ action: r.action,
986
+ match: { expr: { expression: r.expression } },
987
+ rateLimitOptions: {
988
+ rateLimitThreshold: { count: r.count, intervalSec: r.intervalSec },
989
+ conformAction: r.conformAction,
990
+ exceedAction: r.exceedAction,
991
+ enforceOnKey: r.enforceOnKey,
992
+ },
993
+ };
994
+ let proxyPolicyCreated = false;
762
995
  const puts: string[] = [];
763
996
  const { deps, lines } = upgradeDeps(events, {
764
997
  overrides: [
765
998
  [/^compute url-maps list/, ok(JSON.stringify([urlMap]))],
766
999
  [/^compute security-policies list/, ok("caelo-production-waf-1\n")],
767
- [/^compute security-policies describe/, ok(JSON.stringify({ rules: [] }))],
1000
+ [
1001
+ /^compute security-policies describe caelo-production-mcp-auth-proxy-armor /,
1002
+ () =>
1003
+ proxyPolicyCreated
1004
+ ? ok(JSON.stringify({ rules: [] }))
1005
+ : fail("ERROR: The resource 'caelo-production-mcp-auth-proxy-armor' was not found"),
1006
+ ],
1007
+ [
1008
+ /^compute security-policies create caelo-production-mcp-auth-proxy-armor /,
1009
+ () => {
1010
+ proxyPolicyCreated = true;
1011
+ return ok();
1012
+ },
1013
+ ],
1014
+ [
1015
+ /^compute security-policies describe caelo-production-waf-1 /,
1016
+ ok(JSON.stringify({ rules: [gatewayMcpRule] })),
1017
+ ],
1018
+ [/^compute (network-endpoint-groups|backend-services) describe/, fail("NOT_FOUND")],
768
1019
  ],
769
1020
  });
770
1021
  const recordingHttp: HttpFetch = async (url, init) => {
771
- if (init.method === "PUT" && url.includes("/urlMaps/"))
772
- puts.push(`${url} ${init.body ?? ""}`);
1022
+ if (init.method === "PUT" && url.includes("/urlMaps/")) puts.push(init.body ?? "");
773
1023
  return http(url, init);
774
1024
  };
775
1025
  expect(await upgradeGcpInstall(GCP_TARGET, { ...deps, http: recordingHttp })).toBe(true);
776
- expect(events).toContainEqual(
777
- expect.stringMatching(
778
- /^gcloud compute security-policies rules create 900 --security-policy=caelo-production-waf-1 /,
779
- ),
1026
+ const armor = indexOf(
1027
+ events,
1028
+ /^gcloud compute security-policies rules create 900 --security-policy=caelo-production-mcp-auth-proxy-armor /,
780
1029
  );
781
- expect(puts).toHaveLength(1);
782
- expect(puts[0]).toContain("/global/urlMaps/caelo-production-url-map-1");
783
- expect(puts[0]).toContain("/.well-known/oauth-authorization-server/*");
784
- expect(lines).toContain("done: Remote-MCP load-balancer rules ready");
785
- expect(indexOf(events, /security-policies rules create/)).toBeLessThan(
786
- indexOf(events, /^migrate /),
1030
+ const neg = indexOf(
1031
+ events,
1032
+ /^gcloud compute network-endpoint-groups create caelo-production-mcp-auth-proxy-neg .*--cloud-run-service=caelo-production-mcp-auth-proxy/,
1033
+ );
1034
+ const backend = indexOf(
1035
+ events,
1036
+ /^gcloud compute backend-services create caelo-production-mcp-auth-proxy-backend .*--security-policy=caelo-production-mcp-auth-proxy-armor/,
1037
+ );
1038
+ const retire = indexOf(
1039
+ events,
1040
+ /^gcloud compute security-policies rules delete 900 --security-policy=caelo-production-waf-1 /,
787
1041
  );
1042
+ expect(puts).toHaveLength(1);
1043
+ const put = JSON.parse(puts[0] as string) as typeof urlMap;
1044
+ const rules = put.pathMatchers[0]?.pathRules ?? [];
1045
+ expect(rules).toEqual([
1046
+ { paths: ["/api/*"], service: gatewayBackend },
1047
+ {
1048
+ paths: [
1049
+ "/api/mcp/*",
1050
+ "/.well-known/oauth-protected-resource/*",
1051
+ "/.well-known/oauth-authorization-server/*",
1052
+ ],
1053
+ service:
1054
+ "https://www.googleapis.com/compute/v1/projects/acme/global/backendServices/caelo-production-mcp-auth-proxy-backend",
1055
+ },
1056
+ ]);
1057
+ expect(lines).toContain("done: Remote-MCP load-balancer route ready");
1058
+ // Proxy policy before its backend, the old gateway rule only once the
1059
+ // URL map no longer sends MCP there; all before the migrations.
1060
+ expect(armor).toBeLessThan(backend);
1061
+ expect(neg).toBeLessThan(backend);
1062
+ expect(backend).toBeLessThan(retire);
1063
+ expect(retire).toBeLessThan(indexOf(events, /^migrate /));
788
1064
  });
789
1065
 
790
1066
  it("on gcp-firebase there is no URL map: no redirects role", async () => {
@@ -809,7 +1085,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
809
1085
  indexOf(events, /^provisioner identity/),
810
1086
  );
811
1087
  expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
812
- expect(calls.filter((c) => CONVERGE_WRITE.test(c) || APP_ROLL_WRITE.test(c))).toEqual([]);
1088
+ expect(
1089
+ calls.filter(
1090
+ (c) => (CONVERGE_WRITE.test(c) && !PRE_PLAN_WRITE.test(c)) || APP_ROLL_WRITE.test(c),
1091
+ ),
1092
+ ).toEqual([]);
813
1093
  });
814
1094
 
815
1095
  it("a deep health probe that throws still rolls back", async () => {
@@ -827,7 +1107,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
827
1107
  healthProbe: async () => ({ ok: true, probe: throwing }),
828
1108
  }),
829
1109
  ).toBe(false);
830
- expect(events.filter((e) => e.includes("--to-revisions"))).toHaveLength(2);
1110
+ expect(events.filter((e) => e.includes("--to-revisions"))).toHaveLength(3);
831
1111
  expect(recorded.digests).toEqual([]);
832
1112
  });
833
1113
 
@@ -868,7 +1148,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
868
1148
  // Its own update: the image roll does not carry it (no new revision).
869
1149
  expect(events.filter((e) => e.includes("--ingress=all"))).toHaveLength(1);
870
1150
  expect(lines).toContain(
871
- `info: applied: ingress internal-and-cloud-load-balancing → all on ${GATEWAY} (the gcp-firebase stack's gateway ingress (#650); the health check, the staging domain and remote MCP reach its run.app URL)`,
1151
+ `info: applied: ingress internal-and-cloud-load-balancing → all on ${GATEWAY} (the gcp-firebase stack's gateway ingress (#650); Firebase Hosting and the health check reach its run.app URL)`,
872
1152
  );
873
1153
  });
874
1154
 
@@ -919,6 +1199,7 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
919
1199
  domain: "acme.com",
920
1200
  adminServiceUrl: `https://${ADMIN}-xyz.a.run.app`,
921
1201
  gatewayServiceUrl: `https://${GATEWAY}-xyz.a.run.app`,
1202
+ mcpAuthProxyServiceUrl: `https://${PROXY}-xyz.a.run.app`,
922
1203
  },
923
1204
  { run: deps.run, http: htmlGateway, sleep: noSleep, accessJustChanged: false },
924
1205
  ),
@@ -987,7 +1268,35 @@ describe("app-roll and converge stay on their side", () => {
987
1268
  );
988
1269
  expect(result).toEqual({ ok: true, digests: DIGEST });
989
1270
  expect(calls.filter((c) => CONVERGE_WRITE.test(c))).toEqual([]);
990
- expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(2);
1271
+ expect(calls.filter((c) => /^run services update /.test(c))).toHaveLength(3);
1272
+ });
1273
+
1274
+ it("#1338: without a converge, a plan whose services dial the database's IP stops before migrations", async () => {
1275
+ const events: string[] = [];
1276
+ const { run, calls } = fakeInstall(events);
1277
+ const plan = { ...(await plannedRoll(run)), databaseHostPending: true };
1278
+ const { reporter, lines } = recordingReporter();
1279
+ let migrated = false;
1280
+ const result = await executeAppRoll(
1281
+ { plan, backup: BACKUP, targetVersion: "0.11.0", migrationsContract: false },
1282
+ {},
1283
+ {
1284
+ run,
1285
+ sleep: noSleep,
1286
+ reporter,
1287
+ migrate: async () => {
1288
+ migrated = true;
1289
+ return { ok: true };
1290
+ },
1291
+ health: fakeHealth(events),
1292
+ resolveOpsImage: async () => `registry/ops@sha256:${"c".repeat(64)}`,
1293
+ recordDigests: async () => {},
1294
+ },
1295
+ );
1296
+ expect(result).toEqual({ ok: false, phase: "migrations", migrated: false, rolledBack: false });
1297
+ expect(migrated).toBe(false);
1298
+ expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
1299
+ expect(lines.join("\n")).toContain("verify-full TLS");
991
1300
  });
992
1301
 
993
1302
  it("converge alone never rolls a service or runs migrations", async () => {