@caelo-cms/provisioning 0.11.9 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/dist/admin-domain.d.ts +10 -1
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +13 -3
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/adopt-existing.d.ts +6 -1
  6. package/dist/adopt-existing.d.ts.map +1 -1
  7. package/dist/adopt-existing.js +86 -0
  8. package/dist/adopt-existing.js.map +1 -1
  9. package/dist/agent/command.d.ts +20 -0
  10. package/dist/agent/command.d.ts.map +1 -1
  11. package/dist/agent/command.js +32 -9
  12. package/dist/agent/command.js.map +1 -1
  13. package/dist/agent/run.d.ts +2 -0
  14. package/dist/agent/run.d.ts.map +1 -1
  15. package/dist/agent/run.js +5 -1
  16. package/dist/agent/run.js.map +1 -1
  17. package/dist/agent-db.js +44 -2
  18. package/dist/aws-task-roles.d.ts +4 -2
  19. package/dist/aws-task-roles.d.ts.map +1 -1
  20. package/dist/aws-task-roles.js +4 -1
  21. package/dist/aws-task-roles.js.map +1 -1
  22. package/dist/caddy.d.ts +21 -4
  23. package/dist/caddy.d.ts.map +1 -1
  24. package/dist/caddy.js +27 -3
  25. package/dist/caddy.js.map +1 -1
  26. package/dist/cli.js +12 -3
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloudsql-tls.d.ts +63 -0
  29. package/dist/cloudsql-tls.d.ts.map +1 -0
  30. package/dist/cloudsql-tls.js +289 -0
  31. package/dist/cloudsql-tls.js.map +1 -0
  32. package/dist/compose.d.ts +29 -6
  33. package/dist/compose.d.ts.map +1 -1
  34. package/dist/compose.js +95 -23
  35. package/dist/compose.js.map +1 -1
  36. package/dist/converge-checkpoint.d.ts +1 -0
  37. package/dist/converge-checkpoint.d.ts.map +1 -1
  38. package/dist/converge-checkpoint.js +8 -1
  39. package/dist/converge-checkpoint.js.map +1 -1
  40. package/dist/converge-job-gcp.d.ts.map +1 -1
  41. package/dist/converge-job-gcp.js +2 -11
  42. package/dist/converge-job-gcp.js.map +1 -1
  43. package/dist/deployed-release.d.ts +4 -1
  44. package/dist/deployed-release.d.ts.map +1 -1
  45. package/dist/deployed-release.js +18 -8
  46. package/dist/deployed-release.js.map +1 -1
  47. package/dist/gcp-names.d.ts +21 -0
  48. package/dist/gcp-names.d.ts.map +1 -1
  49. package/dist/gcp-names.js +24 -0
  50. package/dist/gcp-names.js.map +1 -1
  51. package/dist/gcp-url-map.d.ts.map +1 -1
  52. package/dist/gcp-url-map.js +6 -2
  53. package/dist/gcp-url-map.js.map +1 -1
  54. package/dist/index.d.ts +1 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/infra-contract.d.ts +1 -1
  59. package/dist/infra-contract.d.ts.map +1 -1
  60. package/dist/infra-contract.js +24 -1
  61. package/dist/infra-contract.js.map +1 -1
  62. package/dist/install-state.d.ts +6 -0
  63. package/dist/install-state.d.ts.map +1 -1
  64. package/dist/install-state.js +17 -4
  65. package/dist/install-state.js.map +1 -1
  66. package/dist/lifecycle.d.ts.map +1 -1
  67. package/dist/lifecycle.js +17 -8
  68. package/dist/lifecycle.js.map +1 -1
  69. package/dist/mcp-auth-proxy-setup.d.ts +48 -0
  70. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -0
  71. package/dist/mcp-auth-proxy-setup.js +280 -0
  72. package/dist/mcp-auth-proxy-setup.js.map +1 -0
  73. package/dist/release-manifest.d.ts +25 -1
  74. package/dist/release-manifest.d.ts.map +1 -1
  75. package/dist/release-manifest.js +36 -3
  76. package/dist/release-manifest.js.map +1 -1
  77. package/dist/remote-mcp.d.ts +55 -18
  78. package/dist/remote-mcp.d.ts.map +1 -1
  79. package/dist/remote-mcp.js +307 -77
  80. package/dist/remote-mcp.js.map +1 -1
  81. package/dist/runtime-secrets.d.ts +9 -0
  82. package/dist/runtime-secrets.d.ts.map +1 -1
  83. package/dist/runtime-secrets.js +21 -10
  84. package/dist/runtime-secrets.js.map +1 -1
  85. package/dist/seal-key-rotation.d.ts +7 -7
  86. package/dist/seal-key-rotation.d.ts.map +1 -1
  87. package/dist/seal-key-rotation.js +22 -22
  88. package/dist/seal-key-rotation.js.map +1 -1
  89. package/dist/self-hosted-db-tls.d.ts +37 -0
  90. package/dist/self-hosted-db-tls.d.ts.map +1 -0
  91. package/dist/self-hosted-db-tls.js +220 -0
  92. package/dist/self-hosted-db-tls.js.map +1 -0
  93. package/dist/self-hosted-release.d.ts +3 -1
  94. package/dist/self-hosted-release.d.ts.map +1 -1
  95. package/dist/self-hosted-release.js +33 -9
  96. package/dist/self-hosted-release.js.map +1 -1
  97. package/dist/self-hosted-roles.d.ts +34 -0
  98. package/dist/self-hosted-roles.d.ts.map +1 -1
  99. package/dist/self-hosted-roles.js +52 -0
  100. package/dist/self-hosted-roles.js.map +1 -1
  101. package/dist/self-hosted-start.d.ts +0 -21
  102. package/dist/self-hosted-start.d.ts.map +1 -1
  103. package/dist/self-hosted-start.js +39 -1
  104. package/dist/self-hosted-start.js.map +1 -1
  105. package/dist/self-hosted-upgrade.d.ts +15 -6
  106. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  107. package/dist/self-hosted-upgrade.js +72 -23
  108. package/dist/self-hosted-upgrade.js.map +1 -1
  109. package/dist/stack-contract.d.ts +170 -25
  110. package/dist/stack-contract.d.ts.map +1 -1
  111. package/dist/stack-contract.js +314 -37
  112. package/dist/stack-contract.js.map +1 -1
  113. package/dist/stack-converge.d.ts +31 -0
  114. package/dist/stack-converge.d.ts.map +1 -1
  115. package/dist/stack-converge.js +71 -30
  116. package/dist/stack-converge.js.map +1 -1
  117. package/dist/upgrade/app-roll.d.ts +25 -4
  118. package/dist/upgrade/app-roll.d.ts.map +1 -1
  119. package/dist/upgrade/app-roll.js +63 -17
  120. package/dist/upgrade/app-roll.js.map +1 -1
  121. package/dist/upgrade/converge.d.ts.map +1 -1
  122. package/dist/upgrade/converge.js +99 -12
  123. package/dist/upgrade/converge.js.map +1 -1
  124. package/dist/upgrade/health.d.ts +31 -7
  125. package/dist/upgrade/health.d.ts.map +1 -1
  126. package/dist/upgrade/health.js +41 -17
  127. package/dist/upgrade/health.js.map +1 -1
  128. package/dist/upgrade/image-forms.d.ts +1 -1
  129. package/dist/upgrade/image-forms.d.ts.map +1 -1
  130. package/dist/upgrade/image-forms.js +24 -7
  131. package/dist/upgrade/image-forms.js.map +1 -1
  132. package/dist/upgrade/release-images.d.ts +12 -3
  133. package/dist/upgrade/release-images.d.ts.map +1 -1
  134. package/dist/upgrade/release-images.js +13 -4
  135. package/dist/upgrade/release-images.js.map +1 -1
  136. package/dist/upgrade/upgrade.d.ts +13 -3
  137. package/dist/upgrade/upgrade.d.ts.map +1 -1
  138. package/dist/upgrade/upgrade.js +34 -2
  139. package/dist/upgrade/upgrade.js.map +1 -1
  140. package/dist/upgrader-gcp.d.ts +12 -0
  141. package/dist/upgrader-gcp.d.ts.map +1 -1
  142. package/dist/upgrader-gcp.js +49 -1
  143. package/dist/upgrader-gcp.js.map +1 -1
  144. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  145. package/dist/upgrader-iam-preflight.js +6 -1
  146. package/dist/upgrader-iam-preflight.js.map +1 -1
  147. package/dist/upgrader-job.d.ts +1 -1
  148. package/dist/upgrader-job.d.ts.map +1 -1
  149. package/dist/upgrader-job.js +20 -14
  150. package/dist/upgrader-job.js.map +1 -1
  151. package/dist/upgrader-records.d.ts +1 -1
  152. package/dist/upgrader-records.d.ts.map +1 -1
  153. package/dist/upgrader-records.js +26 -7
  154. package/dist/upgrader-records.js.map +1 -1
  155. package/dist/upgrader-setup.d.ts.map +1 -1
  156. package/dist/upgrader-setup.js +10 -5
  157. package/dist/upgrader-setup.js.map +1 -1
  158. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  159. package/dist/wizards/gcp-cost.js +22 -7
  160. package/dist/wizards/gcp-cost.js.map +1 -1
  161. package/dist/wizards/gcp.d.ts.map +1 -1
  162. package/dist/wizards/gcp.js +43 -17
  163. package/dist/wizards/gcp.js.map +1 -1
  164. package/package.json +2 -2
  165. package/src/admin-domain.test.ts +9 -8
  166. package/src/admin-domain.ts +13 -3
  167. package/src/admin-memory.test.ts +1 -0
  168. package/src/adopt-existing.test.ts +39 -0
  169. package/src/adopt-existing.ts +83 -1
  170. package/src/agent/command.test.ts +49 -1
  171. package/src/agent/command.ts +36 -9
  172. package/src/agent/run.test.ts +19 -0
  173. package/src/agent/run.ts +11 -2
  174. package/src/aws-task-roles.ts +5 -2
  175. package/src/caddy-redirects.docker.test.ts +2 -0
  176. package/src/caddy-redirects.test.ts +3 -0
  177. package/src/caddy-validate.test.ts +7 -1
  178. package/src/caddy.test.ts +39 -1
  179. package/src/caddy.ts +36 -4
  180. package/src/checkpoint-secrets.test.ts +14 -6
  181. package/src/cli.ts +12 -1
  182. package/src/cloudsql-tls.test.ts +225 -0
  183. package/src/cloudsql-tls.ts +352 -0
  184. package/src/compose.ts +125 -24
  185. package/src/converge-checkpoint.ts +8 -1
  186. package/src/converge-job-gcp.ts +2 -11
  187. package/src/database-tls-builders.test.ts +55 -0
  188. package/src/deployed-release.test.ts +104 -40
  189. package/src/deployed-release.ts +30 -9
  190. package/src/gateway-env-isolation.test.ts +10 -5
  191. package/src/gcp-names.ts +31 -0
  192. package/src/gcp-url-map.test.ts +6 -8
  193. package/src/gcp-url-map.ts +6 -2
  194. package/src/index.test.ts +7 -1
  195. package/src/index.ts +1 -0
  196. package/src/infra-contract.test.ts +5 -3
  197. package/src/infra-contract.ts +24 -1
  198. package/src/install-state.ts +26 -4
  199. package/src/lifecycle.ts +20 -11
  200. package/src/mcp-auth-proxy-setup.test.ts +305 -0
  201. package/src/mcp-auth-proxy-setup.ts +374 -0
  202. package/src/mcp-iap-stacks.test.ts +1 -1
  203. package/src/media-volume.test.ts +1 -1
  204. package/src/operator-access-stacks.test.ts +1 -1
  205. package/src/operator-access.test.ts +5 -4
  206. package/src/pool-budget-stacks.test.ts +1 -1
  207. package/src/release-manifest.test.ts +25 -0
  208. package/src/release-manifest.ts +59 -4
  209. package/src/remote-mcp-stack-config.test.ts +19 -9
  210. package/src/remote-mcp.test.ts +252 -57
  211. package/src/remote-mcp.ts +336 -77
  212. package/src/runtime-secrets.test.ts +20 -7
  213. package/src/runtime-secrets.ts +50 -9
  214. package/src/seal-key-rotation.test.ts +2 -2
  215. package/src/seal-key-rotation.ts +25 -25
  216. package/src/self-hosted-compose.test.ts +108 -5
  217. package/src/self-hosted-db-tls.test.ts +111 -0
  218. package/src/self-hosted-db-tls.ts +287 -0
  219. package/src/self-hosted-release-resolve.test.ts +71 -0
  220. package/src/self-hosted-release.ts +41 -11
  221. package/src/self-hosted-roles.test.ts +54 -0
  222. package/src/self-hosted-roles.ts +58 -0
  223. package/src/self-hosted-start.test.ts +34 -2
  224. package/src/self-hosted-start.ts +20 -2
  225. package/src/self-hosted-upgrade.test.ts +103 -14
  226. package/src/self-hosted-upgrade.ts +115 -29
  227. package/src/stack-contract-parity.test.ts +79 -12
  228. package/src/stack-contract.test.ts +169 -29
  229. package/src/stack-contract.ts +362 -42
  230. package/src/stack-converge-secrets.test.ts +26 -13
  231. package/src/stack-converge.test.ts +199 -26
  232. package/src/stack-converge.ts +92 -30
  233. package/src/state-store.test.ts +2 -1
  234. package/src/upgrade/app-roll.ts +97 -19
  235. package/src/upgrade/converge.ts +120 -11
  236. package/src/upgrade/health.test.ts +63 -25
  237. package/src/upgrade/health.ts +54 -22
  238. package/src/upgrade/image-forms.test.ts +34 -0
  239. package/src/upgrade/image-forms.ts +23 -8
  240. package/src/upgrade/release-images.test.ts +5 -1
  241. package/src/upgrade/release-images.ts +22 -6
  242. package/src/upgrade/upgrade.test.ts +353 -44
  243. package/src/upgrade/upgrade.ts +55 -6
  244. package/src/upgrader-gcp.ts +65 -3
  245. package/src/upgrader-iam-preflight.test.ts +19 -0
  246. package/src/upgrader-iam-preflight.ts +6 -0
  247. package/src/upgrader-job.test.ts +101 -27
  248. package/src/upgrader-job.ts +27 -14
  249. package/src/upgrader-records.test.ts +97 -12
  250. package/src/upgrader-records.ts +28 -11
  251. package/src/upgrader-setup.test.ts +2 -0
  252. package/src/upgrader-setup.ts +10 -4
  253. package/src/wizards/gcp-cost.test.ts +50 -0
  254. package/src/wizards/gcp-cost.ts +24 -7
  255. package/src/wizards/gcp.ts +55 -14
  256. package/stacks/aws/index.ts +32 -14
  257. package/stacks/azure/index.ts +57 -18
  258. package/stacks/gcp/index.ts +83 -23
  259. package/stacks/gcp-firebase/index.ts +162 -33
  260. package/stacks/self-hosted/README.md +2 -1
  261. package/stacks/self-hosted/index.ts +11 -1
@@ -0,0 +1,374 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #1328 — the Cloud Run services an upgrade to the MCP-Auth-Proxy release
5
+ * must find before it plans its rolls, created through gcloud on installs
6
+ * whose stack predates them (`cms-provision upgrade` and the converge job
7
+ * never run Pulumi). Run as the operator's or the provisioner's identity,
8
+ * before the upgrade's plan: each step only ADDS a service, account or grant
9
+ * that serves no traffic yet, so a failure leaves the running install as it
10
+ * was (converge release 1 of the hardening plan, #763, is additive).
11
+ *
12
+ * - Both providers: the proxy's own service account and its
13
+ * `secretAccessor` on exactly its two secrets — before any service runs
14
+ * as it, because Cloud Run checks a revision's secret access at deploy.
15
+ * - `gcp-firebase` (E13 a): the visitor gateway's new service
16
+ * `caelo-<env>-data-gateway`, a copy of the service that was the gateway
17
+ * (same image, env, secrets, VPC, account), so the app-roll can then roll
18
+ * it like any other service and turn the old one into the proxy. Copied,
19
+ * not built from the contract, so the health baseline sees the release
20
+ * that serves today on all three services.
21
+ * - `gcp`: the proxy's own service `caelo-<env>-mcp-auth-proxy`, deployed
22
+ * with the release's proxy image (there is no older proxy to copy). Its
23
+ * load-balancer part is remote-mcp.ts `ensureRemoteMcpEdge`, later in
24
+ * the converge.
25
+ *
26
+ * Fresh installs get the same from their stack (gcp-firebase) or from the
27
+ * wizard after the first `pulumi up` (gcp, like the jobs: CLI-owned).
28
+ */
29
+
30
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join } from "node:path";
33
+ import { gcloud as defaultGcloud } from "./gcloud.js";
34
+ import type { GcloudRunner } from "./gcloud-retry.js";
35
+ import { gcpSecretId, mcpAuthProxyServiceAccountEmail } from "./gcp-names.js";
36
+ import { type EnsureOutcome, ensureMcpAuthProxyServiceAccount } from "./runtime-secrets.js";
37
+ import {
38
+ type CloudRunEnvVar,
39
+ cloudRunServicePrefix,
40
+ DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES,
41
+ fixedCloudRunServiceName,
42
+ type GcpProvider,
43
+ MCP_AUTH_PROXY_TIMEOUT_SECONDS,
44
+ mcpAuthProxyEnvContract,
45
+ OPERATOR_OAUTH_CLIENT_FILE,
46
+ remoteMcpIdentity,
47
+ remoteMcpIdentityEnv,
48
+ secretFilePath,
49
+ serviceSecrets,
50
+ } from "./stack-contract.js";
51
+ import { listFlag } from "./stack-converge.js";
52
+ import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
53
+
54
+ /** The install the services are ensured on. */
55
+ export interface ReleaseServicesTarget {
56
+ readonly provider: GcpProvider;
57
+ readonly projectId: string;
58
+ readonly region: string;
59
+ /** Pulumi stack name (`production`). */
60
+ readonly env: string;
61
+ readonly domain: string;
62
+ /** The release's digest-pinned MCP-Auth-Proxy image (`…/mcp-auth-proxy@sha256:…`). */
63
+ readonly mcpAuthProxyImageRef: string;
64
+ }
65
+
66
+ export interface ReleaseServicesDeps {
67
+ readonly run?: GcloudRunner;
68
+ /** Writes a service spec for `gcloud run services replace`; returns its path and a cleanup. */
69
+ readonly writeSpecFile?: (json: string) => {
70
+ readonly path: string;
71
+ readonly cleanup: () => void;
72
+ };
73
+ }
74
+
75
+ const NOT_FOUND = /NOT_FOUND|not found|does not exist|could not be found/i;
76
+
77
+ /** Ensure everything {@link ReleaseServicesTarget} needs; outcomes in order, failures included. */
78
+ export async function ensureReleaseServices(
79
+ target: ReleaseServicesTarget,
80
+ deps: ReleaseServicesDeps = {},
81
+ ): Promise<EnsureOutcome[]> {
82
+ const run = deps.run ?? defaultGcloud;
83
+ const outcomes: EnsureOutcome[] = [];
84
+ const account = await ensureMcpAuthProxyServiceAccount(target, { run });
85
+ outcomes.push(account);
86
+ if (account.status === "failed") return outcomes;
87
+ outcomes.push(...(await ensureMcpAuthProxySecretAccess(target, run)));
88
+ if (outcomes.some((o) => o.status === "failed")) return outcomes;
89
+ outcomes.push(
90
+ target.provider === "gcp-firebase"
91
+ ? await ensureDataGatewayService(target, run, deps.writeSpecFile ?? writeTempSpec)
92
+ : await ensureMcpAuthProxyServiceGcp(target, run),
93
+ );
94
+ return outcomes;
95
+ }
96
+
97
+ /**
98
+ * `secretAccessor` for the proxy's account on each secret it reads
99
+ * (stack-contract.ts `serviceSecrets("mcp-auth-proxy")`) and nothing else.
100
+ * `add-iam-policy-binding` is idempotent; the stack invariants ensure the
101
+ * same bindings again later in the converge.
102
+ */
103
+ async function ensureMcpAuthProxySecretAccess(
104
+ target: Pick<ReleaseServicesTarget, "projectId" | "env">,
105
+ run: GcloudRunner,
106
+ ): Promise<EnsureOutcome[]> {
107
+ const member = `serviceAccount:${mcpAuthProxyServiceAccountEmail(target.projectId, target.env)}`;
108
+ const outcomes: EnsureOutcome[] = [];
109
+ for (const secret of serviceSecrets("mcp-auth-proxy")) {
110
+ const id = `secretAccessor → MCP-Auth-Proxy on ${secret}`;
111
+ const r = await run([
112
+ "secrets",
113
+ "add-iam-policy-binding",
114
+ gcpSecretId(target.env, secret),
115
+ `--project=${target.projectId}`,
116
+ `--member=${member}`,
117
+ "--role=roles/secretmanager.secretAccessor",
118
+ "--condition=None",
119
+ "--quiet",
120
+ ]);
121
+ outcomes.push(
122
+ r.ok ? { id, status: "present" } : { id, status: "failed", error: r.stderr.trim() },
123
+ );
124
+ }
125
+ return outcomes;
126
+ }
127
+
128
+ // ===========================================================================
129
+ // gcp-firebase: the data gateway
130
+ // ===========================================================================
131
+
132
+ /**
133
+ * The Knative service spec of `serviceJson` (`gcloud run services describe
134
+ * --format=json`) as a new service named `name`, for `gcloud run services
135
+ * replace`: the configuration only — image, env, secrets, volumes, VPC,
136
+ * scaling, account, ingress — without the source's identity (uid, revision
137
+ * names, status), its tool stamps or its traffic pins.
138
+ *
139
+ * @throws when the source is not a gateway image (a re-run must never copy
140
+ * the MCP-Auth-Proxy the old service became)
141
+ */
142
+ export function cloneServiceSpec(serviceJson: string, name: string): string {
143
+ const svc = JSON.parse(serviceJson) as {
144
+ apiVersion?: string;
145
+ kind?: string;
146
+ metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> };
147
+ spec?: {
148
+ template?: {
149
+ metadata?: { labels?: Record<string, string>; annotations?: Record<string, string> };
150
+ spec?: { containers?: { image?: string }[] } & Record<string, unknown>;
151
+ };
152
+ };
153
+ };
154
+ const image = svc.spec?.template?.spec?.containers?.[0]?.image ?? "";
155
+ if (!/\/gateway[@:]/.test(image)) {
156
+ throw new Error(
157
+ `the source service runs ${image || "no image"}, not a Caelo gateway image — the visitor gateway cannot be copied from it`,
158
+ );
159
+ }
160
+ const keepLabels = (labels: Record<string, string> | undefined) =>
161
+ Object.fromEntries(
162
+ Object.entries(labels ?? {}).filter(
163
+ ([k]) =>
164
+ !k.startsWith("cloud.googleapis.com/") &&
165
+ !k.startsWith("run.googleapis.com/") &&
166
+ !k.startsWith("client.knative.dev/") &&
167
+ !k.startsWith("serving.knative.dev/") &&
168
+ !k.startsWith("goog-"),
169
+ ),
170
+ );
171
+ // Service-level: only the settings; the rest are server stamps.
172
+ const KEEP_SERVICE_ANNOTATIONS = [
173
+ "run.googleapis.com/ingress",
174
+ "run.googleapis.com/launch-stage",
175
+ ];
176
+ const annotations = Object.fromEntries(
177
+ Object.entries(svc.metadata?.annotations ?? {}).filter(([k]) =>
178
+ KEEP_SERVICE_ANNOTATIONS.includes(k),
179
+ ),
180
+ );
181
+ const templateAnnotations = Object.fromEntries(
182
+ Object.entries(svc.spec?.template?.metadata?.annotations ?? {}).filter(
183
+ ([k]) => k !== "run.googleapis.com/client-name" && k !== "run.googleapis.com/client-version",
184
+ ),
185
+ );
186
+ return JSON.stringify({
187
+ apiVersion: svc.apiVersion ?? "serving.knative.dev/v1",
188
+ kind: svc.kind ?? "Service",
189
+ metadata: { name, labels: keepLabels(svc.metadata?.labels), annotations },
190
+ spec: {
191
+ template: {
192
+ metadata: {
193
+ labels: keepLabels(svc.spec?.template?.metadata?.labels),
194
+ annotations: templateAnnotations,
195
+ },
196
+ spec: svc.spec?.template?.spec ?? {},
197
+ },
198
+ traffic: [{ percent: 100, latestRevision: true }],
199
+ },
200
+ });
201
+ }
202
+
203
+ function writeTempSpec(json: string): { path: string; cleanup: () => void } {
204
+ const dir = mkdtempSync(join(tmpdir(), "caelo-service-"));
205
+ const path = join(dir, "service.json");
206
+ writeFileSync(path, json, { mode: 0o600 });
207
+ return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
208
+ }
209
+
210
+ /**
211
+ * The gcp-firebase data gateway (`caelo-<env>-data-gateway`): present, or
212
+ * created as a copy of the `caelo-<env>-gateway*` service. Its public invoker
213
+ * and the admin's `run.viewer` (the Firebase rewrite) follow from the stack
214
+ * invariants later in the converge.
215
+ */
216
+ async function ensureDataGatewayService(
217
+ target: ReleaseServicesTarget,
218
+ run: GcloudRunner,
219
+ writeSpecFile: NonNullable<ReleaseServicesDeps["writeSpecFile"]>,
220
+ ): Promise<EnsureOutcome> {
221
+ const name = fixedCloudRunServiceName(target.provider, target.env, "gateway") as string;
222
+ const id = `Cloud Run service ${name} (the visitor gateway)`;
223
+ const where = [`--region=${target.region}`, `--project=${target.projectId}`];
224
+ const existing = await run([
225
+ "run",
226
+ "services",
227
+ "describe",
228
+ name,
229
+ ...where,
230
+ "--format=value(metadata.name)",
231
+ ]);
232
+ if (existing.ok) return { id, status: "present" };
233
+ if (!NOT_FOUND.test(existing.stderr)) {
234
+ return { id, status: "failed", error: existing.stderr.trim() };
235
+ }
236
+ // The source: the service that was the gateway and becomes the proxy.
237
+ const sourcePrefix = cloudRunServicePrefix(target.provider, target.env, "mcp-auth-proxy");
238
+ const source = await resolveGcpResourceName(
239
+ run,
240
+ "run-service",
241
+ sourcePrefix,
242
+ target.projectId,
243
+ target.region,
244
+ );
245
+ if (source.status !== "found") {
246
+ return {
247
+ id,
248
+ status: "failed",
249
+ error: lookupFailure(source, `${sourcePrefix}* Cloud Run service`, "operator"),
250
+ };
251
+ }
252
+ const described = await run([
253
+ "run",
254
+ "services",
255
+ "describe",
256
+ source.name,
257
+ ...where,
258
+ "--format=json",
259
+ ]);
260
+ if (!described.ok) {
261
+ return { id, status: "failed", error: `describe ${source.name}: ${described.stderr.trim()}` };
262
+ }
263
+ let spec: string;
264
+ try {
265
+ spec = cloneServiceSpec(described.stdout, name);
266
+ } catch (e) {
267
+ return {
268
+ id,
269
+ status: "failed",
270
+ error: `${e instanceof Error ? e.message : String(e)}. Re-run the installer (it creates ${name} from the stack), then upgrade again.`,
271
+ };
272
+ }
273
+ const file = writeSpecFile(spec);
274
+ try {
275
+ const replaced = await run(["run", "services", "replace", file.path, ...where, "--quiet"]);
276
+ return replaced.ok
277
+ ? { id, status: "applied", detail: `copied from ${source.name}` }
278
+ : { id, status: "failed", error: replaced.stderr.trim() };
279
+ } finally {
280
+ file.cleanup();
281
+ }
282
+ }
283
+
284
+ // ===========================================================================
285
+ // gcp: the proxy's service
286
+ // ===========================================================================
287
+
288
+ /**
289
+ * The full env the `gcp` proxy is deployed with: its contract plus the
290
+ * remote-MCP identity it can derive without a lookup (its public origin is
291
+ * the site's host, the admin's is `admin.<domain>`). The IAP audience is
292
+ * the admin's alone.
293
+ */
294
+ function gcpProxyEnv(target: Pick<ReleaseServicesTarget, "env" | "domain">): CloudRunEnvVar[] {
295
+ const identity = remoteMcpIdentityEnv(
296
+ remoteMcpIdentity({
297
+ provider: "gcp",
298
+ domain: target.domain,
299
+ // Only the admin's audience uses these; the proxy's env does not.
300
+ projectNumber: "0",
301
+ adminBackendServiceId: "0",
302
+ }),
303
+ )["mcp-auth-proxy"];
304
+ return [...mcpAuthProxyEnvContract({ provider: "gcp", env: target.env }), ...identity];
305
+ }
306
+
307
+ /**
308
+ * The `gcloud run deploy` argv of the `gcp` proxy: load-balancer-only
309
+ * ingress (its two hops, stack-contract.ts MCP_AUTH_PROXY_TRUSTED_PROXY_HOPS),
310
+ * invokable without a Google identity (MCP clients have none), request-based
311
+ * CPU, no VPC, and its Google sign-in client file as a secret volume.
312
+ */
313
+ export function gcpProxyDeployArgs(target: ReleaseServicesTarget): string[] {
314
+ const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
315
+ const env = gcpProxyEnv(target);
316
+ const plain = env.flatMap((e) => ("value" in e ? [`${e.name}=${e.value}`] : []));
317
+ const secrets = [
318
+ ...env.flatMap((e) =>
319
+ "valueSource" in e
320
+ ? [`${e.name}=${e.valueSource.secretKeyRef.secret}:${e.valueSource.secretKeyRef.version}`]
321
+ : [],
322
+ ),
323
+ `${secretFilePath(OPERATOR_OAUTH_CLIENT_FILE)}=${gcpSecretId(target.env, OPERATOR_OAUTH_CLIENT_FILE.secret)}:${OPERATOR_OAUTH_CLIENT_FILE.version}`,
324
+ ];
325
+ return [
326
+ "run",
327
+ "deploy",
328
+ name,
329
+ `--image=${target.mcpAuthProxyImageRef}`,
330
+ `--region=${target.region}`,
331
+ `--project=${target.projectId}`,
332
+ `--service-account=${mcpAuthProxyServiceAccountEmail(target.projectId, target.env)}`,
333
+ "--ingress=internal-and-cloud-load-balancing",
334
+ "--allow-unauthenticated",
335
+ "--min-instances=0",
336
+ `--max-instances=${DEFAULT_MCP_AUTH_PROXY_MAX_INSTANCES}`,
337
+ "--cpu=1",
338
+ "--memory=512Mi",
339
+ "--cpu-throttling",
340
+ `--timeout=${MCP_AUTH_PROXY_TIMEOUT_SECONDS}`,
341
+ listFlag("--set-env-vars", plain),
342
+ listFlag("--set-secrets", secrets),
343
+ "--quiet",
344
+ ];
345
+ }
346
+
347
+ /**
348
+ * The `gcp` proxy service: present, or deployed. Deployed once; the
349
+ * app-roll moves it onto each release's image after that.
350
+ */
351
+ export async function ensureMcpAuthProxyServiceGcp(
352
+ target: ReleaseServicesTarget,
353
+ run: GcloudRunner,
354
+ ): Promise<EnsureOutcome> {
355
+ const name = fixedCloudRunServiceName("gcp", target.env, "mcp-auth-proxy") as string;
356
+ const id = `Cloud Run service ${name} (the MCP-Auth-Proxy)`;
357
+ const existing = await run([
358
+ "run",
359
+ "services",
360
+ "describe",
361
+ name,
362
+ `--region=${target.region}`,
363
+ `--project=${target.projectId}`,
364
+ "--format=value(metadata.name)",
365
+ ]);
366
+ if (existing.ok) return { id, status: "present" };
367
+ if (!NOT_FOUND.test(existing.stderr)) {
368
+ return { id, status: "failed", error: existing.stderr.trim() };
369
+ }
370
+ const deployed = await run(gcpProxyDeployArgs(target));
371
+ return deployed.ok
372
+ ? { id, status: "applied" }
373
+ : { id, status: "failed", error: deployed.stderr.trim() };
374
+ }
@@ -48,7 +48,7 @@ describe.each(["gcp", "gcp-firebase"])("%s stack — MCP through IAP", (provider
48
48
  env: "production",
49
49
  domain: "d.com",
50
50
  region: "r",
51
- databaseUrls: databaseUrls("10.0.0.3"),
51
+ databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
52
52
  });
53
53
  expect(env).toContainEqual({
54
54
  name: "CAELO_MCP_IAP_SERVICE_ACCOUNT",
@@ -152,7 +152,7 @@ describe("admin media volume contract", () => {
152
152
  domain: "acme.com",
153
153
  stagingHost: "staging.acme.com",
154
154
  region: "europe-west1",
155
- databaseUrls: databaseUrls("10.0.0.3"),
155
+ databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
156
156
  });
157
157
  expect(env).toContainEqual({ name: "MEDIA_ROOT_DIR", value: MEDIA_MOUNT_PATH });
158
158
  });
@@ -45,7 +45,7 @@ describe("admin env contract — operator access", () => {
45
45
  domain: "acme.com",
46
46
  stagingHost: "staging.acme.com",
47
47
  region: "europe-west1",
48
- databaseUrls: databaseUrls("10.0.0.3"),
48
+ databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
49
49
  });
50
50
  expect(env).toContainEqual({
51
51
  name: "CAELO_OPERATOR_ACCESS_JOB",
@@ -51,7 +51,7 @@ const target: OperatorAccessSyncTarget = {
51
51
  imageRef: "europe-west1-docker.pkg.dev/r/admin@sha256:new",
52
52
  network: "net",
53
53
  subnet: "sub",
54
- databaseHost: "10.0.0.3",
54
+ databaseHost: "db1.p-label.europe-west1.sql-psa.goog",
55
55
  };
56
56
 
57
57
  const roleJson = JSON.stringify({ includedPermissions: [...OPERATOR_ACCESS_ROLE.permissions] });
@@ -201,7 +201,7 @@ describe("ensureOperatorAccessSync", () => {
201
201
  "--args=--bun,/app/packages/admin-core/src/security/operator-access/sync-job.ts",
202
202
  );
203
203
  expect(deploy).toContain(
204
- "--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=10.0.0.3|CAELO_OPERATOR_OAUTH_CLIENT_SECRET=projects/p/secrets/caelo-production-operator-oauth-client",
204
+ "--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=db1.p-label.europe-west1.sql-psa.goog|CAELO_OPERATOR_OAUTH_CLIENT_SECRET=projects/p/secrets/caelo-production-operator-oauth-client",
205
205
  );
206
206
  });
207
207
 
@@ -322,7 +322,8 @@ describe("resolveOperatorAccessTarget", () => {
322
322
  env: [
323
323
  {
324
324
  name: "ADMIN_DATABASE_URL",
325
- value: "postgresql://admin_role@10.0.0.3:5432/cms_admin",
325
+ value:
326
+ "postgresql://admin_role@db1.p-label.europe-west1.sql-psa.goog:5432/cms_admin",
326
327
  },
327
328
  ],
328
329
  },
@@ -351,7 +352,7 @@ describe("resolveOperatorAccessTarget", () => {
351
352
  imageRef: "img@sha256:new",
352
353
  network: "net",
353
354
  subnet: "sub",
354
- databaseHost: "10.0.0.3",
355
+ databaseHost: "db1.p-label.europe-west1.sql-psa.goog",
355
356
  },
356
357
  });
357
358
  });
@@ -63,7 +63,7 @@ describe("stack pool budgets at the default config", () => {
63
63
  adminMaxInstances: 5,
64
64
  gatewayMaxInstances: 100,
65
65
  }),
66
- ).toThrow(/Raise maxConnections/);
66
+ ).toThrow(/CONNECTION LIMIT, #1338/);
67
67
  });
68
68
  });
69
69
 
@@ -240,6 +240,23 @@ describe("ReleaseManifestSchema", () => {
240
240
  const partial = { ...MANIFEST, platformDigests: { admin: d("1"), gateway: d("2") } };
241
241
  expect(ReleaseManifestSchema.safeParse(partial).success).toBe(false);
242
242
  });
243
+
244
+ it("#1328: mcp-auth-proxy is optional (older signed manifests lack it) and checked when present", () => {
245
+ // The canonical fixture is a pre-#1328 manifest: it names no proxy image.
246
+ expect("mcp-auth-proxy" in MANIFEST.digests).toBe(false);
247
+ const digests = { ...MANIFEST.digests, "mcp-auth-proxy": d("4") };
248
+ const platformDigests = {
249
+ admin: d("1"),
250
+ gateway: d("2"),
251
+ ops: d("3"),
252
+ "mcp-auth-proxy": d("5"),
253
+ };
254
+ const parsed = ReleaseManifestSchema.safeParse({ ...MANIFEST, digests, platformDigests });
255
+ expect(parsed.success && parsed.data.digests["mcp-auth-proxy"]).toBe(d("4"));
256
+ expect(parsed.success && parsed.data.platformDigests?.["mcp-auth-proxy"]).toBe(d("5"));
257
+ const bad = { ...MANIFEST, digests: { ...MANIFEST.digests, "mcp-auth-proxy": "sha256:nope" } };
258
+ expect(ReleaseManifestSchema.safeParse(bad).success).toBe(false);
259
+ });
243
260
  });
244
261
 
245
262
  describe("compareImageDigests", () => {
@@ -270,6 +287,14 @@ describe("compareImageDigests", () => {
270
287
  { image: "admin", form: "platform", expected: null, actual: d("1") },
271
288
  ]);
272
289
  });
290
+
291
+ it("#1328: a resolved image the manifest does not sign (a pre-proxy release) never matches", () => {
292
+ expect(compareImageDigests(MANIFEST, { "mcp-auth-proxy": d("4") })).toEqual([
293
+ { image: "mcp-auth-proxy", form: "index", expected: null, actual: d("4") },
294
+ ]);
295
+ const signed = { ...MANIFEST, digests: { ...MANIFEST.digests, "mcp-auth-proxy": d("4") } };
296
+ expect(compareImageDigests(signed, { "mcp-auth-proxy": d("4") })).toEqual([]);
297
+ });
273
298
  });
274
299
 
275
300
  describe("assessAutoInstall", () => {
@@ -35,7 +35,11 @@ import {
35
35
  type ReleaseManifest,
36
36
  } from "@caelo-cms/shared";
37
37
  import { infraStepsText } from "./infra-contract.js";
38
- import { RELEASE_OIDC_ISSUER, RELEASE_WORKFLOWS_URL } from "./upgrade/release-images.js";
38
+ import {
39
+ RELEASE_OIDC_ISSUER,
40
+ RELEASE_WORKFLOWS_URL,
41
+ releaseImageRef,
42
+ } from "./upgrade/release-images.js";
39
43
 
40
44
  // The manifest's shape and location live in @caelo-cms/shared (the admin's
41
45
  // auto-update scheduler reads them too); re-exported so this module stays
@@ -223,11 +227,58 @@ export interface DigestMismatch {
223
227
  readonly image: ReleaseImage;
224
228
  /** Which digest differs: the image index, or its linux/amd64 manifest (#1025). */
225
229
  readonly form: "index" | "platform";
226
- /** The manifest's digest; null when the manifest has no platform digests. */
230
+ /**
231
+ * The manifest's digest; null when the manifest names none for this form
232
+ * (no platform digests, or an image the release predates —
233
+ * OPTIONAL_RELEASE_IMAGES).
234
+ */
227
235
  readonly expected: string | null;
228
236
  readonly actual: string | null;
229
237
  }
230
238
 
239
+ /** One service's digest-pinned image in the public mirror. */
240
+ export interface ManifestServiceImage {
241
+ readonly digest: string;
242
+ readonly imageRef: string;
243
+ }
244
+
245
+ /**
246
+ * The images the app-roll moves the Cloud Run services onto, from the signed
247
+ * manifest's digests — never a registry tag lookup.
248
+ *
249
+ * Fails for a manifest without an MCP-Auth-Proxy digest: such a release
250
+ * predates #1328, and this code rolls three services, so installing it
251
+ * would leave the proxy on an image of another release.
252
+ */
253
+ export function manifestServiceImages(m: Pick<ReleaseManifest, "version" | "digests">):
254
+ | {
255
+ readonly ok: true;
256
+ readonly images: Readonly<
257
+ Record<"admin" | "gateway" | "mcp-auth-proxy", ManifestServiceImage>
258
+ >;
259
+ }
260
+ | { readonly ok: false; readonly error: string } {
261
+ const proxy = m.digests["mcp-auth-proxy"];
262
+ if (!proxy) {
263
+ return {
264
+ ok: false,
265
+ error: `the signed manifest of ${m.version} names no mcp-auth-proxy image: that release predates the MCP-Auth-Proxy (#1328) and cannot be installed by this release's upgrader. Install it from a terminal with the version's own CLI (bunx @caelo-cms/provisioning@${m.version} upgrade).`,
266
+ };
267
+ }
268
+ const image = (slug: string, digest: string) => ({
269
+ digest,
270
+ imageRef: releaseImageRef(slug, digest),
271
+ });
272
+ return {
273
+ ok: true,
274
+ images: {
275
+ admin: image("admin", m.digests.admin),
276
+ gateway: image("gateway", m.digests.gateway),
277
+ "mcp-auth-proxy": image("mcp-auth-proxy", proxy),
278
+ },
279
+ };
280
+ }
281
+
231
282
  /**
232
283
  * Compare the digests a registry resolved for the release's tags with the
233
284
  * signed manifest. Only images present in `resolved` (or `resolvedPlatform`)
@@ -246,9 +297,13 @@ export function compareImageDigests(
246
297
  const out: DigestMismatch[] = [];
247
298
  for (const image of RELEASE_IMAGES) {
248
299
  if (image in resolved) {
300
+ // An image the signed manifest does not name (a release from before
301
+ // the image existed) can never match: a caller that resolved it is
302
+ // asking about an image the signature does not cover.
249
303
  const actual = resolved[image] ?? null;
250
- if (actual !== manifest.digests[image]) {
251
- out.push({ image, form: "index", expected: manifest.digests[image], actual });
304
+ const expected = manifest.digests[image] ?? null;
305
+ if (actual === null || actual !== expected) {
306
+ out.push({ image, form: "index", expected, actual });
252
307
  }
253
308
  }
254
309
  if (image in resolvedPlatform) {
@@ -15,7 +15,7 @@ import {
15
15
  adminEnvContract,
16
16
  type CloudRunEnvVar,
17
17
  databaseUrls,
18
- gatewayEnvContract,
18
+ mcpAuthProxyEnvContract,
19
19
  parseRemoteMcpIdentityConfig,
20
20
  REMOTE_MCP_ENV,
21
21
  REMOTE_MCP_IDENTITY_CONFIG,
@@ -41,24 +41,28 @@ const contractInputs = {
41
41
  domain: "viu.one",
42
42
  region: "europe-west1",
43
43
  stagingHost: "staging.viu.one",
44
- databaseUrls: databaseUrls("10.153.0.3"),
44
+ databaseUrls: databaseUrls("db1.p-label.europe-west1.sql-psa.goog"),
45
45
  } as const;
46
46
  const contract = {
47
47
  admin: adminEnvContract({
48
48
  ...contractInputs,
49
49
  firebaseSiteId: "caelo-production-site-eb4697",
50
- gatewayService: "caelo-production-gateway-6da3bb0",
50
+ gatewayService: "caelo-production-data-gateway",
51
51
  }),
52
- gateway: gatewayEnvContract(contractInputs),
52
+ // #1328 — the broker's identity env is the MCP-Auth-Proxy's, the service
53
+ // that was the gateway (E13 a), so its URL is the old gateway's.
54
+ "mcp-auth-proxy": mcpAuthProxyEnvContract(contractInputs),
53
55
  };
54
56
 
57
+ type IdentitySlug = keyof typeof contract;
58
+
55
59
  /** The env a stack declares for one service, given the config value. */
56
- function declared(slug: "admin" | "gateway", config: string | undefined): CloudRunEnvVar[] {
60
+ function declared(slug: IdentitySlug, config: string | undefined): CloudRunEnvVar[] {
57
61
  return [...contract[slug], ...remoteMcpIdentityStackEnv(config)[slug]];
58
62
  }
59
63
 
60
64
  /** The live services after an installer run plus its old gcloud post-step: gcloud reorders. */
61
- function live(slug: "admin" | "gateway"): CloudRunEnvVar[] {
65
+ function live(slug: IdentitySlug): CloudRunEnvVar[] {
62
66
  return [...contract[slug], ...remoteMcpIdentityEnv(identity)[slug]].reverse();
63
67
  }
64
68
 
@@ -89,7 +93,11 @@ describe("remote-MCP identity as stack config (#1082)", () => {
89
93
 
90
94
  it("declares nothing while the config is unset (a fresh install's first up)", () => {
91
95
  expect(parseRemoteMcpIdentityConfig(undefined)).toBeNull();
92
- expect(remoteMcpIdentityStackEnv(undefined)).toEqual({ admin: [], gateway: [] });
96
+ expect(remoteMcpIdentityStackEnv(undefined)).toEqual({
97
+ admin: [],
98
+ gateway: [],
99
+ "mcp-auth-proxy": [],
100
+ });
93
101
  });
94
102
 
95
103
  it("refuses a corrupt or foreign value loudly instead of dropping the vars", () => {
@@ -111,9 +119,11 @@ describe("remote-MCP identity as stack config (#1082)", () => {
111
119
  .admin.map((v) => v.name)
112
120
  .sort(),
113
121
  ).toEqual(Object.values(REMOTE_MCP_ENV).sort());
122
+ // #1328 — the visitor gateway carries none of it.
123
+ expect(remoteMcpIdentityStackEnv(serializeRemoteMcpIdentity(identity)).gateway).toEqual([]);
114
124
  });
115
125
 
116
- it.each(["admin", "gateway"] as const)(
126
+ it.each(["admin", "mcp-auth-proxy"] as const)(
117
127
  "%s: with the config the CLI writes, a refreshed plan removes and changes nothing",
118
128
  (slug) => {
119
129
  const plan = envPlan(declared(slug, serializeRemoteMcpIdentity(identity)), live(slug));
@@ -128,7 +138,7 @@ describe("remote-MCP identity as stack config (#1082)", () => {
128
138
  "CAELO_IAP_AUDIENCE",
129
139
  "CAELO_MCP_PUBLIC_URL",
130
140
  ]);
131
- expect(envPlan(declared("gateway", undefined), live("gateway")).removed).toEqual([
141
+ expect(envPlan(declared("mcp-auth-proxy", undefined), live("mcp-auth-proxy")).removed).toEqual([
132
142
  "CAELO_ADMIN_URL",
133
143
  "CAELO_MCP_PUBLIC_URL",
134
144
  ]);