@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
@@ -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 = 11;
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,35 @@ 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.
78
+ // 11 — #914: the remote-MCP OAuth client becomes the install's Google
79
+ // sign-in client (IAP browser login + programmatic access + broker).
80
+ // Its secret is renamed (`operator-oauth-client`, CLI-generated; the
81
+ // old `mcp-oauth-client` is deleted after the roll), so the gateway's
82
+ // secret volume and env var, the admin's store env var and both
83
+ // services' bindings on it move; the operator-access job's SA gets
84
+ // `secretAccessor` on it (IAP needs the client secret) and the job a
85
+ // new env var; the admin gets CAELO_ADMIN_URL and the project's
86
+ // organisation domain. A new secret, new IAM and a job redeploy —
87
+ // nothing the in-cloud upgrader may do.
59
88
 
60
89
  /**
61
90
  * What each contract version adds, in operator terms — the upgrader's
@@ -71,6 +100,9 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
71
100
  6: "the upgrader's permission to list its ops bucket (#799)",
72
101
  7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
73
102
  8: "the quality-audit job, its service account, results bucket and grants (#875)",
103
+ 9: "the converge job that installs cloud-setup changes from Settings → Updates, and a narrower Cloud Run role for the upgrader (#819)",
104
+ 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)",
105
+ 11: "Google sign-in: the renamed sign-in client secret, its IAM and mounts, the operator-access job's read access to it, and the admin's URL and organisation domain (#914)",
74
106
  };
75
107
 
76
108
  /**
@@ -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>> = {};
@@ -161,10 +161,19 @@ describe("ensureOperatorAccessSync", () => {
161
161
  "--args=--bun,/app/packages/admin-core/src/security/operator-access/sync-job.ts",
162
162
  );
163
163
  expect(deploy).toContain(
164
- "--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=10.0.0.3",
164
+ "--set-env-vars=^|^CAELO_OPERATOR_ACCESS_IAP_WEB=cloud_run-europe-west1/services/caelo-production-admin-abc|CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@p.iam.gserviceaccount.com|CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=user:owner@x.com|CAELO_OPERATOR_ACCESS_DB_HOST=10.0.0.3|CAELO_OPERATOR_OAUTH_CLIENT_SECRET=projects/p/secrets/caelo-production-operator-oauth-client",
165
165
  );
166
166
  });
167
167
 
168
+ it("#914 — lets the job read exactly the Google sign-in client's secret, nothing else", async () => {
169
+ const { run, lines } = freshInstall();
170
+ await ensureOperatorAccessSync(target, { run, sleep: async () => {} });
171
+ const secretGrants = lines().filter((l) => l.startsWith("secrets add-iam-policy-binding"));
172
+ expect(secretGrants).toEqual([
173
+ `secrets add-iam-policy-binding caelo-production-operator-oauth-client --member=serviceAccount:${JOB_SA} --role=roles/secretmanager.secretAccessor --condition=None --project=p --quiet --format=none`,
174
+ ]);
175
+ });
176
+
168
177
  it("schedules an hourly run as the job's own SA", async () => {
169
178
  const { run, lines } = freshInstall();
170
179
  await ensureOperatorAccessSync(target, { run, sleep: async () => {} });
@@ -330,3 +339,17 @@ describe("resolveOperatorAccessTarget", () => {
330
339
  expect(r2.ok === false && r2.error).toContain("exactly one");
331
340
  });
332
341
  });
342
+
343
+ describe("caeloOperatorAccess role (#806)", () => {
344
+ it("holds IAM-policy and IAP-settings rights on web services and the MCP SA — nothing else", () => {
345
+ expect([...OPERATOR_ACCESS_ROLE.permissions].sort()).toEqual([
346
+ "iam.serviceAccounts.getIamPolicy",
347
+ "iam.serviceAccounts.setIamPolicy",
348
+ "iap.webServices.getIamPolicy",
349
+ // The admin IAP resource's oauthSettings (Google sign-in, #914).
350
+ "iap.webServices.getSettings",
351
+ "iap.webServices.setIamPolicy",
352
+ "iap.webServices.updateSettings",
353
+ ]);
354
+ });
355
+ });
@@ -6,8 +6,11 @@
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
10
- * service account holds no IAM-policy rights. A Cloud Run job with its own
9
+ * follows the Caelo user list; which OAuth client IAP signs people in with
10
+ * and accepts programmatically (IAP `oauthSettings`: browser client, its
11
+ * secret and `programmaticClients`, #914) follows the Google sign-in client
12
+ * the Owner configured under Settings → Google sign-in. The admin does NOT
13
+ * write that: its runtime service account holds no IAM-policy rights. A Cloud Run job with its own
11
14
  * service account (admin-core security/operator-access/sync-job.ts) is the
12
15
  * only principal that may change those two bindings, and it only ever grants
13
16
  * individual `user:` emails read from the database. The admin may only start
@@ -19,10 +22,13 @@
19
22
  *
20
23
  * What this ensures, every step idempotent:
21
24
  * 1. the Cloud Scheduler API;
22
- * 2. the custom role `caeloOperatorAccess` (get/setIamPolicy only);
25
+ * 2. the custom role `caeloOperatorAccess` (get/setIamPolicy, and
26
+ * get/updateSettings for `oauthSettings`);
23
27
  * 3. the job's service account, bound to that role on exactly the admin's
24
- * IAP resource and the `caelo-mcp` service account, plus
25
- * `cloudsql.instanceUser` + `logging.logWriter`;
28
+ * IAP resource and the `caelo-mcp` service account, `secretAccessor`
29
+ * on exactly the Google sign-in client's secret (#914: IAP needs the
30
+ * client secret too), plus `cloudsql.instanceUser` +
31
+ * `logging.logWriter`;
26
32
  * 4. removal of the admin run SA's operator-access grants an earlier
27
33
  * revision of this feature gave it;
28
34
  * 5. Cloud SQL IAM database authentication (flag + the job's IAM database
@@ -42,6 +48,7 @@ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
42
48
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
43
49
  import {
44
50
  gcpNamePrefix,
51
+ gcpSecretId,
45
52
  mcpIapServiceAccountEmail,
46
53
  operatorAccessJobName,
47
54
  operatorAccessScheduleName,
@@ -50,6 +57,7 @@ import {
50
57
  runServiceAccountEmail,
51
58
  } from "./gcp-names.js";
52
59
  import { type IapResource, iapResourceArgs } from "./mcp-iap.js";
60
+ import { operatorOAuthClientSecretResource } from "./stack-contract.js";
53
61
 
54
62
  type Run = (args: string[]) => Promise<GcloudResult>;
55
63
 
@@ -66,10 +74,17 @@ export const OPERATOR_ACCESS_ROLE: CustomRoleSpec = {
66
74
  roleId: "caeloOperatorAccess",
67
75
  title: "Caelo operator access",
68
76
  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.",
77
+ "Lets the Caelo operator-access sync job set who passes the admin's IAP gate, which OAuth client IAP signs people in with and accepts programmatically (Google sign-in, remote MCP), and who may sign as caelo-mcp. Bound only on those two resources, only to the job's service account.",
70
78
  permissions: [
71
79
  "iap.webServices.getIamPolicy",
72
80
  "iap.webServices.setIamPolicy",
81
+ // #914 — the admin IAP resource's `oauthSettings` (the install's Google
82
+ // sign-in client). IAP settings permissions per resource level:
83
+ // cloud.google.com/iap/docs/customizing ("Web Services": getSettings /
84
+ // updateSettings); the role is bound on exactly the admin's IAP web
85
+ // service, so it reaches no other resource's settings.
86
+ "iap.webServices.getSettings",
87
+ "iap.webServices.updateSettings",
73
88
  "iam.serviceAccounts.getIamPolicy",
74
89
  "iam.serviceAccounts.setIamPolicy",
75
90
  ],
@@ -186,6 +201,12 @@ export interface OperatorAccessSyncTarget {
186
201
  /** Env var of the sync job listing {@link OperatorAccessSyncTarget.mcpSigners}. */
187
202
  export const MCP_SIGNERS_ENV = "CAELO_OPERATOR_ACCESS_MCP_SIGNERS";
188
203
 
204
+ /** #914 — env var of the sync job naming the Google sign-in client's secret. */
205
+ export const OPERATOR_OAUTH_CLIENT_SECRET_ENV = "CAELO_OPERATOR_OAUTH_CLIENT_SECRET";
206
+
207
+ /** The role the job holds on exactly the Google sign-in client's secret (#914). */
208
+ export const OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE = "roles/secretmanager.secretAccessor";
209
+
189
210
  export type OperatorAccessSyncResult =
190
211
  | { readonly ok: true; readonly done: string[] }
191
212
  | { readonly ok: false; readonly done: string[]; readonly error: string };
@@ -276,6 +297,22 @@ export async function ensureOperatorAccessSync(
276
297
  if (!r.ok) return fail(`grant the job ${g.what}`, r);
277
298
  done.push(`job: ${g.what}`);
278
299
  }
300
+ // #914 — the job hands the Google sign-in client to IAP (`oauthSettings`
301
+ // needs the secret too), so it reads that one secret; no other.
302
+ const clientSecret = gcpSecretId(env, "operator-oauth-client");
303
+ const accessor = await retry([
304
+ "secrets",
305
+ "add-iam-policy-binding",
306
+ clientSecret,
307
+ `--member=${jobMember}`,
308
+ `--role=${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE}`,
309
+ "--condition=None",
310
+ project,
311
+ "--quiet",
312
+ "--format=none",
313
+ ]);
314
+ if (!accessor.ok) return fail(`grant the job read access to ${clientSecret}`, accessor);
315
+ done.push(`job: ${OPERATOR_OAUTH_CLIENT_ACCESSOR_ROLE} on ${clientSecret}`);
279
316
  for (const role of ["roles/cloudsql.instanceUser", "roles/logging.logWriter"]) {
280
317
  const r = await retry([
281
318
  "projects",
@@ -354,6 +391,7 @@ export async function ensureOperatorAccessSync(
354
391
  `CAELO_MCP_IAP_SERVICE_ACCOUNT=${mcpSa}`,
355
392
  `CAELO_OPERATOR_ACCESS_STATIC_MEMBERS=${installIapAllowlist(target.ownerEmail).join(",")}`,
356
393
  `CAELO_OPERATOR_ACCESS_DB_HOST=${target.databaseHost}`,
394
+ `${OPERATOR_OAUTH_CLIENT_SECRET_ENV}=${operatorOAuthClientSecretResource(projectId, env)}`,
357
395
  ...(target.mcpSigners && target.mcpSigners.length > 0
358
396
  ? [`${MCP_SIGNERS_ENV}=${target.mcpSigners.join(",")}`]
359
397
  : []),
@@ -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
  /**