@caelo-cms/provisioning 0.10.55 → 0.10.58

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 (221) hide show
  1. package/dist/agent-db.js +97 -24
  2. package/dist/cli-routing.js +1 -1
  3. package/dist/cli-routing.js.map +1 -1
  4. package/dist/cli.js +4 -1
  5. package/dist/cli.js.map +1 -1
  6. package/dist/compose.js +5 -0
  7. package/dist/compose.js.map +1 -1
  8. package/dist/converge-checkpoint.d.ts +66 -0
  9. package/dist/converge-checkpoint.d.ts.map +1 -0
  10. package/dist/converge-checkpoint.js +61 -0
  11. package/dist/converge-checkpoint.js.map +1 -0
  12. package/dist/converge-job-gcp.d.ts +46 -0
  13. package/dist/converge-job-gcp.d.ts.map +1 -0
  14. package/dist/converge-job-gcp.js +368 -0
  15. package/dist/converge-job-gcp.js.map +1 -0
  16. package/dist/converge-job-setup.d.ts +62 -0
  17. package/dist/converge-job-setup.d.ts.map +1 -0
  18. package/dist/converge-job-setup.js +228 -0
  19. package/dist/converge-job-setup.js.map +1 -0
  20. package/dist/converge-job.d.ts +141 -0
  21. package/dist/converge-job.d.ts.map +1 -0
  22. package/dist/converge-job.js +325 -0
  23. package/dist/converge-job.js.map +1 -0
  24. package/dist/gcp-names.d.ts +9 -0
  25. package/dist/gcp-names.d.ts.map +1 -1
  26. package/dist/gcp-names.js +13 -0
  27. package/dist/gcp-names.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/infra-contract.d.ts +1 -1
  33. package/dist/infra-contract.d.ts.map +1 -1
  34. package/dist/infra-contract.js +33 -1
  35. package/dist/infra-contract.js.map +1 -1
  36. package/dist/install-job.d.ts +11 -3
  37. package/dist/install-job.d.ts.map +1 -1
  38. package/dist/install-job.js +22 -12
  39. package/dist/install-job.js.map +1 -1
  40. package/dist/lifecycle.d.ts +13 -1
  41. package/dist/lifecycle.d.ts.map +1 -1
  42. package/dist/lifecycle.js +115 -58
  43. package/dist/lifecycle.js.map +1 -1
  44. package/dist/operator-access.d.ts +4 -0
  45. package/dist/operator-access.d.ts.map +1 -1
  46. package/dist/operator-access.js +43 -7
  47. package/dist/operator-access.js.map +1 -1
  48. package/dist/release-contract.d.ts +3 -2
  49. package/dist/release-contract.d.ts.map +1 -1
  50. package/dist/release-contract.js +5 -2
  51. package/dist/release-contract.js.map +1 -1
  52. package/dist/release-manifest.d.ts +2 -0
  53. package/dist/release-manifest.d.ts.map +1 -1
  54. package/dist/release-manifest.js.map +1 -1
  55. package/dist/remote-mcp-edge.d.ts +114 -0
  56. package/dist/remote-mcp-edge.d.ts.map +1 -0
  57. package/dist/remote-mcp-edge.js +116 -0
  58. package/dist/remote-mcp-edge.js.map +1 -0
  59. package/dist/remote-mcp.d.ts +139 -0
  60. package/dist/remote-mcp.d.ts.map +1 -0
  61. package/dist/remote-mcp.js +434 -0
  62. package/dist/remote-mcp.js.map +1 -0
  63. package/dist/runtime-secrets.d.ts +12 -0
  64. package/dist/runtime-secrets.d.ts.map +1 -1
  65. package/dist/runtime-secrets.js +32 -4
  66. package/dist/runtime-secrets.js.map +1 -1
  67. package/dist/seal-key-rotation.d.ts +38 -0
  68. package/dist/seal-key-rotation.d.ts.map +1 -0
  69. package/dist/seal-key-rotation.js +206 -0
  70. package/dist/seal-key-rotation.js.map +1 -0
  71. package/dist/stack-contract.d.ts +206 -3
  72. package/dist/stack-contract.d.ts.map +1 -1
  73. package/dist/stack-contract.js +219 -4
  74. package/dist/stack-contract.js.map +1 -1
  75. package/dist/stack-converge.d.ts +53 -1
  76. package/dist/stack-converge.d.ts.map +1 -1
  77. package/dist/stack-converge.js +124 -4
  78. package/dist/stack-converge.js.map +1 -1
  79. package/dist/state-lock-lease.d.ts +142 -0
  80. package/dist/state-lock-lease.d.ts.map +1 -0
  81. package/dist/state-lock-lease.js +347 -0
  82. package/dist/state-lock-lease.js.map +1 -0
  83. package/dist/state-store.d.ts +66 -9
  84. package/dist/state-store.d.ts.map +1 -1
  85. package/dist/state-store.js +249 -30
  86. package/dist/state-store.js.map +1 -1
  87. package/dist/upgrade/app-roll.d.ts +36 -7
  88. package/dist/upgrade/app-roll.d.ts.map +1 -1
  89. package/dist/upgrade/app-roll.js +53 -19
  90. package/dist/upgrade/app-roll.js.map +1 -1
  91. package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
  92. package/dist/upgrade/audit-job-roll.js +3 -1
  93. package/dist/upgrade/audit-job-roll.js.map +1 -1
  94. package/dist/upgrade/converge.d.ts.map +1 -1
  95. package/dist/upgrade/converge.js +125 -12
  96. package/dist/upgrade/converge.js.map +1 -1
  97. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  98. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  99. package/dist/upgrade/gcp-lookup.js +46 -7
  100. package/dist/upgrade/gcp-lookup.js.map +1 -1
  101. package/dist/upgrade/record-rolled.d.ts +40 -0
  102. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  103. package/dist/upgrade/record-rolled.js +46 -0
  104. package/dist/upgrade/record-rolled.js.map +1 -0
  105. package/dist/upgrade/upgrade.d.ts +45 -1
  106. package/dist/upgrade/upgrade.d.ts.map +1 -1
  107. package/dist/upgrade/upgrade.js +60 -17
  108. package/dist/upgrade/upgrade.js.map +1 -1
  109. package/dist/upgrader-assess.d.ts +15 -1
  110. package/dist/upgrader-assess.d.ts.map +1 -1
  111. package/dist/upgrader-assess.js +36 -6
  112. package/dist/upgrader-assess.js.map +1 -1
  113. package/dist/upgrader-gcp.d.ts +15 -0
  114. package/dist/upgrader-gcp.d.ts.map +1 -1
  115. package/dist/upgrader-gcp.js +42 -7
  116. package/dist/upgrader-gcp.js.map +1 -1
  117. package/dist/upgrader-iam-preflight.d.ts +123 -0
  118. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  119. package/dist/upgrader-iam-preflight.js +414 -0
  120. package/dist/upgrader-iam-preflight.js.map +1 -0
  121. package/dist/upgrader-job.d.ts +33 -9
  122. package/dist/upgrader-job.d.ts.map +1 -1
  123. package/dist/upgrader-job.js +169 -27
  124. package/dist/upgrader-job.js.map +1 -1
  125. package/dist/upgrader-records.d.ts +58 -0
  126. package/dist/upgrader-records.d.ts.map +1 -0
  127. package/dist/upgrader-records.js +87 -0
  128. package/dist/upgrader-records.js.map +1 -0
  129. package/dist/upgrader-reporter.d.ts +3 -2
  130. package/dist/upgrader-reporter.d.ts.map +1 -1
  131. package/dist/upgrader-reporter.js +10 -2
  132. package/dist/upgrader-reporter.js.map +1 -1
  133. package/dist/upgrader-setup.d.ts +25 -3
  134. package/dist/upgrader-setup.d.ts.map +1 -1
  135. package/dist/upgrader-setup.js +123 -11
  136. package/dist/upgrader-setup.js.map +1 -1
  137. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  138. package/dist/wizards/gcp-cost.js +11 -3
  139. package/dist/wizards/gcp-cost.js.map +1 -1
  140. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  141. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  142. package/dist/wizards/gcp-pulumi.js +14 -2
  143. package/dist/wizards/gcp-pulumi.js.map +1 -1
  144. package/dist/wizards/gcp.d.ts.map +1 -1
  145. package/dist/wizards/gcp.js +106 -4
  146. package/dist/wizards/gcp.js.map +1 -1
  147. package/package.json +2 -2
  148. package/src/agent/run.test.ts +14 -1
  149. package/src/audit-job-setup.test.ts +7 -1
  150. package/src/cli-routing.ts +1 -1
  151. package/src/cli.ts +4 -1
  152. package/src/compose.ts +5 -0
  153. package/src/converge-checkpoint.ts +75 -0
  154. package/src/converge-entrypoint.test.ts +213 -0
  155. package/src/converge-job-gcp.test.ts +222 -0
  156. package/src/converge-job-gcp.ts +443 -0
  157. package/src/converge-job-setup.test.ts +152 -0
  158. package/src/converge-job-setup.ts +268 -0
  159. package/src/converge-job.test.ts +486 -0
  160. package/src/converge-job.ts +542 -0
  161. package/src/gateway-env-isolation.test.ts +8 -1
  162. package/src/gcp-names.ts +15 -0
  163. package/src/gcp-url-map.test.ts +9 -1
  164. package/src/index.ts +8 -0
  165. package/src/infra-contract.test.ts +8 -4
  166. package/src/infra-contract.ts +33 -1
  167. package/src/install-job.test.ts +3 -1
  168. package/src/install-job.ts +31 -14
  169. package/src/lifecycle.ts +137 -71
  170. package/src/operator-access.test.ts +24 -1
  171. package/src/operator-access.ts +44 -6
  172. package/src/release-contract.ts +5 -2
  173. package/src/release-manifest.test.ts +42 -0
  174. package/src/release-manifest.ts +2 -0
  175. package/src/remote-mcp-edge.ts +137 -0
  176. package/src/remote-mcp.test.ts +690 -0
  177. package/src/remote-mcp.ts +524 -0
  178. package/src/runtime-secrets.test.ts +31 -6
  179. package/src/runtime-secrets.ts +41 -3
  180. package/src/seal-key-rotation.test.ts +131 -0
  181. package/src/seal-key-rotation.ts +258 -0
  182. package/src/stack-contract-parity.test.ts +20 -3
  183. package/src/stack-contract.test.ts +32 -2
  184. package/src/stack-contract.ts +363 -4
  185. package/src/stack-converge-secrets.test.ts +4 -0
  186. package/src/stack-converge.test.ts +21 -5
  187. package/src/stack-converge.ts +162 -7
  188. package/src/state-attach.test.ts +7 -1
  189. package/src/state-lock-lease.test.ts +302 -0
  190. package/src/state-lock-lease.ts +423 -0
  191. package/src/state-lock.test.ts +568 -0
  192. package/src/state-store.ts +347 -27
  193. package/src/upgrade/app-roll.ts +102 -21
  194. package/src/upgrade/audit-job-roll.ts +3 -1
  195. package/src/upgrade/converge.ts +148 -10
  196. package/src/upgrade/gcp-lookup.test.ts +80 -0
  197. package/src/upgrade/gcp-lookup.ts +65 -8
  198. package/src/upgrade/record-rolled.test.ts +105 -0
  199. package/src/upgrade/record-rolled.ts +91 -0
  200. package/src/upgrade/upgrade.test.ts +203 -3
  201. package/src/upgrade/upgrade.ts +120 -24
  202. package/src/upgrader-assess.test.ts +92 -3
  203. package/src/upgrader-assess.ts +58 -9
  204. package/src/upgrader-gcp.ts +47 -8
  205. package/src/upgrader-iam-preflight.test.ts +393 -0
  206. package/src/upgrader-iam-preflight.ts +510 -0
  207. package/src/upgrader-job.test.ts +586 -15
  208. package/src/upgrader-job.ts +237 -53
  209. package/src/upgrader-records.ts +151 -0
  210. package/src/upgrader-reporter.test.ts +6 -0
  211. package/src/upgrader-reporter.ts +7 -2
  212. package/src/upgrader-setup.test.ts +3 -3
  213. package/src/upgrader-setup.ts +128 -11
  214. package/src/wizards/gcp-cost.ts +12 -3
  215. package/src/wizards/gcp-pulumi.test.ts +15 -0
  216. package/src/wizards/gcp-pulumi.ts +18 -2
  217. package/src/wizards/gcp.ts +134 -4
  218. package/stacks/aws/index.ts +10 -0
  219. package/stacks/azure/index.ts +9 -0
  220. package/stacks/gcp/index.ts +47 -7
  221. package/stacks/gcp-firebase/index.ts +38 -6
@@ -0,0 +1,524 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #806 — the CLI's part of remote MCP on a Google IAP install (`gcp`,
5
+ * `gcp-firebase`), run as the operator's (or the provisioner's) gcloud
6
+ * identity by the wizard after `pulumi up` and by `cms-provision upgrade`
7
+ * after the roll. Every step is idempotent.
8
+ *
9
+ * - {@link ensureRemoteMcpIdentityEnv}: the env vars that name generated
10
+ * identities ({@link RemoteMcpIdentity} — why the stacks cannot declare
11
+ * them is in stack-contract.ts), applied with `gcloud run services
12
+ * update` only where they differ.
13
+ * - {@link ensureRemoteMcpEdge} (`gcp` only): the load balancer's part for
14
+ * installs whose URL map / Cloud Armor policy predate it — the
15
+ * `/.well-known/oauth-*` path rules (the stack ignores `pathRules` after
16
+ * creation, gcp-url-map.ts) and the `/api/mcp/*` Cloud Armor rule
17
+ * (remote-mcp-edge.ts).
18
+ *
19
+ * The secrets, the IAM bindings, the gateway's secret volume and timeout
20
+ * come through the shared contract like everything else
21
+ * (runtime-secrets.ts, stack-contract.ts, upgrade/app-roll.ts).
22
+ *
23
+ * The same identity env carries what the admin's Google sign-in needs
24
+ * (#914): its own URL and the project's Google organisation domain
25
+ * ({@link resolveGoogleOrgDomain}).
26
+ *
27
+ * Nothing here enables remote MCP: that happens when the Owner sets up the
28
+ * install's Google sign-in client (Settings → Google sign-in). Until then
29
+ * the gateway answers 503 `remote_mcp_not_configured`.
30
+ */
31
+
32
+ import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
33
+ import { gcloud as defaultGcloud } from "./gcloud.js";
34
+ import type { GcloudRunner } from "./gcloud-retry.js";
35
+ import { gcpNamePrefix } from "./gcp-names.js";
36
+ import {
37
+ isMcpArmorRule,
38
+ type LiveArmorRule,
39
+ MCP_ARMOR_RULE,
40
+ mcpArmorRuleFlags,
41
+ } from "./remote-mcp-edge.js";
42
+ import type { HttpFetch } from "./runtime-secrets.js";
43
+ import {
44
+ type CloudRunSlug,
45
+ type GcpProvider,
46
+ type RemoteMcpIdentity,
47
+ remoteMcpIdentity,
48
+ remoteMcpIdentityEnv,
49
+ } from "./stack-contract.js";
50
+ import { type EnvChange, liveContainerEnv, planEnvUpdate } from "./stack-converge.js";
51
+
52
+ /** The install remote MCP is set up on. */
53
+ export interface RemoteMcpTarget {
54
+ readonly provider: GcpProvider;
55
+ readonly projectId: string;
56
+ readonly region: string;
57
+ /** Pulumi stack name (`production`). */
58
+ readonly env: string;
59
+ readonly domain: string;
60
+ /** Deployed (Pulumi-suffixed) Cloud Run service names. */
61
+ readonly services: Readonly<Record<CloudRunSlug, string>>;
62
+ }
63
+
64
+ export type RemoteMcpResult<T> =
65
+ | ({ readonly ok: true } & T)
66
+ | { readonly ok: false; readonly error: string };
67
+
68
+ /** Parse gcloud JSON output; null (never a throw) when it is not JSON. */
69
+ function parseJson(stdout: string): unknown {
70
+ try {
71
+ return JSON.parse(stdout);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function lines(stdout: string): string[] {
78
+ return stdout
79
+ .split("\n")
80
+ .map((s) => s.trim())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ // ===========================================================================
85
+ // Identity env
86
+ // ===========================================================================
87
+
88
+ /**
89
+ * #914 — the project's Google organisation domain (the organisation's
90
+ * `displayName`, which Cloud Resource Manager sets to its primary domain),
91
+ * or `none` when the project sits in no organisation. Read as the
92
+ * operator's identity: `organizations describe` needs
93
+ * `resourcemanager.organizations.get` on the organisation, which project
94
+ * Owners often have and the admin's service account never does.
95
+ */
96
+ export async function resolveGoogleOrgDomain(
97
+ projectId: string,
98
+ deps: { run?: GcloudRunner } = {},
99
+ ): Promise<RemoteMcpResult<{ readonly domain: string }>> {
100
+ const run = deps.run ?? defaultGcloud;
101
+ const ancestors = await run(["projects", "get-ancestors", projectId, "--format=json"]);
102
+ if (!ancestors.ok) {
103
+ return { ok: false, error: `read the ancestors of ${projectId}: ${ancestors.stderr.trim()}` };
104
+ }
105
+ const list = parseJson(ancestors.stdout || "[]");
106
+ if (!Array.isArray(list)) return { ok: false, error: "get-ancestors: output is not a JSON list" };
107
+ const org = (list as { id?: string; type?: string }[]).find((a) => a.type === "organization");
108
+ if (!org?.id) return { ok: true, domain: NO_GOOGLE_ORGANISATION };
109
+ const describe = await run(["organizations", "describe", org.id, "--format=value(displayName)"]);
110
+ const domain = describe.ok ? describe.stdout.trim().toLowerCase() : "";
111
+ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
112
+ return {
113
+ ok: false,
114
+ error: `read organization ${org.id}: ${describe.stderr.trim() || `unexpected name "${domain}"`} (needs the Organization Viewer role)`,
115
+ };
116
+ }
117
+ return { ok: true, domain };
118
+ }
119
+
120
+ /** `CAELO_GOOGLE_ORG_DOMAIN` value for a project outside any organisation (admin-core identity-gate.ts). */
121
+ export const NO_GOOGLE_ORGANISATION = "none";
122
+
123
+ /**
124
+ * Read the live identities {@link remoteMcpIdentity} derives from: the
125
+ * project number, and the admin's backend service id (`gcp`) or both
126
+ * services' URLs (`gcp-firebase`). Fails naming what it could not read.
127
+ *
128
+ * The organisation domain (#914) is best-effort: when it cannot be read,
129
+ * `orgDomainError` says why and the identity carries none, so the admin's
130
+ * var keeps its value — the admin then reports the organisation as unknown
131
+ * and refuses user creation loudly until the Owner sets up Google sign-in.
132
+ */
133
+ export async function resolveRemoteMcpIdentity(
134
+ target: RemoteMcpTarget,
135
+ deps: { run?: GcloudRunner } = {},
136
+ ): Promise<
137
+ RemoteMcpResult<{ readonly identity: RemoteMcpIdentity; readonly orgDomainError?: string }>
138
+ > {
139
+ const resolved = await resolveServiceIdentity(target, deps);
140
+ if (!resolved.ok) return resolved;
141
+ const org = await resolveGoogleOrgDomain(target.projectId, deps);
142
+ return org.ok
143
+ ? { ok: true, identity: { ...resolved.identity, googleOrgDomain: org.domain } }
144
+ : { ok: true, identity: resolved.identity, orgDomainError: org.error };
145
+ }
146
+
147
+ async function resolveServiceIdentity(
148
+ target: RemoteMcpTarget,
149
+ deps: { run?: GcloudRunner } = {},
150
+ ): Promise<RemoteMcpResult<{ readonly identity: RemoteMcpIdentity }>> {
151
+ const run = deps.run ?? defaultGcloud;
152
+ const project = `--project=${target.projectId}`;
153
+ const num = await run([
154
+ "projects",
155
+ "describe",
156
+ target.projectId,
157
+ "--format=value(projectNumber)",
158
+ ]);
159
+ const projectNumber = num.ok ? num.stdout.trim() : "";
160
+ if (!/^\d+$/.test(projectNumber)) {
161
+ return {
162
+ ok: false,
163
+ error: `read the project number of ${target.projectId}: ${num.stderr.trim() || "empty"}`,
164
+ };
165
+ }
166
+ if (target.provider === "gcp") {
167
+ const prefix = `${gcpNamePrefix(target.env)}-admin-backend`;
168
+ const list = await run([
169
+ "compute",
170
+ "backend-services",
171
+ "list",
172
+ "--global",
173
+ project,
174
+ `--filter=name~^${prefix} AND iap.enabled=true`,
175
+ "--format=value(id)",
176
+ ]);
177
+ if (!list.ok) return { ok: false, error: `list backend services: ${list.stderr.trim()}` };
178
+ const ids = lines(list.stdout);
179
+ if (ids.length !== 1 || !/^\d+$/.test(ids[0] ?? "")) {
180
+ return {
181
+ ok: false,
182
+ error: `expected exactly one IAP-enabled ${prefix}* backend service, found ${ids.length}`,
183
+ };
184
+ }
185
+ return {
186
+ ok: true,
187
+ identity: remoteMcpIdentity({
188
+ provider: "gcp",
189
+ domain: target.domain,
190
+ projectNumber,
191
+ adminBackendServiceId: ids[0] as string,
192
+ }),
193
+ };
194
+ }
195
+ const url = async (service: string) => {
196
+ const r = await run([
197
+ "run",
198
+ "services",
199
+ "describe",
200
+ service,
201
+ `--region=${target.region}`,
202
+ project,
203
+ "--format=value(status.url)",
204
+ ]);
205
+ const value = r.ok ? r.stdout.trim() : "";
206
+ return value.startsWith("https://") ? value : null;
207
+ };
208
+ const adminUrl = await url(target.services.admin);
209
+ const gatewayUrl = await url(target.services.gateway);
210
+ if (!adminUrl || !gatewayUrl) {
211
+ return {
212
+ ok: false,
213
+ error: `read the Cloud Run URL of the ${adminUrl ? "gateway" : "admin"} service (${adminUrl ? target.services.gateway : target.services.admin})`,
214
+ };
215
+ }
216
+ return {
217
+ ok: true,
218
+ identity: remoteMcpIdentity({
219
+ provider: "gcp-firebase",
220
+ projectNumber,
221
+ region: target.region,
222
+ adminServiceName: target.services.admin,
223
+ adminServiceUrl: adminUrl,
224
+ gatewayServiceUrl: gatewayUrl,
225
+ }),
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Bring both services' {@link RemoteMcpIdentity} env vars to `identity`
231
+ * with one `gcloud run services update` per service that differs (a new
232
+ * revision only then). Vars already right are left alone.
233
+ */
234
+ export async function applyRemoteMcpIdentityEnv(
235
+ target: RemoteMcpTarget,
236
+ identity: RemoteMcpIdentity,
237
+ deps: { run?: GcloudRunner } = {},
238
+ ): Promise<RemoteMcpResult<{ readonly changes: Record<CloudRunSlug, EnvChange[]> }>> {
239
+ const run = deps.run ?? defaultGcloud;
240
+ const desired = remoteMcpIdentityEnv(identity);
241
+ const changes: Record<CloudRunSlug, EnvChange[]> = { admin: [], gateway: [] };
242
+ for (const slug of ["admin", "gateway"] as const) {
243
+ const service = target.services[slug];
244
+ const describe = await run([
245
+ "run",
246
+ "services",
247
+ "describe",
248
+ service,
249
+ `--region=${target.region}`,
250
+ `--project=${target.projectId}`,
251
+ "--format=json",
252
+ ]);
253
+ if (!describe.ok) return { ok: false, error: `describe ${service}: ${describe.stderr.trim()}` };
254
+ if (parseJson(describe.stdout) === null) {
255
+ return { ok: false, error: `describe ${service}: output is not JSON` };
256
+ }
257
+ const plan = planEnvUpdate(liveContainerEnv(describe.stdout), desired[slug]);
258
+ if (!plan.ok) return { ok: false, error: `${slug}: ${plan.error}` };
259
+ if (plan.flags.length === 0) continue;
260
+ const update = await run([
261
+ "run",
262
+ "services",
263
+ "update",
264
+ service,
265
+ `--region=${target.region}`,
266
+ `--project=${target.projectId}`,
267
+ ...plan.flags,
268
+ "--quiet",
269
+ ]);
270
+ if (!update.ok) return { ok: false, error: `update ${service}: ${update.stderr.trim()}` };
271
+ changes[slug] = plan.changes;
272
+ }
273
+ return { ok: true, changes };
274
+ }
275
+
276
+ /** {@link resolveRemoteMcpIdentity} then {@link applyRemoteMcpIdentityEnv}. */
277
+ export async function ensureRemoteMcpIdentityEnv(
278
+ target: RemoteMcpTarget,
279
+ deps: { run?: GcloudRunner } = {},
280
+ ): Promise<
281
+ RemoteMcpResult<{
282
+ readonly identity: RemoteMcpIdentity;
283
+ readonly changes: Record<CloudRunSlug, EnvChange[]>;
284
+ readonly orgDomainError?: string;
285
+ }>
286
+ > {
287
+ const resolved = await resolveRemoteMcpIdentity(target, deps);
288
+ if (!resolved.ok) return resolved;
289
+ const applied = await applyRemoteMcpIdentityEnv(target, resolved.identity, deps);
290
+ if (!applied.ok) return applied;
291
+ return {
292
+ ok: true,
293
+ identity: resolved.identity,
294
+ changes: applied.changes,
295
+ ...(resolved.orgDomainError ? { orgDomainError: resolved.orgDomainError } : {}),
296
+ };
297
+ }
298
+
299
+ // ===========================================================================
300
+ // Load balancer (gcp)
301
+ // ===========================================================================
302
+
303
+ /** A path rule / matcher / URL map as the Compute API returns them (the parts used here). */
304
+ interface UrlMapPathRule {
305
+ readonly paths: string[];
306
+ readonly service?: string;
307
+ readonly urlRedirect?: unknown;
308
+ }
309
+ interface UrlMapPathMatcher {
310
+ readonly name: string;
311
+ readonly pathRules?: UrlMapPathRule[];
312
+ }
313
+ export interface LiveUrlMap {
314
+ readonly name: string;
315
+ readonly hostRules?: { readonly hosts: string[]; readonly pathMatcher: string }[];
316
+ readonly pathMatchers?: UrlMapPathMatcher[];
317
+ readonly [field: string]: unknown;
318
+ }
319
+
320
+ export type UrlMapPathsPlan =
321
+ | { readonly ok: true; readonly map: LiveUrlMap; readonly added: string[] }
322
+ | { readonly ok: false; readonly error: string };
323
+
324
+ /**
325
+ * The URL map with every {@link GCP_PUBLIC_BASE_PATHS} entry routed like
326
+ * `/api/*` (to the gateway). Only the rule that carries `/api/*` changes;
327
+ * redirect rules the admin wrote stay as they are. `added` is empty when the
328
+ * map is already complete.
329
+ */
330
+ export function planUrlMapPaths(maps: readonly LiveUrlMap[], siteHost: string): UrlMapPathsPlan {
331
+ const routing = maps.flatMap((m) =>
332
+ (m.hostRules ?? [])
333
+ .filter((h) => h.hosts.includes(siteHost))
334
+ .map((h) => ({ map: m, pathMatcher: h.pathMatcher })),
335
+ );
336
+ if (routing.length !== 1) {
337
+ return {
338
+ ok: false,
339
+ error: `expected exactly one URL map routing ${siteHost}, found ${routing.length}`,
340
+ };
341
+ }
342
+ const { map, pathMatcher } = routing[0] as (typeof routing)[number];
343
+ const matcher = (map.pathMatchers ?? []).find((m) => m.name === pathMatcher);
344
+ const gatewayRule = matcher?.pathRules?.find(
345
+ (r) => r.service !== undefined && r.urlRedirect === undefined && r.paths.includes("/api/*"),
346
+ );
347
+ if (!matcher || !gatewayRule) {
348
+ return {
349
+ ok: false,
350
+ error: `URL map ${map.name} has no /api/* rule to the gateway in path matcher ${pathMatcher}; re-run the installer to restore the load balancer`,
351
+ };
352
+ }
353
+ const added = GCP_PUBLIC_BASE_PATHS.filter((p) => !gatewayRule.paths.includes(p));
354
+ if (added.length === 0) return { ok: true, map, added };
355
+ const next: LiveUrlMap = {
356
+ ...map,
357
+ pathMatchers: (map.pathMatchers ?? []).map((m) =>
358
+ m !== matcher
359
+ ? m
360
+ : {
361
+ ...m,
362
+ pathRules: (m.pathRules ?? []).map((r) =>
363
+ r === gatewayRule ? { ...r, paths: [...r.paths, ...added] } : r,
364
+ ),
365
+ },
366
+ ),
367
+ };
368
+ return { ok: true, map: next, added };
369
+ }
370
+
371
+ const COMPUTE_API = "https://compute.googleapis.com/compute/v1";
372
+ const OPERATION_POLLS = 60;
373
+
374
+ /**
375
+ * Route the remote-MCP discovery paths to the gateway on an existing `gcp`
376
+ * install: read the URL map, add the missing paths to its `/api/*` rule and
377
+ * write it back through the Compute API (`urlMaps.update` carries the map's
378
+ * fingerprint, so a concurrent write — the admin publishing redirects —
379
+ * fails instead of being lost; a re-run then succeeds).
380
+ */
381
+ export async function ensureUrlMapRemoteMcpPaths(
382
+ target: Pick<RemoteMcpTarget, "projectId" | "domain">,
383
+ deps: { run?: GcloudRunner; http?: HttpFetch; sleep?: (ms: number) => Promise<void> } = {},
384
+ ): Promise<RemoteMcpResult<{ readonly added: string[] }>> {
385
+ const run = deps.run ?? defaultGcloud;
386
+ const http = deps.http ?? (fetch as unknown as HttpFetch);
387
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
388
+ const list = await run([
389
+ "compute",
390
+ "url-maps",
391
+ "list",
392
+ "--global",
393
+ `--project=${target.projectId}`,
394
+ "--format=json",
395
+ ]);
396
+ if (!list.ok) return { ok: false, error: `list URL maps: ${list.stderr.trim()}` };
397
+ const maps = parseJson(list.stdout || "[]");
398
+ if (!Array.isArray(maps)) return { ok: false, error: "list URL maps: output is not a JSON list" };
399
+ const plan = planUrlMapPaths(maps as LiveUrlMap[], target.domain);
400
+ if (!plan.ok) return plan;
401
+ if (plan.added.length === 0) return { ok: true, added: [] };
402
+ const token = await run(["auth", "print-access-token"]);
403
+ const bearer = token.ok ? token.stdout.trim() : "";
404
+ if (!bearer)
405
+ return { ok: false, error: `gcloud auth print-access-token: ${token.stderr.trim()}` };
406
+ const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
407
+ const base = `${COMPUTE_API}/projects/${encodeURIComponent(target.projectId)}/global`;
408
+ const put = await http(`${base}/urlMaps/${encodeURIComponent(plan.map.name)}`, {
409
+ method: "PUT",
410
+ headers,
411
+ body: JSON.stringify(plan.map),
412
+ });
413
+ if (!put.ok) {
414
+ return {
415
+ ok: false,
416
+ error: `update URL map ${plan.map.name}: HTTP ${put.status} ${(await put.text()).slice(0, 500)}`,
417
+ };
418
+ }
419
+ type Operation = { name?: string; status?: string; error?: { errors?: { message?: string }[] } };
420
+ let op = (parseJson(await put.text()) ?? {}) as Operation;
421
+ for (let i = 0; op.status !== "DONE" && i < OPERATION_POLLS; i++) {
422
+ await sleep(2_000);
423
+ const poll = await http(`${base}/operations/${encodeURIComponent(op.name ?? "")}`, {
424
+ method: "GET",
425
+ headers,
426
+ });
427
+ if (!poll.ok) return { ok: false, error: `poll ${op.name}: HTTP ${poll.status}` };
428
+ op = (parseJson(await poll.text()) ?? {}) as Operation;
429
+ }
430
+ if (op.status !== "DONE") return { ok: false, error: `URL-map update ${op.name} did not finish` };
431
+ const errors = op.error?.errors ?? [];
432
+ if (errors.length > 0) {
433
+ return {
434
+ ok: false,
435
+ error: `URL-map update failed: ${errors.map((e) => e.message).join("; ")}`,
436
+ };
437
+ }
438
+ return { ok: true, added: plan.added };
439
+ }
440
+
441
+ /**
442
+ * Give the gateway's Cloud Armor policy the `/api/mcp/*` rule
443
+ * ({@link MCP_ARMOR_RULE}): create it, or bring a rule at its priority back
444
+ * to spec.
445
+ */
446
+ export async function ensureMcpArmorRule(
447
+ target: Pick<RemoteMcpTarget, "projectId" | "env">,
448
+ deps: { run?: GcloudRunner } = {},
449
+ ): Promise<
450
+ RemoteMcpResult<{ readonly policy: string; readonly action: "present" | "created" | "updated" }>
451
+ > {
452
+ const run = deps.run ?? defaultGcloud;
453
+ const project = `--project=${target.projectId}`;
454
+ const prefix = `${gcpNamePrefix(target.env)}-waf`;
455
+ const list = await run([
456
+ "compute",
457
+ "security-policies",
458
+ "list",
459
+ project,
460
+ `--filter=name~^${prefix}`,
461
+ "--format=value(name)",
462
+ ]);
463
+ if (!list.ok) return { ok: false, error: `list security policies: ${list.stderr.trim()}` };
464
+ const names = lines(list.stdout);
465
+ if (names.length !== 1) {
466
+ return {
467
+ ok: false,
468
+ error: `expected exactly one ${prefix}* Cloud Armor policy, found ${names.length}`,
469
+ };
470
+ }
471
+ const policy = names[0] as string;
472
+ const describe = await run([
473
+ "compute",
474
+ "security-policies",
475
+ "describe",
476
+ policy,
477
+ project,
478
+ "--format=json",
479
+ ]);
480
+ if (!describe.ok) return { ok: false, error: `describe ${policy}: ${describe.stderr.trim()}` };
481
+ const described = parseJson(describe.stdout) as { rules?: LiveArmorRule[] } | null;
482
+ if (!described) return { ok: false, error: `describe ${policy}: output is not JSON` };
483
+ const rules = described.rules ?? [];
484
+ const existing = rules.find((r) => r.priority === MCP_ARMOR_RULE.priority);
485
+ if (existing && isMcpArmorRule(existing)) return { ok: true, policy, action: "present" };
486
+ const verb = existing ? "update" : "create";
487
+ const r = await run([
488
+ "compute",
489
+ "security-policies",
490
+ "rules",
491
+ verb,
492
+ String(MCP_ARMOR_RULE.priority),
493
+ `--security-policy=${policy}`,
494
+ project,
495
+ ...mcpArmorRuleFlags(),
496
+ "--quiet",
497
+ ]);
498
+ if (!r.ok)
499
+ return {
500
+ ok: false,
501
+ error: `${verb} rule ${MCP_ARMOR_RULE.priority} on ${policy}: ${r.stderr.trim()}`,
502
+ };
503
+ return { ok: true, policy, action: existing ? "updated" : "created" };
504
+ }
505
+
506
+ /** Both load-balancer parts on `gcp`; a no-op success on `gcp-firebase`. */
507
+ export async function ensureRemoteMcpEdge(
508
+ target: Pick<RemoteMcpTarget, "provider" | "projectId" | "env" | "domain">,
509
+ deps: { run?: GcloudRunner; http?: HttpFetch; sleep?: (ms: number) => Promise<void> } = {},
510
+ ): Promise<RemoteMcpResult<{ readonly done: string[] }>> {
511
+ if (target.provider !== "gcp") return { ok: true, done: [] };
512
+ const done: string[] = [];
513
+ const armor = await ensureMcpArmorRule(target, deps);
514
+ if (!armor.ok) return { ok: false, error: `Cloud Armor: ${armor.error}` };
515
+ done.push(`Cloud Armor rule ${MCP_ARMOR_RULE.priority} on ${armor.policy}: ${armor.action}`);
516
+ const paths = await ensureUrlMapRemoteMcpPaths(target, deps);
517
+ if (!paths.ok) return { ok: false, error: `URL map: ${paths.error}` };
518
+ done.push(
519
+ paths.added.length > 0
520
+ ? `URL map: routed ${paths.added.join(", ")} to the gateway`
521
+ : "URL map: discovery paths already routed to the gateway",
522
+ );
523
+ return { ok: true, done };
524
+ }
@@ -115,7 +115,7 @@ describe("ensureGeneratedSecrets", () => {
115
115
  { ...secretsInstall, replication: regional },
116
116
  { run },
117
117
  );
118
- expect(outcomes.map((o) => o.status)).toEqual(["present", "present", "present", "present"]);
118
+ expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("present"));
119
119
  expect(lines().some((l) => l.includes(" create ") || l.includes(" add "))).toBe(false);
120
120
  });
121
121
 
@@ -126,8 +126,11 @@ describe("ensureGeneratedSecrets", () => {
126
126
  { ...secretsInstall, replication: regional },
127
127
  { run, generate: () => `generated-value-${++n}` },
128
128
  );
129
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
129
+ expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
130
130
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
131
+ // #806 — the seal key is 32 random bytes in base64 (not the injected hex generator).
132
+ const sealKey = creates.find((c) => c.line.includes("mcp-oauth-seal-key"))?.stdin ?? "";
133
+ expect(Buffer.from(sealKey, "base64")).toHaveLength(32);
131
134
  expect(creates).toEqual([
132
135
  {
133
136
  line: "secrets create caelo-production-internal-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
@@ -147,6 +150,16 @@ describe("ensureGeneratedSecrets", () => {
147
150
  line: "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
148
151
  stdin: "generated-value-4",
149
152
  },
153
+ // #806 — remote MCP's OAuth client starts as the "not configured"
154
+ // placeholder; the Owner writes the real one from Settings → MCP.
155
+ {
156
+ line: "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
157
+ stdin: "{}",
158
+ },
159
+ {
160
+ line: "secrets create caelo-production-mcp-oauth-seal-key --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
161
+ stdin: sealKey,
162
+ },
150
163
  ]);
151
164
  for (const c of calls) expect(c.line).not.toContain("generated-value");
152
165
  });
@@ -165,11 +178,12 @@ describe("ensureGeneratedSecrets", () => {
165
178
  { run, generate: () => "generated" },
166
179
  );
167
180
  const creates = calls.filter((c) => c.line.startsWith("secrets create"));
168
- expect(creates.map((c) => c.stdin)).toEqual([
181
+ expect(creates.map((c) => c.stdin).slice(0, 5)).toEqual([
169
182
  "operator-set",
170
183
  "generated",
171
184
  "generated",
172
185
  "generated",
186
+ "{}",
173
187
  ]);
174
188
  for (const c of calls) expect(c.line).not.toContain("operator-set");
175
189
  });
@@ -181,12 +195,21 @@ describe("ensureGeneratedSecrets", () => {
181
195
  "secrets versions list caelo-production-tool-approval-secret": [ok("v/1")],
182
196
  "secrets versions list caelo-production-public-role-password": [ok("v/1")],
183
197
  "secrets versions list caelo-production-staging-gate-secret": [ok("v/1")],
198
+ "secrets versions list caelo-production-operator-oauth-client": [ok("v/1")],
199
+ "secrets versions list caelo-production-mcp-oauth-seal-key": [ok("v/1")],
184
200
  });
185
201
  const outcomes = await ensureGeneratedSecrets(
186
202
  { ...secretsInstall, replication: { kind: "automatic" } },
187
203
  { run, generate: () => "v" },
188
204
  );
189
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "present", "present", "present"]);
205
+ expect(outcomes.map((o) => o.status)).toEqual([
206
+ "applied",
207
+ "present",
208
+ "present",
209
+ "present",
210
+ "present",
211
+ "present",
212
+ ]);
190
213
  expect(calls.filter((c) => c.line.startsWith("secrets versions add"))).toEqual([
191
214
  {
192
215
  line: "secrets versions add caelo-production-internal-secret --project=acme --data-file=-",
@@ -220,7 +243,7 @@ describe("ensureGeneratedSecrets", () => {
220
243
  { ...secretsInstall, replication: { kind: "automatic" } },
221
244
  { run, generate: () => "v" },
222
245
  );
223
- expect(outcomes.map((o) => o.status)).toEqual(["applied", "applied", "applied", "applied"]);
246
+ expect(outcomes.map((o) => o.status)).toEqual(Array(6).fill("applied"));
224
247
  expect(outcomes.every((o) => o.detail?.includes("gcp.resourceLocations"))).toBe(true);
225
248
  const creates = lines().filter((l) => l.startsWith("secrets create"));
226
249
  // One refused automatic attempt; then every secret — the refused one
@@ -232,6 +255,8 @@ describe("ensureGeneratedSecrets", () => {
232
255
  "secrets create caelo-production-public-role-password --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
233
256
  // #652 — the staging gate's key follows the same path.
234
257
  "secrets create caelo-production-staging-gate-secret --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
258
+ "secrets create caelo-production-operator-oauth-client --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
259
+ "secrets create caelo-production-mcp-oauth-seal-key --project=acme --replication-policy=user-managed --locations=europe-west1 --data-file=- --quiet",
235
260
  ]);
236
261
  });
237
262
 
@@ -259,7 +284,7 @@ describe("ensureGeneratedSecrets", () => {
259
284
  { run, generate: () => "v" },
260
285
  );
261
286
  expect(outcomes.every((o) => o.status === "failed")).toBe(true);
262
- expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(4);
287
+ expect(lines().filter((l) => l.startsWith("secrets create"))).toHaveLength(6);
263
288
  });
264
289
  });
265
290