@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
@@ -17,9 +17,19 @@
17
17
  * The job's own service account, `caelo-<env>-upgrader`, may do exactly
18
18
  * what an app-roll needs (upgrade/app-roll.ts):
19
19
  *
20
- * - `roles/run.developer` on the project: deploy admin/gateway revisions,
21
- * move traffic, recreate the migration job, update its own job image.
22
- * It includes no `*.setIamPolicy`.
20
+ * - the custom role `caeloUpgraderRun` on the project
21
+ * ({@link UPGRADER_RUN_ROLE}): deploy admin/gateway revisions, move
22
+ * traffic, recreate and run the migration job, update its own job image.
23
+ * NOT `roles/run.developer`: that role holds `run.jobs.runWithOverrides`
24
+ * on every job of the project, including the converge job and the
25
+ * install job, which run as `caelo-provisioner` — the upgrader could run
26
+ * them with its own args (destroy, owner-reset) or env (CLOUDSDK_*,
27
+ * PULUMI_*) and so act as the provisioner (#883 review). The custom role
28
+ * has no `runWithOverrides` and no `*.setIamPolicy`. Updating another
29
+ * job (the converge job's image, say) additionally needs `actAs` on its
30
+ * service account, which the upgrader holds only on the admin, gateway
31
+ * and its own. A grant of `roles/run.developer` left by an earlier
32
+ * setup is removed.
23
33
  * - `roles/iam.serviceAccountUser` on exactly the admin's and the
24
34
  * gateway's runtime service accounts (a revision runs as them), on
25
35
  * itself (its own job) and on the quality-audit job's account (the
@@ -46,6 +56,19 @@
46
56
  * is a converge, which only the operator runs ("Run in Cloud Shell").
47
57
  * {@link UPGRADER_FORBIDDEN_ROLES} names that line; the tests assert no
48
58
  * grant crosses it.
59
+ *
60
+ * Direct VPC egress (#818): the migration job and both services run with
61
+ * `--network/--subnet`. Google documents the compute permissions that needs
62
+ * (`compute.subnetworks.use`, `compute.addresses.createInternal`, …) on the
63
+ * Cloud Run service agent, which `roles/run.serviceAgent` already gives it
64
+ * (cloud.google.com/run/docs/configuring/vpc-direct-vpc), not on the
65
+ * principal that deploys. So the upgrader gets no compute role; the
66
+ * real-GCP upgrader smoke test (.github/workflows/upgrader-smoke.yml) shows
67
+ * it once a stable release was installed on the smoke install through
68
+ * Settings → Updates (the next tag cannot ship before). Every permission
69
+ * the job needs is checked by the job itself before it changes anything
70
+ * (upgrader-iam-preflight.ts);
71
+ * a grant added or removed here must keep that list in step.
49
72
  */
50
73
 
51
74
  import { ensureCloudSqlIamUser } from "./cloudsql-iam-user.js";
@@ -91,22 +114,72 @@ export const UPGRADER_OPS_LIST_ROLE: CustomRoleSpec = {
91
114
  permissions: ["storage.objects.list"],
92
115
  };
93
116
 
94
- /** Project-level roles of the upgrader's service account. */
117
+ /**
118
+ * What an app-roll does on Cloud Run (upgrade/app-roll.ts, migration-runner.ts,
119
+ * the self-update, the #815 lock lease) — `roles/run.developer` minus
120
+ * everything else, above all `run.jobs.runWithOverrides` (see the file
121
+ * header). Keep in step with upgrader-iam-preflight.ts.
122
+ */
123
+ export const UPGRADER_RUN_ROLE: CustomRoleSpec = {
124
+ roleId: "caeloUpgraderRun",
125
+ title: "Caelo upgrader: roll Cloud Run",
126
+ description:
127
+ "Lets the Caelo upgrader job deploy admin/gateway revisions, run the migration job and update its own image. Cannot start any job with changed arguments or env, cannot change IAM.",
128
+ permissions: [
129
+ "run.configurations.get",
130
+ "run.configurations.list",
131
+ "run.executions.get",
132
+ "run.executions.list",
133
+ "run.jobs.create",
134
+ "run.jobs.delete",
135
+ "run.jobs.get",
136
+ "run.jobs.list",
137
+ "run.jobs.run",
138
+ "run.jobs.update",
139
+ "run.operations.get",
140
+ "run.revisions.get",
141
+ "run.revisions.list",
142
+ "run.routes.get",
143
+ "run.routes.list",
144
+ "run.services.get",
145
+ "run.services.list",
146
+ "run.services.update",
147
+ "run.tasks.get",
148
+ "run.tasks.list",
149
+ ],
150
+ };
151
+
152
+ /** Project-level predefined roles of the upgrader's service account. */
95
153
  export const UPGRADER_PROJECT_ROLES = [
96
- "roles/run.developer",
97
154
  "roles/cloudsql.instanceUser",
98
155
  "roles/logging.logWriter",
99
156
  ] as const;
100
157
 
158
+ /**
159
+ * Predefined roles that hold `run.jobs.runWithOverrides`. Neither the
160
+ * upgrader nor the admin may hold any of them anywhere: with it, either
161
+ * could run the converge or install job — which run as caelo-provisioner —
162
+ * with its own arguments or environment (#883 review).
163
+ */
164
+ export const RUN_WITH_OVERRIDES_ROLES = [
165
+ "roles/owner",
166
+ "roles/editor",
167
+ "roles/run.admin",
168
+ "roles/run.developer",
169
+ "roles/run.invoker",
170
+ "roles/run.jobsExecutorWithOverrides",
171
+ ] as const;
172
+
173
+ /** A project-level grant an earlier setup gave the upgrader; removed on every setup. */
174
+ const LEGACY_UPGRADER_PROJECT_ROLES = ["roles/run.developer"] as const;
175
+
101
176
  /**
102
177
  * Roles the upgrader must never hold anywhere — they would let it change
103
178
  * IAM, manage service accounts or secrets, or touch compute/projects.
104
179
  * Prefix matches (`roles/compute.` = every compute role).
105
180
  */
106
181
  export const UPGRADER_FORBIDDEN_ROLES = [
107
- "roles/owner",
108
- "roles/editor",
109
- "roles/run.admin",
182
+ ...RUN_WITH_OVERRIDES_ROLES,
110
183
  "roles/iam.serviceAccountAdmin",
111
184
  "roles/iam.serviceAccountKeyAdmin",
112
185
  "roles/iam.securityAdmin",
@@ -124,8 +197,14 @@ export const UPGRADER_FORBIDDEN_ROLES = [
124
197
  /** Database role migration 0267 creates for the job. */
125
198
  export const UPGRADER_DB_ROLE = "platform_updater";
126
199
 
127
- /** The job's resources: enough for a backup + roll; the job ends within it. */
128
- const JOB_TASK_TIMEOUT = "60m";
200
+ /**
201
+ * The job's task timeout in minutes: enough for a backup + roll; the job
202
+ * ends within it. It is also the lease on the state lock the job takes
203
+ * (#815, state-lock-lease.ts): after it, Cloud Run has killed the run.
204
+ */
205
+ export const UPGRADER_TASK_TIMEOUT_MINUTES = 60;
206
+
207
+ const JOB_TASK_TIMEOUT = `${UPGRADER_TASK_TIMEOUT_MINUTES}m`;
129
208
 
130
209
  export interface UpgraderSetupTarget {
131
210
  readonly projectId: string;
@@ -197,7 +276,7 @@ export async function ensureUpgraderJob(
197
276
  }
198
277
 
199
278
  // 1. The backup and ops-bucket-list roles.
200
- for (const role of [UPGRADER_SQL_BACKUP_ROLE, UPGRADER_OPS_LIST_ROLE]) {
279
+ for (const role of [UPGRADER_RUN_ROLE, UPGRADER_SQL_BACKUP_ROLE, UPGRADER_OPS_LIST_ROLE]) {
201
280
  const roleError = await ensureCustomRole(run, projectId, role);
202
281
  if (roleError) return { ok: false, done, error: roleError };
203
282
  done.push(`custom role ${role.roleId}`);
@@ -229,6 +308,7 @@ export async function ensureUpgraderJob(
229
308
  // 3. Project-level roles.
230
309
  for (const role of [
231
310
  ...UPGRADER_PROJECT_ROLES,
311
+ customRoleName(projectId, UPGRADER_RUN_ROLE.roleId),
232
312
  customRoleName(projectId, UPGRADER_SQL_BACKUP_ROLE.roleId),
233
313
  ]) {
234
314
  const r = await retry([
@@ -245,6 +325,34 @@ export async function ensureUpgraderJob(
245
325
  done.push(`upgrader: ${role} on the project`);
246
326
  }
247
327
 
328
+ // 3b. The project-wide roles/run.developer an earlier setup granted holds
329
+ // run.jobs.runWithOverrides on the provisioner's jobs: take it away.
330
+ // Only after the custom role above is bound, so a running upgrade
331
+ // never loses its Cloud Run rights in between.
332
+ const policy = await run(["projects", "get-iam-policy", projectId, "--format=json"]);
333
+ if (!policy.ok) return fail("read the project's IAM policy", policy);
334
+ for (const role of LEGACY_UPGRADER_PROJECT_ROLES) {
335
+ let held: boolean;
336
+ try {
337
+ held = projectPolicyHas(policy.stdout, role, member);
338
+ } catch (e) {
339
+ return { ok: false, done, error: `read the project's IAM policy: ${String(e)}` };
340
+ }
341
+ if (!held) continue;
342
+ const r = await retry([
343
+ "projects",
344
+ "remove-iam-policy-binding",
345
+ projectId,
346
+ `--member=${member}`,
347
+ `--role=${role}`,
348
+ "--all",
349
+ "--quiet",
350
+ "--format=none",
351
+ ]);
352
+ if (!r.ok) return fail(`remove the upgrader's ${role}`, r);
353
+ done.push(`upgrader: removed ${role} on the project`);
354
+ }
355
+
248
356
  // 4. Resource-level grants on single service accounts.
249
357
  const onAccounts: { account: string; role: string; why: string }[] = [
250
358
  {
@@ -373,6 +481,15 @@ export async function ensureUpgraderJob(
373
481
  return { ok: true, done };
374
482
  }
375
483
 
484
+ /** Whether a gcloud IAM policy (JSON) grants `role` to `member`. */
485
+ export function projectPolicyHas(policyJson: string, role: string, member: string): boolean {
486
+ // Throws on unreadable JSON: the caller reports it, never guesses "absent".
487
+ const policy = JSON.parse(policyJson.trim() || "{}") as {
488
+ bindings?: { role: string; members?: string[] }[];
489
+ };
490
+ return (policy.bindings ?? []).some((b) => b.role === role && (b.members ?? []).includes(member));
491
+ }
492
+
376
493
  /**
377
494
  * {@link ensureUpgraderJob}'s target, with the VPC read from the deployed
378
495
  * admin service (the job reaches Cloud SQL's private IP the same way).
@@ -138,6 +138,14 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
138
138
  ? "Adaptive protection (ML-based bot mitigation)"
139
139
  : "Free tier — rate limit + OWASP basic rules",
140
140
  },
141
+ {
142
+ // #806 — the remote-MCP throttle (src/remote-mcp-edge.ts) is one
143
+ // more rule on the gateway's policy; Cloud Armor Standard bills
144
+ // each rule at about $1/mo.
145
+ name: "Cloud Armor rule: remote MCP throttle",
146
+ monthlyUsd: 1,
147
+ notes: "Per-IP throttle for /api/mcp/* (remote MCP clients)",
148
+ },
141
149
  ] satisfies CostLine[])
142
150
  : ([
143
151
  {
@@ -155,9 +163,10 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
155
163
  notes: "~5 GB storage + low egress; growth roughly $0.02/GB",
156
164
  },
157
165
  {
158
- name: "Secret Manager (5 secrets)",
159
- monthlyUsd: 0,
160
- notes: "Free tier",
166
+ name: "Secret Manager (runtime secrets)",
167
+ monthlyUsd: 0.2,
168
+ notes:
169
+ "6 active versions free, then ~$0.06 each; the Google sign-in client (#914) and the remote-MCP seal key (#806)",
161
170
  },
162
171
  {
163
172
  name: "BigQuery edge log sink",
@@ -7,6 +7,7 @@ import {
7
7
  alignedStackSettings,
8
8
  assertStackRegion,
9
9
  deployedStackRegion,
10
+ presentImageOverrides,
10
11
  pulumiGcpEnv,
11
12
  resolveSecretReplication,
12
13
  stateOnlyEnv,
@@ -244,3 +245,17 @@ describe("stateOnlyEnv (#696)", () => {
244
245
  expect(env.PULUMI_BACKEND_URL).toBe("gs://acme-caelo-ops/pulumi");
245
246
  });
246
247
  });
248
+
249
+ describe("presentImageOverrides", () => {
250
+ it("reads the settings file as-is, so a stack missing its required keys still pins (regression: `pulumi config` refused to list it and every upgrade from a new CLI version failed)", () => {
251
+ const freshBunxSettings = { "caelo-gcp-firebase:image-digest-admin": "sha256:a" };
252
+ expect(presentImageOverrides("gcp-firebase", freshBunxSettings)).toEqual([]);
253
+ expect(presentImageOverrides("gcp", { "caelo-gcp:image-admin": "x:1" })).toEqual([
254
+ "caelo-gcp:image-admin",
255
+ ]);
256
+ });
257
+
258
+ it("finds nothing in a settings file without config", () => {
259
+ expect(presentImageOverrides("gcp", undefined)).toEqual([]);
260
+ });
261
+ });
@@ -150,12 +150,28 @@ export async function writeImageDigestsToStack(inputs: {
150
150
  }): Promise<{ removedOverrides: string[] }> {
151
151
  const stack = await selectInstallStack(inputs.backend, inputs.provider);
152
152
  await stack.setAllConfig(imageDigestConfig(inputs.provider, inputs.digests));
153
- const config = await stack.getAllConfig();
154
- const removedOverrides = imageOverrideKeys(inputs.provider).filter((k) => k in config);
153
+ // Read the settings file, not `getAllConfig()`: `pulumi config` refuses
154
+ // to list a stack whose required keys (domain, project, …) are unset, and
155
+ // the settings file in a freshly unpacked `bunx` package holds only what
156
+ // this call just wrote — so every upgrade from a new CLI version failed.
157
+ const settings = await stack.workspace.stackSettings(stack.name);
158
+ const removedOverrides = presentImageOverrides(inputs.provider, settings.config);
155
159
  if (removedOverrides.length > 0) await stack.removeAllConfig(removedOverrides);
156
160
  return { removedOverrides };
157
161
  }
158
162
 
163
+ /**
164
+ * The image-override keys set in a stack settings file's `config`.
165
+ *
166
+ * @param config the settings file's `config` map (absent when the file has none)
167
+ */
168
+ export function presentImageOverrides(
169
+ provider: "gcp" | "gcp-firebase",
170
+ config: Record<string, unknown> | undefined,
171
+ ): string[] {
172
+ return imageOverrideKeys(provider).filter((k) => config !== undefined && k in config);
173
+ }
174
+
159
175
  /**
160
176
  * One output of the install's stack, secrets included (the Automation API's
161
177
  * `pulumi stack output --show-secrets`). Read at the moment of use, never
@@ -36,6 +36,7 @@ import { checkAdcImpersonation } from "../adc-impersonation.js";
36
36
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
37
37
  import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
38
38
  import { redactCredentials } from "../checkpoint-secrets.js";
39
+ import { ensureConvergeJob } from "../converge-job-setup.js";
39
40
  import { removeDefaultFirewallRules } from "../default-firewall.js";
40
41
  import {
41
42
  checkDeployedRuntimeEnv,
@@ -101,6 +102,7 @@ import {
101
102
  } from "../provisioner-identity.js";
102
103
  import { revokePublicRoleMemberships } from "../public-role-isolation.js";
103
104
  import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
105
+ import { ensureRemoteMcpEdge, ensureRemoteMcpIdentityEnv } from "../remote-mcp.js";
104
106
  import type { RunMode } from "../run-mode.js";
105
107
  import { selfCommand } from "../self-command.js";
106
108
  import { ABSENT_DEFAULT_FIREWALL_RULES } from "../stack-contract.js";
@@ -115,6 +117,7 @@ import {
115
117
  resolveBackend,
116
118
  } from "../state-backend.js";
117
119
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
120
+ import { resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
118
121
  import {
119
122
  releaseVersionForDigest,
120
123
  resolveOpsImageRef,
@@ -464,6 +467,8 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
464
467
  const releaseVersion = await releaseVersionForDigest(gcloud, "admin", imageDigests.admin);
465
468
  const upgraderReady = await stepUpgraderJob(projectId, region, releaseVersion);
466
469
  const keepSigner = upgraderReady || (await upgraderExists(projectId));
470
+ // === 11.45. The converge job: releases that change the cloud setup (#819) ===
471
+ const convergeReady = await stepConvergeJob(projectId, region, releaseVersion);
467
472
 
468
473
  // === 11.5. Operator access: IAP follows the Caelo user list ===
469
474
  await stepOperatorAccessSync(
@@ -474,11 +479,18 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
474
479
  keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : [],
475
480
  );
476
481
 
482
+ // === 11.6. Remote MCP (#806): LB rules (gcp) + the identity env ===
483
+ // After `pulumi up` on every pass: the identity env names services Pulumi
484
+ // generated, so the stack cannot declare it, and an `up` that changed a
485
+ // service's template dropped it (remote-mcp.ts).
486
+ const remoteMcpReady = await stepRemoteMcp(projectId, region, provider, domain);
487
+
477
488
  // A fresh install ran every converge step above, so it is at this CLI's
478
- // infrastructure contract — recorded only when the upgrader is in place
479
- // (the last step a contract bump added). With the release version, the
480
- // admin can install the next update itself.
481
- if (upgraderReady) {
489
+ // infrastructure contract — recorded only when the upgrader and the
490
+ // converge job are in place (the steps contract bumps 2 and 9 added) and
491
+ // remote MCP's steps (#806, bump 10) succeeded. With the release version,
492
+ // the admin can install the next update itself.
493
+ if (upgraderReady && convergeReady && remoteMcpReady) {
482
494
  const version = releaseVersion;
483
495
  recordImageDigests(installId, imageDigests, version);
484
496
  recordInfraContractVersion(installId, INFRA_CONTRACT_VERSION);
@@ -981,6 +993,63 @@ async function stepUpgraderJob(
981
993
  return true;
982
994
  }
983
995
 
996
+ /**
997
+ * Set up the converge job (converge-job-setup.ts, #819): what the admin's
998
+ * "Install update" starts for a release that changes the cloud setup. Same
999
+ * shape as {@link stepUpgraderJob}: after migrations (its database login
1000
+ * gets migration 0267's role), only a verified ops image, and a failure
1001
+ * only warns — such releases then install from a terminal until a later
1002
+ * `upgrade` sets the job up. Idempotent.
1003
+ *
1004
+ * @returns whether the job is ready
1005
+ */
1006
+ async function stepConvergeJob(
1007
+ projectId: string,
1008
+ region: string,
1009
+ releaseVersion: string | null,
1010
+ ): Promise<boolean> {
1011
+ const opsImageRef = releaseVersion ? await resolveOpsImageRef(releaseVersion) : null;
1012
+ const digest = opsImageRef ? /@(sha256:[0-9a-f]{64})$/.exec(opsImageRef)?.[1] : undefined;
1013
+ // The job runs as the provisioner: only a verified image, ever.
1014
+ if (digest && !(await verifyCosignAll([{ slug: "ops", digest }]))) {
1015
+ log.warn(
1016
+ `The converge job is not set up: its image's signature did not verify. The install works; ${bold(selfCommand("upgrade"))} sets it up later.`,
1017
+ );
1018
+ return false;
1019
+ }
1020
+ const s = spinner();
1021
+ s.start("Setting up the converge job (cloud-setup updates from the admin)...");
1022
+ const resolved = opsImageRef
1023
+ ? await resolveUpgraderTarget({ projectId, region, env: GCP_STACK_ENV, opsImageRef })
1024
+ : {
1025
+ ok: false as const,
1026
+ error: releaseVersion
1027
+ ? `release ${releaseVersion} has no caelo-ops image in the registry`
1028
+ : "the release this install runs could not be told from its images",
1029
+ };
1030
+ const r = resolved.ok
1031
+ ? await ensureConvergeJob(resolved.target)
1032
+ : { ok: false as const, done: [], error: resolved.error };
1033
+ if (!r.ok && r.reason === "no-provisioner") {
1034
+ // #883 review — not needed for the contract: such an install installs
1035
+ // cloud-setup releases from a terminal, and its admin says so.
1036
+ s.stop(yellow("No converge job: this install has no caelo-provisioner to run it as"));
1037
+ log.info(
1038
+ "Releases that change the cloud setup install from a terminal; Settings → Updates says so when one comes.",
1039
+ );
1040
+ return true;
1041
+ }
1042
+ if (!r.ok) {
1043
+ s.stop(yellow(`Converge job not set up: ${r.error}`));
1044
+ log.warn(
1045
+ `The install works; releases that change the cloud setup install from a terminal until ${bold(selfCommand("upgrade"))} sets the job up.`,
1046
+ );
1047
+ return false;
1048
+ }
1049
+ s.stop(green(`Converge job ready (${r.done.length} steps)`));
1050
+ return true;
1051
+ }
1052
+
984
1053
  /**
985
1054
  * Set up the quality-audit job (audit-job-setup.ts) on the deployed admin's
986
1055
  * image. A failure stops the wizard: without the job every Stage audit
@@ -1064,6 +1133,67 @@ async function stepOperatorAccessSync(
1064
1133
  s.stop(green(`Operator-access sync job ready (${r.done.length} steps)`));
1065
1134
  }
1066
1135
 
1136
+ /**
1137
+ * #806 — the CLI's part of remote MCP (remote-mcp.ts): on `gcp` the load
1138
+ * balancer's discovery routes and Cloud Armor rule, on both providers the
1139
+ * env vars naming the admin URL, the broker's public URL and the IAP
1140
+ * audience. Remote MCP stays off until the Owner sets it up under Settings →
1141
+ * MCP, so a failure does not stop the install: it warns, and the next pass
1142
+ * or `upgrade` retries. Idempotent.
1143
+ *
1144
+ * @returns whether every part is in place
1145
+ */
1146
+ async function stepRemoteMcp(
1147
+ projectId: string,
1148
+ region: string,
1149
+ provider: "gcp" | "gcp-firebase",
1150
+ domain: string,
1151
+ ): Promise<boolean> {
1152
+ const s = spinner();
1153
+ s.start(
1154
+ "Preparing remote MCP (ChatGPT / claude.ai connectors; off until the Owner enables it)...",
1155
+ );
1156
+ const name = (slug: "admin" | "gateway") =>
1157
+ resolveGcpResourceName(
1158
+ gcloud,
1159
+ "run-service",
1160
+ `caelo-${GCP_STACK_ENV}-${slug}`,
1161
+ projectId,
1162
+ region,
1163
+ );
1164
+ const [admin, gateway] = [await name("admin"), await name("gateway")];
1165
+ const failed = (error: string) => {
1166
+ s.stop(yellow(`Remote MCP not prepared: ${error}`));
1167
+ log.warn(
1168
+ `The install works; Settings → Google sign-in and Settings → MCP report this install as not prepared until ${bold(selfCommand("upgrade"))} prepares it.`,
1169
+ );
1170
+ return false;
1171
+ };
1172
+ if (!admin || !gateway) return failed("the admin or gateway Cloud Run service was not found");
1173
+ const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
1174
+ if (!edge.ok) return failed(edge.error);
1175
+ const env = await ensureRemoteMcpIdentityEnv({
1176
+ provider,
1177
+ projectId,
1178
+ region,
1179
+ env: GCP_STACK_ENV,
1180
+ domain,
1181
+ services: { admin, gateway },
1182
+ });
1183
+ if (!env.ok) return failed(env.error);
1184
+ s.stop(green(`Remote MCP and Google sign-in prepared (${env.identity.mcpPublicUrl})`));
1185
+ if (env.orgDomainError) {
1186
+ // #914 — without the organisation's domain the admin cannot tell who
1187
+ // IAP's built-in sign-in admits, so it refuses new users until the
1188
+ // Owner sets up Google sign-in. Say so now rather than at the first
1189
+ // invite.
1190
+ log.warn(
1191
+ `Could not read this project's Google organisation (${env.orgDomainError}). Set up Google sign-in under Settings → Google sign-in before adding users, or re-run ${bold(selfCommand("upgrade"))} as an account with the Organization Viewer role.`,
1192
+ );
1193
+ }
1194
+ return true;
1195
+ }
1196
+
1067
1197
  /**
1068
1198
  * Delete the firewall rules GCP pre-populates on the `default` network
1069
1199
  * (SSH, RDP, ICMP, internal; default-firewall.ts). A customised rule of the
@@ -357,10 +357,20 @@ new aws.cloudwatch.LogDelivery(
357
357
  // exists the admin records every Lighthouse audit as failed with that gap
358
358
  // (quality/lighthouse-runner-select.ts) instead of measuring on its own
359
359
  // shared CPU. Nothing is staged on AWS before #749 either.
360
+ //
361
+ // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
362
+ // GCP Settings → Updates hands app-roll releases to the upgrader job and
363
+ // cloud-setup releases to the converge job; on AWS the admin refuses a
364
+ // cloud-setup release with the CLI command that installs it (admin-core
365
+ // platform-updates/update-route.ts) until this adapter gains both jobs.
360
366
  const cluster = new aws.ecs.Cluster(`${namePrefix}-ecs`, {
361
367
  settings: [{ name: "containerInsights", value: "enabled" }],
362
368
  });
363
369
 
370
+ // #806 — no remote MCP on AWS: it rides Google IAP (gcp stacks only). The
371
+ // gateway task definition, once it exists, sets none of its env, so /api/mcp/*
372
+ // answers 503 remote_mcp_not_configured and the admin reports it unavailable.
373
+ //
364
374
  // One task role per service (#635): the gateway's may read only the
365
375
  // public_role password, never a secret that unlocks a cms_admin login
366
376
  // (src/aws-task-roles.ts AWS_SERVICE_SECRETS, asserted by
@@ -339,6 +339,12 @@ const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs
339
339
  // audit as failed with that gap (quality/lighthouse-runner-select.ts)
340
340
  // instead of measuring on its own shared CPU. Nothing is staged on Azure
341
341
  // before #750 either.
342
+ //
343
+ // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
344
+ // GCP Settings → Updates hands app-roll releases to the upgrader job and
345
+ // cloud-setup releases to the converge job; on Azure the admin refuses a
346
+ // cloud-setup release with the CLI command that installs it (admin-core
347
+ // platform-updates/update-route.ts) until this adapter gains both jobs.
342
348
  const cappEnv = new azure.app.ManagedEnvironment(`${namePrefix}-capp-env`, {
343
349
  resourceGroupName: rg.name,
344
350
  environmentName: `${namePrefix}-capp-env`,
@@ -430,6 +436,9 @@ const _gatewayApp = containerApp({
430
436
  serviceName: "gateway",
431
437
  databaseEnv: [{ name: "PUBLIC_DATABASE_URL", value: publicDatabaseUrl }],
432
438
  // Which X-Forwarded-For entry is the visitor (api-gateway client-ip.ts).
439
+ // #806 — no remote-MCP env: remote MCP rides Google IAP (gcp stacks
440
+ // only); unset, the gateway answers /api/mcp/* with 503
441
+ // remote_mcp_not_configured and the admin reports it unavailable.
433
442
  extraEnv: [{ name: TRUSTED_PROXY_HOPS_ENV, value: String(GATEWAY_TRUSTED_PROXY_HOPS.azure) }],
434
443
  });
435
444
  const _orchestratorApp = containerApp({
@@ -31,6 +31,7 @@ import {
31
31
  staticPublisherServiceAccountId,
32
32
  } from "../../dist/gcp-names.js";
33
33
  import { createSiteUrlMap } from "../../dist/gcp-url-map.js";
34
+ import { mcpArmorRuleArgs } from "../../dist/remote-mcp-edge.js";
34
35
  import {
35
36
  ADMIN_MEMORY_DEFAULT,
36
37
  adminEnvContract,
@@ -40,10 +41,12 @@ import {
40
41
  type CloudRunSlug,
41
42
  databaseUrls,
42
43
  defaultStagingHost,
44
+ GATEWAY_TIMEOUT_SECONDS,
43
45
  gatewayEnvContract,
44
46
  type RuntimeSecret,
45
47
  runtimeSecretBindings,
46
48
  STATIC_CDN_POLICY,
49
+ secretFileVolumesTemplate,
47
50
  } from "../../dist/stack-contract.js";
48
51
 
49
52
  const cfg = new pulumi.Config();
@@ -251,6 +254,11 @@ const runtimeSecretIds: Record<RuntimeSecret, pulumi.Input<string>> = {
251
254
  "tool-approval-secret": gcpSecretId(env, "tool-approval-secret"),
252
255
  "staging-gate-secret": gcpSecretId(env, "staging-gate-secret"),
253
256
  "public-role-password": gcpSecretId(env, "public-role-password"),
257
+ // #914 / #806 — the Owner-set Google sign-in client (a placeholder until
258
+ // then; IAP login, IAP programmatic access and the remote-MCP broker) and
259
+ // the broker's seal key, both CLI-generated.
260
+ "operator-oauth-client": gcpSecretId(env, "operator-oauth-client"),
261
+ "mcp-oauth-seal-key": gcpSecretId(env, "mcp-oauth-seal-key"),
254
262
  };
255
263
 
256
264
  // #613 — public_role's own password (CLI-generated, stack-contract.ts
@@ -472,6 +480,20 @@ for (const binding of runtimeSecretBindings()) {
472
480
  );
473
481
  }
474
482
 
483
+ // #914 — the Owner's Google sign-in setup (Settings → Google sign-in) stores
484
+ // the install's Google OAuth client in this one secret. Add-version only: the
485
+ // admin can replace the client the gateway and the operator-access job read, never read it back or touch any
486
+ // other secret (stack-contract.ts GCP_COMMON says why).
487
+ new gcp.secretmanager.SecretIamMember(
488
+ `${namePrefix}-admin-operator-oauth-client-version-adder`,
489
+ {
490
+ secretId: runtimeSecretIds["operator-oauth-client"],
491
+ role: "roles/secretmanager.secretVersionAdder",
492
+ member: pulumi.interpolate`serviceAccount:${runSa.email}`,
493
+ },
494
+ opts,
495
+ );
496
+
475
497
  new gcp.storage.BucketIAMMember(
476
498
  `${namePrefix}-media-rw`,
477
499
  {
@@ -627,8 +649,8 @@ interface CloudRunArgs {
627
649
  * Per-request timeout. Cloud Run's default is 300s (5min), which is too
628
650
  * short for the admin's SSE chat-stream — a multi-tool compose-page session
629
651
  * regularly runs 5–15 min. Pass an explicit "<n>s" Pulumi-style duration
630
- * string. Gateway stays on the default; visitor-facing endpoints don't
631
- * legitimately need longer.
652
+ * string. The gateway gets the same (#806): it proxies remote-MCP chat
653
+ * streams to the admin.
632
654
  */
633
655
  readonly timeout?: string;
634
656
  /**
@@ -689,16 +711,23 @@ function cloudRunService(args: CloudRunArgs): gcp.cloudrunv2.Service {
689
711
  },
690
712
  timeout: args.timeout,
691
713
  ...(args.mediaVolume
692
- ? {
693
- executionEnvironment: args.mediaVolume.executionEnvironment,
694
- volumes: [...args.mediaVolume.volumes],
695
- }
714
+ ? { executionEnvironment: args.mediaVolume.executionEnvironment }
696
715
  : {}),
716
+ // The admin's media bucket, plus each service's secret files (#806:
717
+ // the gateway's remote-MCP OAuth client) — both from the contract
718
+ // upgrade applies to existing installs.
719
+ volumes: [
720
+ ...(args.mediaVolume?.volumes ?? []),
721
+ ...secretFileVolumesTemplate(args.serviceName, env).volumes,
722
+ ],
697
723
  containers: [
698
724
  {
699
725
  image: imageTag(args.serviceName),
700
726
  envs: [...args.contractEnv],
701
- ...(args.mediaVolume ? { volumeMounts: [...args.mediaVolume.volumeMounts] } : {}),
727
+ volumeMounts: [
728
+ ...(args.mediaVolume?.volumeMounts ?? []),
729
+ ...secretFileVolumesTemplate(args.serviceName, env).volumeMounts,
730
+ ],
702
731
  resources: { limits: { cpu: "1", memory: args.memory } },
703
732
  },
704
733
  ],
@@ -749,6 +778,11 @@ const gatewaySvc = cloudRunService({
749
778
  minInstances: gatewayMinInstances,
750
779
  maxInstances: 100,
751
780
  memory: "512Mi",
781
+ // #806 — remote MCP: chat turns stream through the gateway for minutes,
782
+ // so it gets the admin's 3600 s instead of Cloud Run's 300 s default
783
+ // (stack-contract.ts GATEWAY_TIMEOUT_SECONDS). The LB in front imposes no
784
+ // shorter limit on serverless NEG backends.
785
+ timeout: `${GATEWAY_TIMEOUT_SECONDS}s`,
752
786
  // public_role on cms_public only: no cms_admin login of any role
753
787
  // (#613, #635, CLAUDE.md §2).
754
788
  contractEnv: gatewayEnvContract(envContractInputs),
@@ -784,6 +818,12 @@ const wafPolicy = new gcp.compute.SecurityPolicy(
784
818
  {
785
819
  description: `Caelo ${env} Cloud Armor — rate limit + OWASP`,
786
820
  rules: [
821
+ // #806 — remote MCP (/api/mcp/*): a per-IP throttle sized for agent
822
+ // traffic behind shared connector IPs, evaluated first; below it the
823
+ // rule allows, so the OWASP packs (MCP bodies carry HTML) and the
824
+ // general ban below do not apply there. src/remote-mcp-edge.ts says
825
+ // why; `cms-provision upgrade` adds the same rule to existing installs.
826
+ mcpArmorRuleArgs(),
787
827
  // Rate limit per source IP — 100 req / min sliding window.
788
828
  {
789
829
  action: "rate_based_ban",