@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
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 14;
25
+ export const INFRA_CONTRACT_VERSION = 16;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -110,6 +110,27 @@ export const INFRA_CONTRACT_VERSION = 14;
110
110
  // CAELO_CAPTURE_BUCKET / CAELO_MEDIA_BUCKET on the admin. The new admin
111
111
  // runs no browser itself on GCP and the app-roll moves the job's image,
112
112
  // so it must exist before an in-cloud upgrader rolls onto it.
113
+ // 15 — #1338: verify-full TLS to Cloud SQL. The instance moves to the
114
+ // shared server CA (a patch plus a server-certificate rotation) and
115
+ // refuses plaintext, a private Cloud DNS zone resolves its DNS name in
116
+ // the VPC, and the services dial that name (cloudsql-tls.ts). The new
117
+ // images refuse any database URL weaker than verify-full, and the
118
+ // in-cloud upgrader may neither patch the instance nor create a DNS
119
+ // zone, so it must not roll an install onto them first.
120
+ // 16 — #1328: the MCP-Auth-Proxy, remote MCP's broker as a service of its
121
+ // own (no database, its own image). Its service account with
122
+ // secretAccessor on exactly the Google sign-in client and the seal
123
+ // key, and the upgrader's actAs on it. On `gcp-firebase` the visitor
124
+ // gateway moves to a new Cloud Run service (`caelo-<env>-data-gateway`,
125
+ // public invoker, the admin's run.viewer for the Firebase rewrite) and
126
+ // the old `gateway` service, whose run.app URL every connector holds
127
+ // (E13 a), becomes the proxy: new image, new account, no VPC, no
128
+ // database env. On `gcp` the proxy is a new service behind its own
129
+ // serverless NEG, backend service and Cloud Armor policy (the MCP rule
130
+ // moves there) and the URL map sends the MCP paths to it. New
131
+ // services, accounts, IAM and load-balancer resources — none of which
132
+ // the in-cloud upgrader may create, and the app-roll rolls three
133
+ // services from now on.
113
134
 
114
135
  /**
115
136
  * What each contract version adds, in operator terms — the upgrader's
@@ -131,6 +152,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
131
152
  12: "Google sign-in at project level: the operator-access job's permission to set the sign-in client in the project's IAP settings (#914)",
132
153
  13: "the public site's security headers at the edge: nosniff on every file, uploaded SVGs no longer run as documents on the site's origin, and a one-time CDN purge (#891)",
133
154
  14: "the capture job that takes server-side screenshots outside the admin, its service account, bucket and grants (#1217)",
155
+ 15: "verified TLS to the database: Cloud SQL's shared server CA, encrypted connections only, and a private DNS name the services dial (#1338)",
156
+ 16: "the MCP sign-in service as its own Cloud Run service with its own account (no database access), the visitor gateway as its own service on Firebase installs, and on Google Cloud load-balancer installs the sign-in service's backend and Cloud Armor rule (#1328)",
134
157
  };
135
158
 
136
159
  /**
@@ -155,10 +155,25 @@ export function recordableStagingHost(host: string): string | null {
155
155
  export interface ImageDigests {
156
156
  readonly admin: string;
157
157
  readonly gateway: string;
158
+ /**
159
+ * #1328 — the MCP-Auth-Proxy's. Absent on installs recorded before it
160
+ * existed: their first upgrade to a release with it records it, and a
161
+ * stack run without it deploys the release tag's proxy image.
162
+ */
163
+ readonly "mcp-auth-proxy"?: string;
158
164
  }
159
165
 
160
166
  const DIGEST = /^sha256:[0-9a-f]{64}$/;
161
167
 
168
+ /** {@link ImageDigests} without the keys that are absent (a JSON-stable copy). */
169
+ function imageDigestsRecord(d: ImageDigests): ImageDigests {
170
+ return {
171
+ admin: d.admin,
172
+ gateway: d.gateway,
173
+ ...(d["mcp-auth-proxy"] ? { "mcp-auth-proxy": d["mcp-auth-proxy"] } : {}),
174
+ };
175
+ }
176
+
162
177
  /**
163
178
  * The image digests recorded for an install, or null when none are. Throws
164
179
  * on a malformed record rather than deploying a guess.
@@ -166,7 +181,12 @@ const DIGEST = /^sha256:[0-9a-f]{64}$/;
166
181
  export function recordedImageDigests(meta: InstallMetadata): ImageDigests | null {
167
182
  const d = meta.imageDigests;
168
183
  if (d === undefined) return null;
169
- if (!DIGEST.test(d.admin ?? "") || !DIGEST.test(d.gateway ?? "")) {
184
+ const proxy = d["mcp-auth-proxy"];
185
+ if (
186
+ !DIGEST.test(d.admin ?? "") ||
187
+ !DIGEST.test(d.gateway ?? "") ||
188
+ (proxy !== undefined && !DIGEST.test(proxy))
189
+ ) {
170
190
  throw new Error(
171
191
  `install.json imageDigests is malformed (${JSON.stringify(d)}); fix or remove it, then re-run.`,
172
192
  );
@@ -211,15 +231,17 @@ export function recordImageDigests(
211
231
  // drop it — a stale one would be worse than none (the upgrader job
212
232
  // refuses on none and judges downgrades by it).
213
233
  const same =
214
- meta.imageDigests?.admin === digests.admin && meta.imageDigests?.gateway === digests.gateway;
234
+ meta.imageDigests?.admin === digests.admin &&
235
+ meta.imageDigests?.gateway === digests.gateway &&
236
+ meta.imageDigests?.["mcp-auth-proxy"] === digests["mcp-auth-proxy"];
215
237
  const { releaseVersion: previous, platformDigests: previousPlatform, ...rest } = meta;
216
238
  const version = releaseVersion ?? (same ? previous : undefined);
217
239
  // The platform forms belong to their indexes just as the version does.
218
240
  const platform = platformDigests ?? (same ? previousPlatform : undefined);
219
241
  writeMetadata(installId, {
220
242
  ...rest,
221
- imageDigests: { admin: digests.admin, gateway: digests.gateway },
222
- ...(platform ? { platformDigests: { admin: platform.admin, gateway: platform.gateway } } : {}),
243
+ imageDigests: imageDigestsRecord(digests),
244
+ ...(platform ? { platformDigests: imageDigestsRecord(platform) } : {}),
223
245
  ...(version ? { releaseVersion: version } : {}),
224
246
  });
225
247
  }
package/src/lifecycle.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  } from "./runtime-secrets.js";
61
61
  import { finishSealKeyRotation, startSealKeyRotation } from "./seal-key-rotation.js";
62
62
  import { selfCommand } from "./self-command.js";
63
+ import { CLOUD_RUN_SLUGS, type CloudRunSlug, cloudRunServicePrefix } from "./stack-contract.js";
63
64
  import {
64
65
  backendShellEnv,
65
66
  localStateNotice,
@@ -720,6 +721,7 @@ async function gcpHealthProbe(
720
721
  domain: install.domain,
721
722
  adminServiceUrl: urls.admin as string,
722
723
  gatewayServiceUrl: urls.gateway as string,
724
+ mcpAuthProxyServiceUrl: urls["mcp-auth-proxy"] as string,
723
725
  },
724
726
  { run, http, sleep: realSleep, accessJustChanged: context.accessJustChanged },
725
727
  ),
@@ -880,23 +882,29 @@ export async function rotateSecretCommand(
880
882
  }
881
883
 
882
884
  if (name === SEAL_KEY_SECRET) {
883
- const gateway = await resolveGcpResourceName(
885
+ // #1328 — the broker, and with it the seal key, lives in the MCP-Auth-Proxy.
886
+ const proxyPrefix = cloudRunServicePrefix(meta.provider, GCP_STACK_ENV, "mcp-auth-proxy");
887
+ const proxy = await resolveGcpResourceName(
884
888
  gcloud,
885
889
  "run-service",
886
- "caelo-production-gateway",
890
+ proxyPrefix,
887
891
  meta.projectId,
888
892
  region,
889
893
  );
890
- if (gateway.status !== "found") {
894
+ if (proxy.status !== "found") {
891
895
  log.error(
892
896
  red(
893
- `${lookupFailure(gateway, "caelo-production-gateway* Cloud Run service", "operator")} — nothing rotated`,
897
+ `${lookupFailure(proxy, `${proxyPrefix}* Cloud Run service`, "operator")} — nothing rotated`,
894
898
  ),
895
899
  );
896
900
  return;
897
901
  }
898
- const gatewayService = gateway.name;
899
- const target = { projectId: meta.projectId, region, env: GCP_STACK_ENV, gatewayService };
902
+ const target = {
903
+ projectId: meta.projectId,
904
+ region,
905
+ env: GCP_STACK_ENV,
906
+ proxyService: proxy.name,
907
+ };
900
908
  const report = opts.finish
901
909
  ? await finishSealKeyRotation(target, {}, { now: opts.now === true })
902
910
  : await startSealKeyRotation(target);
@@ -910,19 +918,20 @@ export async function rotateSecretCommand(
910
918
 
911
919
  const s = spinner();
912
920
  s.start(`Rotating ${secret}...`);
913
- const services: Partial<Record<"admin" | "gateway", string>> = {};
914
- for (const slug of ["admin", "gateway"] as const) {
921
+ const services: Partial<Record<CloudRunSlug, string>> = {};
922
+ for (const slug of CLOUD_RUN_SLUGS) {
923
+ const prefix = cloudRunServicePrefix(meta.provider, GCP_STACK_ENV, slug);
915
924
  const service = await resolveGcpResourceName(
916
925
  gcloud,
917
926
  "run-service",
918
- `caelo-production-${slug}`,
927
+ prefix,
919
928
  meta.projectId,
920
929
  region,
921
930
  );
922
931
  if (service.status !== "found") {
923
932
  s.stop(
924
933
  red(
925
- `${lookupFailure(service, `caelo-production-${slug}* Cloud Run service`, "operator")} — nothing rotated`,
934
+ `${lookupFailure(service, `${prefix}* Cloud Run service`, "operator")} — nothing rotated`,
926
935
  ),
927
936
  );
928
937
  return;
@@ -952,7 +961,7 @@ export async function rotateSecretCommand(
952
961
  projectId: meta.projectId,
953
962
  region,
954
963
  env: GCP_STACK_ENV,
955
- services: services as Record<"admin" | "gateway", string>,
964
+ services: services as Record<CloudRunSlug, string>,
956
965
  ...(sqlInstance ? { sqlInstance } : {}),
957
966
  },
958
967
  secret,
@@ -0,0 +1,305 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1328 — the services an upgrade to the MCP-Auth-Proxy release creates on
5
+ * installs whose stack predates them (mcp-auth-proxy-setup.ts): the proxy's
6
+ * account and its two secret grants, the gcp-firebase data gateway copied
7
+ * from the old gateway service, and the gcp proxy service.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import type { GcloudResult } from "./gcloud.js";
12
+ import type { GcloudRunner } from "./gcloud-retry.js";
13
+ import {
14
+ cloneServiceSpec,
15
+ ensureReleaseServices,
16
+ gcpProxyDeployArgs,
17
+ type ReleaseServicesTarget,
18
+ } from "./mcp-auth-proxy-setup.js";
19
+ import { MCP_AUTH_PROXY_TIMEOUT_SECONDS } from "./stack-contract.js";
20
+
21
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
22
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
23
+
24
+ /** Answers by the longest matching command prefix; anything else is an error. */
25
+ function fakeGcloud(answers: Record<string, GcloudResult>) {
26
+ const calls: string[] = [];
27
+ const run: GcloudRunner = async (args) => {
28
+ const line = args.join(" ");
29
+ calls.push(line);
30
+ const key = Object.keys(answers)
31
+ .filter((k) => line.startsWith(k))
32
+ .sort((a, b) => b.length - a.length)[0];
33
+ return key ? (answers[key] as GcloudResult) : fail(`unexpected: ${line}`);
34
+ };
35
+ return { run, calls };
36
+ }
37
+
38
+ const IMAGE = "europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images";
39
+ const PROXY_SA = "caelo-production-mcp-proxy-sa@acme.iam.gserviceaccount.com";
40
+
41
+ const firebase: ReleaseServicesTarget = {
42
+ provider: "gcp-firebase",
43
+ projectId: "acme",
44
+ region: "europe-west1",
45
+ env: "production",
46
+ domain: "acme.com",
47
+ mcpAuthProxyImageRef: `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`,
48
+ };
49
+ const gcp: ReleaseServicesTarget = { ...firebase, provider: "gcp" };
50
+
51
+ /** The old gateway service as `gcloud run services describe --format=json` shows it. */
52
+ const OLD_GATEWAY = {
53
+ apiVersion: "serving.knative.dev/v1",
54
+ kind: "Service",
55
+ metadata: {
56
+ name: "caelo-production-gateway-bbb",
57
+ namespace: "123456",
58
+ uid: "u-1",
59
+ resourceVersion: "AAY",
60
+ generation: 42,
61
+ creationTimestamp: "2026-01-01T00:00:00Z",
62
+ selfLink:
63
+ "/apis/serving.knative.dev/v1/namespaces/123456/services/caelo-production-gateway-bbb",
64
+ labels: {
65
+ "cloud.googleapis.com/location": "europe-west1",
66
+ "goog-terraform-provisioned": "true",
67
+ team: "web",
68
+ },
69
+ annotations: {
70
+ "run.googleapis.com/ingress": "all",
71
+ "run.googleapis.com/ingress-status": "all",
72
+ "serving.knative.dev/creator": "someone@acme.com",
73
+ "serving.knative.dev/lastModifier": "someone@acme.com",
74
+ "run.googleapis.com/operation-id": "op-1",
75
+ "run.googleapis.com/client-name": "gcloud",
76
+ },
77
+ },
78
+ spec: {
79
+ template: {
80
+ metadata: {
81
+ name: "caelo-production-gateway-bbb-00042-abc",
82
+ labels: {
83
+ "client.knative.dev/nonce": "n",
84
+ "run.googleapis.com/startupProbeType": "Default",
85
+ },
86
+ annotations: {
87
+ "autoscaling.knative.dev/maxScale": "10",
88
+ "run.googleapis.com/network-interfaces": '[{"network":"caelo-production-vpc"}]',
89
+ "run.googleapis.com/vpc-access-egress": "private-ranges-only",
90
+ "run.googleapis.com/client-name": "gcloud",
91
+ "run.googleapis.com/client-version": "500.0.0",
92
+ },
93
+ },
94
+ spec: {
95
+ serviceAccountName: "caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
96
+ timeoutSeconds: 3600,
97
+ containers: [
98
+ {
99
+ image: `${IMAGE}/gateway@sha256:${"b".repeat(64)}`,
100
+ env: [
101
+ { name: "CAELO_PROVIDER", value: "gcp-firebase" },
102
+ {
103
+ name: "PUBLIC_DATABASE_PASSWORD",
104
+ valueFrom: {
105
+ secretKeyRef: { name: "caelo-production-public-role-password", key: "latest" },
106
+ },
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ },
113
+ traffic: [{ revisionName: "caelo-production-gateway-bbb-00041-old", percent: 100 }],
114
+ },
115
+ status: {
116
+ url: "https://caelo-production-gateway-bbb-xyz.a.run.app",
117
+ latestReadyRevisionName: "x",
118
+ },
119
+ };
120
+
121
+ describe("cloneServiceSpec", () => {
122
+ const clone = JSON.parse(
123
+ cloneServiceSpec(JSON.stringify(OLD_GATEWAY), "caelo-production-data-gateway"),
124
+ );
125
+
126
+ it("names the copy and drops the source's identity, status and tool stamps", () => {
127
+ expect(clone.metadata).toEqual({
128
+ name: "caelo-production-data-gateway",
129
+ labels: { team: "web" },
130
+ annotations: { "run.googleapis.com/ingress": "all" },
131
+ });
132
+ expect(clone.status).toBeUndefined();
133
+ expect(clone.spec.template.metadata.name).toBeUndefined();
134
+ expect(clone.spec.template.metadata.labels).toEqual({});
135
+ expect(clone.spec.template.metadata.annotations).toEqual({
136
+ "autoscaling.knative.dev/maxScale": "10",
137
+ "run.googleapis.com/network-interfaces": '[{"network":"caelo-production-vpc"}]',
138
+ "run.googleapis.com/vpc-access-egress": "private-ranges-only",
139
+ });
140
+ });
141
+
142
+ it("keeps the configuration — image, env, secrets, account, timeout — and routes all traffic to the new revision", () => {
143
+ expect(clone.spec.template.spec).toEqual(OLD_GATEWAY.spec.template.spec);
144
+ expect(clone.spec.traffic).toEqual([{ percent: 100, latestRevision: true }]);
145
+ expect(clone.apiVersion).toBe("serving.knative.dev/v1");
146
+ expect(clone.kind).toBe("Service");
147
+ });
148
+
149
+ it("refuses to copy a service that runs no gateway image (the proxy the old service became)", () => {
150
+ const proxy = structuredClone(OLD_GATEWAY);
151
+ (proxy.spec.template.spec.containers[0] as { image: string }).image =
152
+ `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
153
+ expect(() => cloneServiceSpec(JSON.stringify(proxy), "x")).toThrow("not a Caelo gateway image");
154
+ });
155
+ });
156
+
157
+ describe("gcpProxyDeployArgs", () => {
158
+ it("deploys the proxy load-balancer-only, public, at zero instances, without a VPC, with its two secrets", () => {
159
+ const args = gcpProxyDeployArgs(gcp);
160
+ expect(args.slice(0, 3)).toEqual(["run", "deploy", "caelo-production-mcp-auth-proxy"]);
161
+ expect(args).toContain(`--image=${gcp.mcpAuthProxyImageRef}`);
162
+ expect(args).toContain(`--service-account=${PROXY_SA}`);
163
+ expect(args).toContain("--ingress=internal-and-cloud-load-balancing");
164
+ expect(args).toContain("--allow-unauthenticated");
165
+ expect(args).toContain("--min-instances=0");
166
+ expect(args).toContain("--cpu-throttling");
167
+ expect(args).toContain(`--timeout=${MCP_AUTH_PROXY_TIMEOUT_SECONDS}`);
168
+ expect(args.some((a) => a.startsWith("--network") || a.startsWith("--vpc"))).toBe(false);
169
+ expect(args).toContain(
170
+ "--set-env-vars=CAELO_PROVIDER=gcp,CAELO_REMOTE_MCP=available,CAELO_TRUSTED_PROXY_HOPS=2,CAELO_OPERATOR_OAUTH_CLIENT_FILE=/secrets/operator-oauth-client/client.json,CAELO_ADMIN_URL=https://admin.acme.com,CAELO_MCP_PUBLIC_URL=https://acme.com",
171
+ );
172
+ expect(args).toContain(
173
+ "--set-secrets=CAELO_MCP_OAUTH_SEAL_KEY=caelo-production-mcp-oauth-seal-key:latest,/secrets/operator-oauth-client/client.json=caelo-production-operator-oauth-client:latest",
174
+ );
175
+ // Nothing that would hand it a database: no URL, no password.
176
+ expect(args.join(" ")).not.toMatch(/DATABASE|public-role-password|postgres-password/);
177
+ });
178
+ });
179
+
180
+ /** The account and its two grants, as a fresh install answers them. */
181
+ const accountAndGrants = {
182
+ [`iam service-accounts describe ${PROXY_SA}`]: fail("NOT_FOUND: does not exist"),
183
+ "iam service-accounts create caelo-production-mcp-proxy-sa": ok(),
184
+ "secrets add-iam-policy-binding": ok(),
185
+ };
186
+
187
+ describe("ensureReleaseServices", () => {
188
+ it("gcp-firebase: creates the account, grants exactly its two secrets and copies the old gateway", async () => {
189
+ const written: string[] = [];
190
+ let cleaned = false;
191
+ const { run, calls } = fakeGcloud({
192
+ ...accountAndGrants,
193
+ "run services describe caelo-production-data-gateway": fail("NOT_FOUND: could not be found"),
194
+ "run services list": ok("caelo-production-gateway-bbb\n"),
195
+ "run services describe caelo-production-gateway-bbb": ok(JSON.stringify(OLD_GATEWAY)),
196
+ "run services replace": ok(),
197
+ });
198
+ const outcomes = await ensureReleaseServices(firebase, {
199
+ run,
200
+ writeSpecFile: (json) => {
201
+ written.push(json);
202
+ return {
203
+ path: "/tmp/spec.json",
204
+ cleanup: () => {
205
+ cleaned = true;
206
+ },
207
+ };
208
+ },
209
+ });
210
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
211
+ expect(calls.filter((c) => c.startsWith("secrets add-iam-policy-binding"))).toEqual([
212
+ `secrets add-iam-policy-binding caelo-production-mcp-oauth-seal-key --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
213
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --project=acme --member=serviceAccount:${PROXY_SA} --role=roles/secretmanager.secretAccessor --condition=None --quiet`,
214
+ ]);
215
+ // Never a project role.
216
+ expect(calls.some((c) => c.startsWith("projects add-iam-policy-binding"))).toBe(false);
217
+ // The copy's source is the service the proxy prefix finds — the old gateway.
218
+ expect(calls).toContain(
219
+ "run services list --region europe-west1 --project acme --filter metadata.name~^caelo-production-gateway --format=value(metadata.name)",
220
+ );
221
+ expect(JSON.parse(written[0] as string).metadata.name).toBe("caelo-production-data-gateway");
222
+ expect(calls).toContain(
223
+ "run services replace /tmp/spec.json --region=europe-west1 --project=acme --quiet",
224
+ );
225
+ expect(cleaned).toBe(true);
226
+ // No new image is deployed: the copy runs what serves today.
227
+ expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
228
+ });
229
+
230
+ it("gcp-firebase: a data gateway that exists already is left alone", async () => {
231
+ const { run, calls } = fakeGcloud({
232
+ [`iam service-accounts describe ${PROXY_SA}`]: ok(`${PROXY_SA}\n`),
233
+ "secrets add-iam-policy-binding": ok(),
234
+ "run services describe caelo-production-data-gateway": ok("caelo-production-data-gateway\n"),
235
+ });
236
+ const outcomes = await ensureReleaseServices(firebase, { run });
237
+ expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
238
+ expect(calls.some((c) => c.includes("replace") || c.includes("create"))).toBe(false);
239
+ });
240
+
241
+ it("gcp-firebase: refuses to copy when the old service already runs the proxy image", async () => {
242
+ const proxy = structuredClone(OLD_GATEWAY);
243
+ (proxy.spec.template.spec.containers[0] as { image: string }).image =
244
+ `${IMAGE}/mcp-auth-proxy@sha256:${"c".repeat(64)}`;
245
+ const { run, calls } = fakeGcloud({
246
+ ...accountAndGrants,
247
+ "run services describe caelo-production-data-gateway": fail("NOT_FOUND: could not be found"),
248
+ "run services list": ok("caelo-production-gateway-bbb\n"),
249
+ "run services describe caelo-production-gateway-bbb": ok(JSON.stringify(proxy)),
250
+ });
251
+ const outcomes = await ensureReleaseServices(firebase, { run });
252
+ const last = outcomes.at(-1);
253
+ expect(last?.status).toBe("failed");
254
+ expect(last?.error).toContain("Re-run the installer");
255
+ expect(calls.some((c) => c.startsWith("run services replace"))).toBe(false);
256
+ });
257
+
258
+ it("gcp: deploys the proxy service with the release's image when it is missing", async () => {
259
+ const { run, calls } = fakeGcloud({
260
+ ...accountAndGrants,
261
+ "run services describe caelo-production-mcp-auth-proxy": fail(
262
+ "NOT_FOUND: could not be found",
263
+ ),
264
+ "run deploy caelo-production-mcp-auth-proxy": ok(),
265
+ });
266
+ const outcomes = await ensureReleaseServices(gcp, { run });
267
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "applied"]);
268
+ expect(calls).toContain(gcpProxyDeployArgs(gcp).join(" "));
269
+ });
270
+
271
+ it("gcp: an existing proxy service is not deployed again", async () => {
272
+ const { run, calls } = fakeGcloud({
273
+ [`iam service-accounts describe ${PROXY_SA}`]: ok(`${PROXY_SA}\n`),
274
+ "secrets add-iam-policy-binding": ok(),
275
+ "run services describe caelo-production-mcp-auth-proxy": ok(
276
+ "caelo-production-mcp-auth-proxy\n",
277
+ ),
278
+ });
279
+ const outcomes = await ensureReleaseServices(gcp, { run });
280
+ expect(outcomes.at(-1)?.status).toBe("present");
281
+ expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
282
+ });
283
+
284
+ it("stops before any service when a grant fails (Cloud Run would refuse the revision)", async () => {
285
+ const { run, calls } = fakeGcloud({
286
+ ...accountAndGrants,
287
+ "secrets add-iam-policy-binding caelo-production-operator-oauth-client": fail(
288
+ "PERMISSION_DENIED: secretmanager.secrets.setIamPolicy",
289
+ ),
290
+ });
291
+ const outcomes = await ensureReleaseServices(gcp, { run });
292
+ expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "failed"]);
293
+ expect(calls.some((c) => c.startsWith("run "))).toBe(false);
294
+ });
295
+
296
+ it("stops at once when the account cannot be read", async () => {
297
+ const { run, calls } = fakeGcloud({
298
+ [`iam service-accounts describe ${PROXY_SA}`]: fail("PERMISSION_DENIED"),
299
+ });
300
+ const outcomes = await ensureReleaseServices(firebase, { run });
301
+ expect(outcomes).toHaveLength(1);
302
+ expect(outcomes[0]?.status).toBe("failed");
303
+ expect(calls).toHaveLength(1);
304
+ });
305
+ });