@caelo-cms/provisioning 0.10.55 → 0.10.57

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 (209) hide show
  1. package/dist/agent-db.js +76 -19
  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 +22 -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.map +1 -1
  45. package/dist/operator-access.js +13 -3
  46. package/dist/operator-access.js.map +1 -1
  47. package/dist/release-contract.d.ts +3 -2
  48. package/dist/release-contract.d.ts.map +1 -1
  49. package/dist/release-contract.js +5 -2
  50. package/dist/release-contract.js.map +1 -1
  51. package/dist/release-manifest.d.ts +2 -0
  52. package/dist/release-manifest.d.ts.map +1 -1
  53. package/dist/release-manifest.js.map +1 -1
  54. package/dist/remote-mcp-edge.d.ts +114 -0
  55. package/dist/remote-mcp-edge.d.ts.map +1 -0
  56. package/dist/remote-mcp-edge.js +116 -0
  57. package/dist/remote-mcp-edge.js.map +1 -0
  58. package/dist/remote-mcp.d.ts +117 -0
  59. package/dist/remote-mcp.d.ts.map +1 -0
  60. package/dist/remote-mcp.js +379 -0
  61. package/dist/remote-mcp.js.map +1 -0
  62. package/dist/runtime-secrets.d.ts +12 -0
  63. package/dist/runtime-secrets.d.ts.map +1 -1
  64. package/dist/runtime-secrets.js +32 -4
  65. package/dist/runtime-secrets.js.map +1 -1
  66. package/dist/seal-key-rotation.d.ts +38 -0
  67. package/dist/seal-key-rotation.d.ts.map +1 -0
  68. package/dist/seal-key-rotation.js +206 -0
  69. package/dist/seal-key-rotation.js.map +1 -0
  70. package/dist/stack-contract.d.ts +167 -3
  71. package/dist/stack-contract.d.ts.map +1 -1
  72. package/dist/stack-contract.js +171 -3
  73. package/dist/stack-contract.js.map +1 -1
  74. package/dist/stack-converge.d.ts +38 -1
  75. package/dist/stack-converge.d.ts.map +1 -1
  76. package/dist/stack-converge.js +74 -4
  77. package/dist/stack-converge.js.map +1 -1
  78. package/dist/state-lock-lease.d.ts +142 -0
  79. package/dist/state-lock-lease.d.ts.map +1 -0
  80. package/dist/state-lock-lease.js +347 -0
  81. package/dist/state-lock-lease.js.map +1 -0
  82. package/dist/state-store.d.ts +66 -9
  83. package/dist/state-store.d.ts.map +1 -1
  84. package/dist/state-store.js +249 -30
  85. package/dist/state-store.js.map +1 -1
  86. package/dist/upgrade/app-roll.d.ts +36 -7
  87. package/dist/upgrade/app-roll.d.ts.map +1 -1
  88. package/dist/upgrade/app-roll.js +53 -19
  89. package/dist/upgrade/app-roll.js.map +1 -1
  90. package/dist/upgrade/converge.d.ts.map +1 -1
  91. package/dist/upgrade/converge.js +111 -9
  92. package/dist/upgrade/converge.js.map +1 -1
  93. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  94. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  95. package/dist/upgrade/gcp-lookup.js +46 -7
  96. package/dist/upgrade/gcp-lookup.js.map +1 -1
  97. package/dist/upgrade/record-rolled.d.ts +40 -0
  98. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  99. package/dist/upgrade/record-rolled.js +46 -0
  100. package/dist/upgrade/record-rolled.js.map +1 -0
  101. package/dist/upgrade/upgrade.d.ts +45 -1
  102. package/dist/upgrade/upgrade.d.ts.map +1 -1
  103. package/dist/upgrade/upgrade.js +60 -17
  104. package/dist/upgrade/upgrade.js.map +1 -1
  105. package/dist/upgrader-assess.d.ts +15 -1
  106. package/dist/upgrader-assess.d.ts.map +1 -1
  107. package/dist/upgrader-assess.js +36 -6
  108. package/dist/upgrader-assess.js.map +1 -1
  109. package/dist/upgrader-gcp.d.ts +15 -0
  110. package/dist/upgrader-gcp.d.ts.map +1 -1
  111. package/dist/upgrader-gcp.js +42 -7
  112. package/dist/upgrader-gcp.js.map +1 -1
  113. package/dist/upgrader-iam-preflight.d.ts +123 -0
  114. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  115. package/dist/upgrader-iam-preflight.js +414 -0
  116. package/dist/upgrader-iam-preflight.js.map +1 -0
  117. package/dist/upgrader-job.d.ts +33 -9
  118. package/dist/upgrader-job.d.ts.map +1 -1
  119. package/dist/upgrader-job.js +169 -27
  120. package/dist/upgrader-job.js.map +1 -1
  121. package/dist/upgrader-records.d.ts +58 -0
  122. package/dist/upgrader-records.d.ts.map +1 -0
  123. package/dist/upgrader-records.js +87 -0
  124. package/dist/upgrader-records.js.map +1 -0
  125. package/dist/upgrader-reporter.d.ts +3 -2
  126. package/dist/upgrader-reporter.d.ts.map +1 -1
  127. package/dist/upgrader-reporter.js +10 -2
  128. package/dist/upgrader-reporter.js.map +1 -1
  129. package/dist/upgrader-setup.d.ts +25 -3
  130. package/dist/upgrader-setup.d.ts.map +1 -1
  131. package/dist/upgrader-setup.js +123 -11
  132. package/dist/upgrader-setup.js.map +1 -1
  133. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  134. package/dist/wizards/gcp-cost.js +11 -3
  135. package/dist/wizards/gcp-cost.js.map +1 -1
  136. package/dist/wizards/gcp.d.ts.map +1 -1
  137. package/dist/wizards/gcp.js +99 -4
  138. package/dist/wizards/gcp.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/agent/run.test.ts +14 -1
  141. package/src/cli-routing.ts +1 -1
  142. package/src/cli.ts +4 -1
  143. package/src/compose.ts +5 -0
  144. package/src/converge-checkpoint.ts +75 -0
  145. package/src/converge-entrypoint.test.ts +213 -0
  146. package/src/converge-job-gcp.test.ts +222 -0
  147. package/src/converge-job-gcp.ts +443 -0
  148. package/src/converge-job-setup.test.ts +152 -0
  149. package/src/converge-job-setup.ts +268 -0
  150. package/src/converge-job.test.ts +486 -0
  151. package/src/converge-job.ts +542 -0
  152. package/src/gateway-env-isolation.test.ts +8 -1
  153. package/src/gcp-names.ts +15 -0
  154. package/src/gcp-url-map.test.ts +9 -1
  155. package/src/index.ts +8 -0
  156. package/src/infra-contract.test.ts +7 -4
  157. package/src/infra-contract.ts +22 -1
  158. package/src/install-job.test.ts +3 -1
  159. package/src/install-job.ts +31 -14
  160. package/src/lifecycle.ts +137 -71
  161. package/src/operator-access.test.ts +14 -0
  162. package/src/operator-access.ts +13 -3
  163. package/src/release-contract.ts +5 -2
  164. package/src/release-manifest.test.ts +42 -0
  165. package/src/release-manifest.ts +2 -0
  166. package/src/remote-mcp-edge.ts +137 -0
  167. package/src/remote-mcp.test.ts +517 -0
  168. package/src/remote-mcp.ts +460 -0
  169. package/src/runtime-secrets.test.ts +31 -6
  170. package/src/runtime-secrets.ts +41 -3
  171. package/src/seal-key-rotation.test.ts +131 -0
  172. package/src/seal-key-rotation.ts +258 -0
  173. package/src/stack-contract-parity.test.ts +20 -3
  174. package/src/stack-contract.test.ts +30 -2
  175. package/src/stack-contract.ts +291 -3
  176. package/src/stack-converge-secrets.test.ts +3 -0
  177. package/src/stack-converge.test.ts +21 -5
  178. package/src/stack-converge.ts +101 -7
  179. package/src/state-attach.test.ts +7 -1
  180. package/src/state-lock-lease.test.ts +302 -0
  181. package/src/state-lock-lease.ts +423 -0
  182. package/src/state-lock.test.ts +568 -0
  183. package/src/state-store.ts +347 -27
  184. package/src/upgrade/app-roll.ts +96 -21
  185. package/src/upgrade/converge.ts +130 -8
  186. package/src/upgrade/gcp-lookup.test.ts +80 -0
  187. package/src/upgrade/gcp-lookup.ts +65 -8
  188. package/src/upgrade/record-rolled.test.ts +105 -0
  189. package/src/upgrade/record-rolled.ts +91 -0
  190. package/src/upgrade/upgrade.test.ts +203 -3
  191. package/src/upgrade/upgrade.ts +120 -24
  192. package/src/upgrader-assess.test.ts +92 -3
  193. package/src/upgrader-assess.ts +58 -9
  194. package/src/upgrader-gcp.ts +47 -8
  195. package/src/upgrader-iam-preflight.test.ts +393 -0
  196. package/src/upgrader-iam-preflight.ts +510 -0
  197. package/src/upgrader-job.test.ts +586 -15
  198. package/src/upgrader-job.ts +237 -53
  199. package/src/upgrader-records.ts +151 -0
  200. package/src/upgrader-reporter.test.ts +6 -0
  201. package/src/upgrader-reporter.ts +7 -2
  202. package/src/upgrader-setup.test.ts +3 -3
  203. package/src/upgrader-setup.ts +128 -11
  204. package/src/wizards/gcp-cost.ts +12 -3
  205. package/src/wizards/gcp.ts +125 -4
  206. package/stacks/aws/index.ts +10 -0
  207. package/stacks/azure/index.ts +9 -0
  208. package/stacks/gcp/index.ts +46 -7
  209. package/stacks/gcp-firebase/index.ts +37 -6
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 8;
25
+ export const INFRA_CONTRACT_VERSION = 10;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -56,6 +56,25 @@ export const INFRA_CONTRACT_VERSION = 8;
56
56
  // CAELO_AUDIT_RESULTS_BUCKET on the admin. The new admin fans every
57
57
  // Stage audit out to that job, and the app-roll moves the job's image,
58
58
  // so it must exist before an in-cloud upgrader rolls onto it.
59
+ // 9 — #819: the converge job (`caelo-<env>-converge`, converge-job-setup.ts)
60
+ // that installs contract-bumping releases from Settings → Updates as
61
+ // the install's caelo-provisioner, its Cloud SQL IAM database login
62
+ // (platform_updater), the admin's start-only grant on it and
63
+ // CAELO_CONVERGE_JOB on the admin. Installed by the last converge an
64
+ // Owner runs by hand; every later one runs from Caelo. The same bump
65
+ // replaces the upgrader's project-wide roles/run.developer (which
66
+ // holds run.jobs.runWithOverrides on the provisioner's jobs) with the
67
+ // custom role caeloUpgraderRun (#883 review). An install without a
68
+ // caelo-provisioner reaches 9 without the job: there is nothing it
69
+ // could run as, so its admin keeps routing cloud-setup releases to
70
+ // the terminal (upgrade/converge.ts, update-route.ts).
71
+ // 10 — #806: remote MCP. Two CLI-generated secrets (the Google OAuth client
72
+ // placeholder, the broker's seal key) the gateway's new revision
73
+ // references — a secret volume and a secret env var Cloud Run refuses
74
+ // to deploy without — plus the gateway SA's accessor and the admin
75
+ // SA's version-adder bindings on them, the operator-access role's
76
+ // IAP-settings permissions, and on `gcp` the URL-map routes and the
77
+ // Cloud Armor rule. The in-cloud upgrader can create none of these.
59
78
 
60
79
  /**
61
80
  * What each contract version adds, in operator terms — the upgrader's
@@ -71,6 +90,8 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
71
90
  6: "the upgrader's permission to list its ops bucket (#799)",
72
91
  7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
73
92
  8: "the quality-audit job, its service account, results bucket and grants (#875)",
93
+ 9: "the converge job that installs cloud-setup changes from Settings → Updates, and a narrower Cloud Run role for the upgrader (#819)",
94
+ 10: "remote MCP: the OAuth client and seal-key secrets, their IAM, the gateway's secret file and timeout, IAP programmatic-client sync and the load-balancer rules (#806)",
74
95
  };
75
96
 
76
97
  /**
@@ -162,7 +162,9 @@ describe("the status document", () => {
162
162
 
163
163
  it("fails loudly on a malformed document and on an unreadable bucket", () => {
164
164
  const objects = new Map([[installJobStatusUrl(BUCKET), '{"statusVersion":9}']]);
165
- expect(() => readInstallJobStatus(BUCKET, fakeBucket(objects))).toThrow(/not an install-job/);
165
+ expect(() => readInstallJobStatus(BUCKET, fakeBucket(objects))).toThrow(
166
+ /not a job status this CLI understands/,
167
+ );
166
168
  expect(() => readInstallJobStatus(BUCKET, () => fail("403 Forbidden"))).toThrow(/403/);
167
169
  });
168
170
  });
@@ -113,12 +113,12 @@ export function installJobStatusUrl(bucket: string): string {
113
113
  const NOT_FOUND = /matched no objects|No URLs matched|NotFound|NOT_FOUND|404|does not exist/i;
114
114
 
115
115
  /**
116
- * The install job's status as the ops bucket holds it now (never cached:
117
- * another process — the job — writes it), or null before the first start.
116
+ * A job status document (`install-job.json`, and the converge job's
117
+ * `converge-job.json`, #819) as the ops bucket holds it now — never cached:
118
+ * another process (the job) writes it — or null before the first start.
118
119
  * Throws when the bucket can't be read or the document is malformed.
119
120
  */
120
- export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJobStatus | null {
121
- const url = installJobStatusUrl(bucket);
121
+ export function readJobStatusDoc<T>(url: string, schema: z.ZodType<T>, run: SyncGcloud): T | null {
122
122
  const r = run(["storage", "cat", url]);
123
123
  if (!r.ok) {
124
124
  if (NOT_FOUND.test(r.stderr)) return null;
@@ -128,26 +128,26 @@ export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJo
128
128
  try {
129
129
  json = JSON.parse(r.stdout);
130
130
  } catch {
131
- throw new Error(`${url} is not valid JSON; remove it and re-run to start the install again.`);
131
+ throw new Error(`${url} is not valid JSON; remove it and re-run to start again.`);
132
132
  }
133
- const parsed = InstallJobStatusSchema.safeParse(json);
133
+ const parsed = schema.safeParse(json);
134
134
  if (!parsed.success) {
135
135
  throw new Error(
136
- `${url} is not an install-job status this CLI understands (${parsed.error.message}); remove it and re-run.`,
136
+ `${url} is not a job status this CLI understands (${parsed.error.message}); remove it and re-run.`,
137
137
  );
138
138
  }
139
139
  return parsed.data;
140
140
  }
141
141
 
142
- /** Replace the status document. Refuses secret material (#656). */
143
- export function writeInstallJobStatus(
144
- bucket: string,
145
- status: InstallJobStatus,
142
+ /** Replace a job status document. Refuses secret material (#656). */
143
+ export function writeJobStatusDoc<T>(
144
+ url: string,
145
+ schema: z.ZodType<T>,
146
+ status: T,
146
147
  run: SyncGcloud,
147
148
  ): void {
148
- const parsed = InstallJobStatusSchema.parse(status);
149
- assertNoSecrets(parsed, "install-job.json");
150
- const url = installJobStatusUrl(bucket);
149
+ const parsed = schema.parse(status);
150
+ assertNoSecrets(parsed, url.split("/").pop() ?? url);
151
151
  const r = run(
152
152
  ["storage", "cp", "-", url, "--content-type=application/json"],
153
153
  `${JSON.stringify(parsed, null, 2)}\n`,
@@ -157,6 +157,23 @@ export function writeInstallJobStatus(
157
157
  }
158
158
  }
159
159
 
160
+ /**
161
+ * The install job's status as the ops bucket holds it now, or null before
162
+ * the first start ({@link readJobStatusDoc}).
163
+ */
164
+ export function readInstallJobStatus(bucket: string, run: SyncGcloud): InstallJobStatus | null {
165
+ return readJobStatusDoc(installJobStatusUrl(bucket), InstallJobStatusSchema, run);
166
+ }
167
+
168
+ /** Replace the status document. Refuses secret material (#656). */
169
+ export function writeInstallJobStatus(
170
+ bucket: string,
171
+ status: InstallJobStatus,
172
+ run: SyncGcloud,
173
+ ): void {
174
+ writeJobStatusDoc(installJobStatusUrl(bucket), InstallJobStatusSchema, status, run);
175
+ }
176
+
160
177
  /**
161
178
  * Write `status` only if the document doesn't name its execution yet and
162
179
  * nobody wrote it since this call read it (`--if-generation-match`). The
package/src/lifecycle.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  installRoot,
33
33
  installStagingHost,
34
34
  listInstalls,
35
+ readLocalMetadata,
35
36
  readMetadata,
36
37
  readSecret,
37
38
  recordImageDigests,
@@ -53,14 +54,18 @@ import {
53
54
  rolesOfSecret,
54
55
  rotateRuntimeSecret,
55
56
  rotationRefusal,
57
+ SEAL_KEY_SECRET,
56
58
  } from "./runtime-secrets.js";
59
+ import { finishSealKeyRotation, startSealKeyRotation } from "./seal-key-rotation.js";
57
60
  import { selfCommand } from "./self-command.js";
58
61
  import {
59
62
  backendShellEnv,
60
63
  localStateNotice,
64
+ OPS_INSTALL_PREFIX,
61
65
  type ResolvedBackend,
62
66
  resolveBackend,
63
67
  } from "./state-backend.js";
68
+ import { acquireStateLock, realSyncGcloud, releaseOnSignal } from "./state-store.js";
64
69
  import {
65
70
  type AppRollPlan,
66
71
  type ReleaseImage,
@@ -70,6 +75,7 @@ import {
70
75
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
71
76
  import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
72
77
  import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
78
+ import { recordRolledDigests } from "./upgrade/record-rolled.js";
73
79
  import {
74
80
  canonicalReleaseTag,
75
81
  fetchLatestReleaseTag,
@@ -488,6 +494,53 @@ async function upgradeInstall(
488
494
  const provider = meta.provider;
489
495
  const projectId = meta.projectId;
490
496
 
497
+ // #815 — the install's state lock, the one the in-app upgrader job,
498
+ // `pulumi up` and `state migrate` take: a Cloud Shell upgrade must not
499
+ // race an Owner-started or automatic job run. The local copy always
500
+ // names where the state lives (install-state.ts stateStoreFor). A lock
501
+ // left by a job whose run has ended, by a CLI whose process is gone on
502
+ // this machine, or by one that stopped renewing its lease is broken
503
+ // automatically. This run's own lock carries host + pid and a lease its
504
+ // heartbeat renews, and is given back on Ctrl-C / SIGTERM — a killed CLI
505
+ // or a closed Cloud Shell never blocks in-app updates for good.
506
+ const backend = readLocalMetadata(installId)?.stateBackend;
507
+ let releaseLock: () => void;
508
+ try {
509
+ releaseLock = acquireStateLock(
510
+ backend?.kind === "gcs" ? { kind: "gcs", bucket: backend.bucket } : { kind: "local" },
511
+ installRoot(installId),
512
+ "cms-provision upgrade",
513
+ realSyncGcloud,
514
+ {
515
+ onBreak: (message) => log.info(message),
516
+ onLost: (message) => log.error(red(message)),
517
+ },
518
+ );
519
+ } catch (e) {
520
+ log.error(red(`Nothing was changed: ${e instanceof Error ? e.message : String(e)}`));
521
+ return false;
522
+ }
523
+ const disposeSignals = releaseOnSignal(releaseLock, (message) => log.warn(yellow(message)));
524
+ try {
525
+ return await upgradeLockedInstall(installId, meta, opts, provider, projectId);
526
+ } finally {
527
+ disposeSignals();
528
+ try {
529
+ releaseLock();
530
+ } catch (e) {
531
+ log.warn(yellow(e instanceof Error ? e.message : String(e)));
532
+ }
533
+ }
534
+ }
535
+
536
+ /** {@link upgradeInstall} once it holds the install's state lock. */
537
+ async function upgradeLockedInstall(
538
+ installId: string,
539
+ meta: InstallMetadata,
540
+ opts: UpgradeOpts,
541
+ provider: "gcp" | "gcp-firebase",
542
+ projectId: string,
543
+ ): Promise<boolean> {
491
544
  // #607 — upgrade never moves an install: a `--region` other than the
492
545
  // recorded one is refused before anything rolls.
493
546
  const region = installRegion(meta, opts.region);
@@ -501,7 +554,9 @@ async function upgradeInstall(
501
554
 
502
555
  const run: GcloudRunner = gcloud;
503
556
  const http = fetch as unknown as HttpFetch;
504
- const ok = await upgradeGcpInstall(
557
+ // #860 — a roll whose records could not be written is a failed upgrade.
558
+ let recorded = true;
559
+ const rolled = await upgradeGcpInstall(
505
560
  {
506
561
  provider,
507
562
  projectId,
@@ -524,20 +579,29 @@ async function upgradeInstall(
524
579
  migrate: (imageRef) =>
525
580
  runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
526
581
  healthProbe: (plan) => gcpHealthProbe(run, http, plan),
527
- recordDigests: async (digests) =>
528
- recordRolledDigests(
582
+ recordDigests: async (digests) => {
583
+ recorded = await recordRolledRelease(
529
584
  installId,
530
585
  provider,
531
586
  digests,
532
587
  // #696 — the version the upgrader job judges later updates by.
533
588
  canonicalReleaseTag(targetTag)?.replace(/^v/, "") ??
534
589
  (await releaseVersionForDigest(run, "admin", digests.admin)),
535
- ),
590
+ );
591
+ },
536
592
  resolveOpsImage: resolveOpsImageRef,
537
593
  recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
538
594
  provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
539
595
  },
540
596
  );
597
+ if (rolled && !recorded) {
598
+ log.error(
599
+ red(
600
+ "The new release serves, but the upgrade could not record it (see above). Apply the fixes above; until then this upgrade counts as failed.",
601
+ ),
602
+ );
603
+ }
604
+ const ok = rolled && recorded;
541
605
  // #696 — upgrade never moves Pulumi state; it says how to.
542
606
  const stateNotice = localStateNotice(meta);
543
607
  if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
@@ -586,74 +650,44 @@ async function gcpHealthProbe(
586
650
  }
587
651
 
588
652
  /**
589
- * Record the digests just rolled so nothing re-deploys an older release:
590
- * install.json (what a wizard re-run deploys) and the Pulumi stack config
591
- * (what a plain `pulumi up` deploys). The roll already succeeded, so each
592
- * write that fails warns with the exact manual fix instead of failing the
593
- * upgrade, and one failing never skips the other.
653
+ * Record the digests just rolled (upgrade/record-rolled.ts), wired to this
654
+ * install's install.json and Pulumi stack. The converge job (#819,
655
+ * converge-job-gcp.ts) records through the same function as the terminal.
656
+ *
657
+ * @returns whether every record was written; false fails the upgrade.
594
658
  */
595
- async function recordRolledDigests(
659
+ export function recordRolledRelease(
596
660
  installId: string,
597
661
  provider: "gcp" | "gcp-firebase",
598
662
  digests: ImageDigests,
599
663
  releaseVersion: string | null,
600
- ): Promise<void> {
601
- const reason = (e: unknown) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
602
- if (!releaseVersion) {
603
- log.warn(
604
- yellow(
605
- "Could not tell which Caelo release these images are (no version tag points at them), so install.json records none; in-product updates stay unavailable until an upgrade to a released version.",
606
- ),
607
- );
608
- }
609
- try {
610
- recordImageDigests(installId, digests, releaseVersion);
611
- } catch (e) {
612
- log.warn(
613
- yellow(
614
- `Could not record the rolled digests in the install.json of ${installId} (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`,
615
- ),
616
- );
617
- }
618
- const meta = readMetadata(installId);
619
- const manual = stackConfigRecovery(installId, meta ?? {}, provider, digests);
620
- let backend: ResolvedBackend;
621
- try {
622
- if (!meta) throw new Error(`no install.json for '${installId}'`);
623
- backend = resolveBackend(meta, {
624
- installRoot: installRoot(installId),
625
- readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
626
- });
627
- } catch (e) {
628
- log.warn(
629
- yellow(
630
- `Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
631
- ),
632
- );
633
- return;
634
- }
635
- try {
636
- const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
637
- const { removedOverrides } = await writeImageDigestsToStack({
638
- backend,
639
- provider,
640
- digests,
641
- });
642
- log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
643
- if (removedOverrides.length > 0) {
644
- log.warn(
645
- yellow(
646
- `Removed ${removedOverrides.join(", ")} from the stack config: those image overrides win over the digest pins, so a later \`pulumi up\` would have rolled back to them.`,
647
- ),
648
- );
649
- }
650
- } catch (e) {
651
- log.warn(
652
- yellow(
653
- `Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
654
- ),
655
- );
656
- }
664
+ ): Promise<boolean> {
665
+ // The local copy always names where the state lives (install-state.ts
666
+ // stateStoreFor), and reading it cannot fail on bucket access.
667
+ const local = readLocalMetadata(installId);
668
+ const bucket = local?.stateBackend?.kind === "gcs" ? local.stateBackend.bucket : null;
669
+ return recordRolledDigests(digests, releaseVersion, {
670
+ recordInstall: (d, v) => recordImageDigests(installId, d, v),
671
+ pinStack: async (d) => {
672
+ const meta = readMetadata(installId);
673
+ if (!meta) throw new Error(`no install.json for '${installId}'`);
674
+ const backend: ResolvedBackend = resolveBackend(meta, {
675
+ installRoot: installRoot(installId),
676
+ readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
677
+ });
678
+ const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
679
+ return writeImageDigestsToStack({ backend, provider, digests: d });
680
+ },
681
+ stackRecovery: (d) => stackConfigRecovery(installId, local ?? {}, provider, d),
682
+ installJsonLocation: bucket
683
+ ? `gs://${bucket}/${OPS_INSTALL_PREFIX}/install.json (and ${join(installRoot(installId), "install.json")})`
684
+ : join(installRoot(installId), "install.json"),
685
+ log: {
686
+ info: (m) => log.info(dim(m)),
687
+ warn: (m) => log.warn(yellow(m)),
688
+ error: (m) => log.error(red(m)),
689
+ },
690
+ });
657
691
  }
658
692
 
659
693
  /**
@@ -717,10 +751,13 @@ async function backupOf(meta: InstallMetadata): Promise<boolean> {
717
751
  // rotate-secret <name>
718
752
  // =========================================================================
719
753
 
720
- export async function rotateSecretCommand(name: string | undefined): Promise<void> {
754
+ export async function rotateSecretCommand(
755
+ name: string | undefined,
756
+ opts: { readonly finish?: boolean; readonly now?: boolean } = {},
757
+ ): Promise<void> {
721
758
  if (!name) {
722
- log.error(red("Usage: caelo-cms rotate-secret <name>"));
723
- log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
759
+ log.error(red("Usage: caelo-cms rotate-secret <name> [--finish]"));
760
+ log.warn(`Names: ${[...ROTATABLE_SECRETS, SEAL_KEY_SECRET].join(", ")}`);
724
761
  process.exit(2);
725
762
  }
726
763
  const refusal = rotationRefusal(name);
@@ -728,7 +765,10 @@ export async function rotateSecretCommand(name: string | undefined): Promise<voi
728
765
  log.error(red(refusal));
729
766
  process.exit(2);
730
767
  }
731
- const secret = name as RotatableSecret;
768
+ if (opts.finish && name !== SEAL_KEY_SECRET) {
769
+ log.error(red(`--finish applies to ${SEAL_KEY_SECRET} only; ${name} rotates in one step.`));
770
+ process.exit(2);
771
+ }
732
772
  const { meta } = await requireInstall("rotate-secret");
733
773
  if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
734
774
  log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
@@ -737,6 +777,32 @@ export async function rotateSecretCommand(name: string | undefined): Promise<voi
737
777
  if (!meta.projectId) return;
738
778
  const region = installRegion(meta);
739
779
 
780
+ if (name === SEAL_KEY_SECRET) {
781
+ const gatewayService = await resolveGcpResourceName(
782
+ gcloud,
783
+ "run-service",
784
+ "caelo-production-gateway",
785
+ meta.projectId,
786
+ region,
787
+ );
788
+ if (!gatewayService) {
789
+ log.error(
790
+ red("Could not find the caelo-production-gateway* Cloud Run service — nothing rotated"),
791
+ );
792
+ return;
793
+ }
794
+ const target = { projectId: meta.projectId, region, env: GCP_STACK_ENV, gatewayService };
795
+ const report = opts.finish
796
+ ? await finishSealKeyRotation(target, {}, { now: opts.now === true })
797
+ : await startSealKeyRotation(target);
798
+ for (const step of report.steps) log.info(` ${step}`);
799
+ if (report.ok)
800
+ log.success(green(opts.finish ? "seal-key rotation finished" : "seal-key rotation started"));
801
+ else log.error(red(report.error ?? "seal-key rotation failed"));
802
+ return;
803
+ }
804
+ const secret = name as RotatableSecret;
805
+
740
806
  const s = spinner();
741
807
  s.start(`Rotating ${secret}...`);
742
808
  const services: Partial<Record<"admin" | "gateway", string>> = {};
@@ -330,3 +330,17 @@ describe("resolveOperatorAccessTarget", () => {
330
330
  expect(r2.ok === false && r2.error).toContain("exactly one");
331
331
  });
332
332
  });
333
+
334
+ describe("caeloOperatorAccess role (#806)", () => {
335
+ it("holds IAM-policy and IAP-settings rights on web services and the MCP SA — nothing else", () => {
336
+ expect([...OPERATOR_ACCESS_ROLE.permissions].sort()).toEqual([
337
+ "iam.serviceAccounts.getIamPolicy",
338
+ "iam.serviceAccounts.setIamPolicy",
339
+ "iap.webServices.getIamPolicy",
340
+ // The admin IAP resource's programmatic_clients (remote MCP).
341
+ "iap.webServices.getSettings",
342
+ "iap.webServices.setIamPolicy",
343
+ "iap.webServices.updateSettings",
344
+ ]);
345
+ });
346
+ });
@@ -6,7 +6,9 @@
6
6
  * `cms-provision upgrade`, both as the operator's own gcloud identity.
7
7
  *
8
8
  * Trust boundary. Who may pass the admin's IAP gate and sign as `caelo-mcp`
9
- * follows the Caelo user list. The admin does NOT write that: its runtime
9
+ * follows the Caelo user list; which OAuth client may call the admin
10
+ * programmatically (IAP `programmatic_clients`, remote MCP #806) follows the
11
+ * client the Owner configured under Settings → MCP → Remote. The admin does NOT write that: its runtime
10
12
  * service account holds no IAM-policy rights. A Cloud Run job with its own
11
13
  * service account (admin-core security/operator-access/sync-job.ts) is the
12
14
  * only principal that may change those two bindings, and it only ever grants
@@ -19,7 +21,8 @@
19
21
  *
20
22
  * What this ensures, every step idempotent:
21
23
  * 1. the Cloud Scheduler API;
22
- * 2. the custom role `caeloOperatorAccess` (get/setIamPolicy only);
24
+ * 2. the custom role `caeloOperatorAccess` (get/setIamPolicy, and
25
+ * get/updateSettings for `programmatic_clients`);
23
26
  * 3. the job's service account, bound to that role on exactly the admin's
24
27
  * IAP resource and the `caelo-mcp` service account, plus
25
28
  * `cloudsql.instanceUser` + `logging.logWriter`;
@@ -66,10 +69,17 @@ export const OPERATOR_ACCESS_ROLE: CustomRoleSpec = {
66
69
  roleId: "caeloOperatorAccess",
67
70
  title: "Caelo operator access",
68
71
  description:
69
- "Lets the Caelo operator-access sync job set who passes the admin's IAP gate and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
72
+ "Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client may call it programmatically (remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
70
73
  permissions: [
71
74
  "iap.webServices.getIamPolicy",
72
75
  "iap.webServices.setIamPolicy",
76
+ // #806 — the admin IAP resource's `programmatic_clients` (the remote-MCP
77
+ // OAuth client). IAP settings permissions per resource level:
78
+ // cloud.google.com/iap/docs/customizing ("Web Services": getSettings /
79
+ // updateSettings); the role is bound on exactly the admin's IAP web
80
+ // service, so it reaches no other resource's settings.
81
+ "iap.webServices.getSettings",
82
+ "iap.webServices.updateSettings",
73
83
  "iam.serviceAccounts.getIamPolicy",
74
84
  "iam.serviceAccounts.setIamPolicy",
75
85
  ],
@@ -11,8 +11,9 @@
11
11
  * The admin's upgrader job (Phase 2) only rolls images: it holds no IAM
12
12
  * write, no secret create, no resource create. Anything a release needs
13
13
  * beyond "new image digests + expand-only migrations" must therefore be
14
- * declared here, so the upgrader refuses it and sends the Owner to the
15
- * "Run in Cloud Shell" path instead of half-applying it.
14
+ * declared here, so the upgrader refuses it and the admin routes the
15
+ * release to the converge job (#819; the provisioner's identity) instead
16
+ * of half-applying it.
16
17
  */
17
18
 
18
19
  /**
@@ -52,6 +53,8 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
52
53
  // #875 — the quality-audit job, its service account, results bucket and
53
54
  // grants.
54
55
  "packages/provisioning/src/audit-job-setup.ts",
56
+ // #819 — the converge job, its database login and the admin's grant on it.
57
+ "packages/provisioning/src/converge-job-setup.ts",
55
58
  ];
56
59
 
57
60
  /**
@@ -172,6 +172,48 @@ describe("fetchVerifiedReleaseManifest", () => {
172
172
  expect(r.ok ? "" : r.error.message).toContain("HTTP 404");
173
173
  });
174
174
 
175
+ it("regression #865: a missing manifest or blob (404) is not transient — a retry cannot find it", async () => {
176
+ const missing = await fetchVerifiedReleaseManifest(
177
+ "0.11.0",
178
+ fixture({ manifestStatus: 404 }).deps,
179
+ );
180
+ expect(missing.ok ? null : missing.error).toMatchObject({ code: "registry" });
181
+ expect(missing.ok ? null : missing.error.transient).toBeUndefined();
182
+ const { deps } = fixture();
183
+ const noBlob = await fetchVerifiedReleaseManifest("0.11.0", {
184
+ ...deps,
185
+ fetch: async (url, init) =>
186
+ url.includes("/blobs/") ? new Response("", { status: 404 }) : deps.fetch(url, init),
187
+ });
188
+ expect(noBlob.ok ? null : noBlob.error.code).toBe("registry");
189
+ expect(noBlob.ok ? null : noBlob.error.transient).toBeUndefined();
190
+ });
191
+
192
+ it("regression #865: 5xx, 429 and network failures are transient", async () => {
193
+ for (const status of [500, 503, 429]) {
194
+ const r = await fetchVerifiedReleaseManifest(
195
+ "0.11.0",
196
+ fixture({ manifestStatus: status }).deps,
197
+ );
198
+ expect(r.ok ? null : r.error).toMatchObject({ code: "registry", transient: true });
199
+ }
200
+ const { deps } = fixture();
201
+ const blob503 = await fetchVerifiedReleaseManifest("0.11.0", {
202
+ ...deps,
203
+ fetch: async (url, init) =>
204
+ url.includes("/blobs/") ? new Response("", { status: 503 }) : deps.fetch(url, init),
205
+ });
206
+ expect(blob503.ok ? null : blob503.error).toMatchObject({ code: "registry", transient: true });
207
+ const down = await fetchVerifiedReleaseManifest("0.11.0", {
208
+ ...deps,
209
+ fetch: async () => {
210
+ throw new TypeError("fetch failed: ECONNRESET");
211
+ },
212
+ });
213
+ expect(down.ok ? null : down.error).toMatchObject({ code: "registry", transient: true });
214
+ expect(down.ok ? "" : down.error.message).toContain("ECONNRESET");
215
+ });
216
+
175
217
  it("rejects a non-SemVer version without touching the network", async () => {
176
218
  const { deps, urls } = fixture();
177
219
  const r = await fetchVerifiedReleaseManifest("v0.11.0", deps);
@@ -129,6 +129,8 @@ export type ReleaseManifestErrorCode =
129
129
  export interface ReleaseManifestError {
130
130
  readonly code: ReleaseManifestErrorCode;
131
131
  readonly message: string;
132
+ /** On `registry` errors a retry may cure; absent for not-found (see @caelo-cms/shared ReleaseArtifactError). */
133
+ readonly transient?: true;
132
134
  }
133
135
 
134
136
  export type ReleaseManifestResult =