@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,12 +22,24 @@
22
22
  */
23
23
 
24
24
  import { spawnSync } from "node:child_process";
25
+ import { randomUUID } from "node:crypto";
25
26
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
26
- import { hostname } from "node:os";
27
27
  import { join } from "node:path";
28
28
  import { assertNoCheckpointSecrets, assertNoSecrets } from "./checkpoint-secrets.js";
29
29
  import { type GcloudResult, gcloudEnv } from "./gcloud.js";
30
30
  import { OPS_INSTALL_PREFIX } from "./state-backend.js";
31
+ import {
32
+ describeExecutionArgs,
33
+ type ExecutionLockHolder,
34
+ judgeHeldLock,
35
+ type LocalHost,
36
+ type LockHolder,
37
+ lockBody,
38
+ PROCESS_LEASE_MS,
39
+ parseExecutionState,
40
+ parseLockBody,
41
+ renewedLockBody,
42
+ } from "./state-lock-lease.js";
31
43
 
32
44
  /** The two documents a store holds. */
33
45
  export type StateFile = "install.json" | "progress.json";
@@ -176,58 +188,324 @@ export function bucketStateLockUrl(bucket: string): string {
176
188
  return `gs://${bucket}/${OPS_INSTALL_PREFIX}/state.lock`;
177
189
  }
178
190
 
179
- /** Who holds a lock, for the refusal message. */
180
- function lockBody(purpose: string): string {
181
- return JSON.stringify({
182
- purpose,
183
- host: hostname(),
184
- pid: process.pid,
185
- startedAt: new Date().toISOString(),
186
- });
191
+ /** gcloud's wording for a failed `--if-generation-match` precondition. */
192
+ const PRECONDITION_FAILED = /PreconditionFailed|412|At least one of the pre-conditions/i;
193
+
194
+ /** How a bucket lock is taken: who holds it, and the clock to judge leases by. */
195
+ export interface BucketLockOptions {
196
+ /**
197
+ * The Cloud Run execution taking the lock (#815): the lock's lease is the
198
+ * execution's task timeout, and a later run may break it once Cloud Run
199
+ * says this execution has finished. Without it the holder is this
200
+ * process (a CLI run): the lock names host + pid, carries a lease of
201
+ * {@link PROCESS_LEASE_MS} and is renewed by a heartbeat until released.
202
+ */
203
+ readonly holder?: ExecutionLockHolder;
204
+ /** Told when a dead holder's lock was broken, with why. */
205
+ readonly onBreak?: (message: string) => void;
206
+ /**
207
+ * Told when the heartbeat found the lock no longer this run's (another
208
+ * run broke it after the lease ran out, e.g. while this machine slept).
209
+ */
210
+ readonly onLost?: (message: string) => void;
211
+ readonly now?: () => Date;
212
+ /** Starts the heartbeat; returns its stop. Defaults to an unref'd setInterval. */
213
+ readonly schedule?: (tick: () => void, everyMs: number) => () => void;
214
+ /** This host and its pid check (state-lock-lease.ts LocalHost); tests pass a fake. */
215
+ readonly here?: LocalHost;
187
216
  }
188
217
 
218
+ const defaultSchedule = (tick: () => void, everyMs: number): (() => void) => {
219
+ const timer = setInterval(tick, everyMs);
220
+ // The heartbeat must never keep a finished CLI alive.
221
+ timer.unref?.();
222
+ return () => clearInterval(timer);
223
+ };
224
+
189
225
  /**
190
226
  * The install's state lock in its ops bucket, created only if absent
191
227
  * (`--if-generation-match=0`). Held by every writer of a bucket-backed
192
- * install's state — an installer run's `pulumi up`, `state migrate` — so
193
- * none writes a backend another is switching away from. A lock left by a
194
- * crashed run is reported with the command to remove it, never broken
195
- * automatically.
228
+ * install's state — an installer run's `pulumi up`, `state migrate`, the
229
+ * CLI `upgrade`, the upgrader job — so none writes a backend another is
230
+ * switching away from.
196
231
  *
197
- * @returns the release function
232
+ * A lock that is held is judged (state-lock-lease.ts): one whose Cloud Run
233
+ * execution has finished, whose execution is gone after its lease, whose
234
+ * CLI process is gone on this host, or whose CLI lease ran out unrenewed,
235
+ * is broken with a generation-matched delete and taken once more. The
236
+ * generation is read BEFORE the body, so the delete can only remove the
237
+ * lock that was judged: if another run replaced it in between, the
238
+ * delete's precondition fails and nothing is removed. Two runs breaking the
239
+ * same dead lock cannot both win either — the slower one's create fails
240
+ * its own precondition and reports the lock as held.
241
+ *
242
+ * A CLI holder renews its lease every third of {@link PROCESS_LEASE_MS}
243
+ * with a generation-matched rewrite, so a run that is alive keeps its lock
244
+ * however long it takes, and a killed one loses it within one lease.
245
+ *
246
+ * @returns the release function; it stops the heartbeat and deletes only
247
+ * this lock's generation
248
+ * @throws StateLockHeldError when another run holds the lock
249
+ * @throws Error when the lock or its holder cannot be read (no access)
198
250
  */
199
251
  export function acquireBucketStateLock(
200
252
  bucket: string,
201
253
  purpose: string,
202
254
  run: SyncGcloud = realSyncGcloud,
255
+ opts: BucketLockOptions = {},
203
256
  ): () => void {
204
257
  const url = bucketStateLockUrl(bucket);
205
- const r = run(["storage", "cp", "-", url, "--if-generation-match=0"], lockBody(purpose));
206
- if (!r.ok) {
207
- if (/PreconditionFailed|412|At least one of the pre-conditions/i.test(r.stderr)) {
208
- const held = run(["storage", "cat", url]);
258
+ const now = opts.now ?? (() => new Date());
259
+ const holder: LockHolder = opts.holder ?? { leaseMs: PROCESS_LEASE_MS };
260
+ const id = randomUUID();
261
+ const create = () =>
262
+ run(
263
+ ["storage", "cp", "-", url, "--if-generation-match=0"],
264
+ lockBody(purpose, now(), holder, id),
265
+ );
266
+ let r = create();
267
+ if (!r.ok && PRECONDITION_FAILED.test(r.stderr)) {
268
+ breakDeadLock(url, run, now(), opts.onBreak, opts.here);
269
+ r = create();
270
+ if (!r.ok && PRECONDITION_FAILED.test(r.stderr)) {
209
271
  throw new StateLockHeldError(
210
- `Another run is changing this install's state (${held.ok ? held.stdout.trim() : url}). Re-run once it has finished; if none is running, remove the stale lock: gcloud storage rm ${url}`,
272
+ `Another run took this install's state lock at the same moment (${readLockText(url, run)}). Re-run once it has finished.`,
211
273
  );
212
274
  }
213
- throw new Error(`Could not take the state lock ${url}: ${r.stderr.trim()}`);
214
275
  }
276
+ if (!r.ok) throw new Error(`Could not take the state lock ${url}: ${r.stderr.trim()}`);
277
+ const owned = ownedGeneration(url, run, id);
278
+ if (owned.kind !== "owned") {
279
+ // Ours, just created — but without its generation a delete could hit a
280
+ // successor's lock. Give it back only once a second look proves it is
281
+ // still ours at a known generation; otherwise its lease ends it.
282
+ const retry = ownedGeneration(url, run, id);
283
+ if (retry.kind === "owned") {
284
+ run(["storage", "rm", url, `--if-generation-match=${retry.generation}`]);
285
+ }
286
+ throw new Error(
287
+ `Could not read the generation of the state lock ${url} this run just took (${owned.why}). ${
288
+ retry.kind === "owned"
289
+ ? "It was given back."
290
+ : "It was left in place and expires with its lease; re-run after that."
291
+ }`,
292
+ );
293
+ }
294
+ let generation = owned.generation;
295
+ let lost: string | null = null;
296
+ const stopHeartbeat =
297
+ "execution" in holder
298
+ ? () => {}
299
+ : (opts.schedule ?? defaultSchedule)(
300
+ () => {
301
+ if (lost !== null) return;
302
+ const renewed = renewLock(url, run, id, generation, now(), holder.leaseMs);
303
+ if (renewed.kind === "owned") {
304
+ generation = renewed.generation;
305
+ return;
306
+ }
307
+ if (renewed.kind === "lost") {
308
+ lost = renewed.why;
309
+ opts.onLost?.(
310
+ `This run no longer holds the install's state lock (${renewed.why}). Stop it and re-run once the other run has finished.`,
311
+ );
312
+ }
313
+ // "error": a transient failure; the next beat tries again within the lease.
314
+ },
315
+ Math.floor(holder.leaseMs / 3),
316
+ );
215
317
  return () => {
216
- const rm = run(["storage", "rm", url]);
217
- if (!rm.ok) throw new Error(`Could not release ${url}; remove it: gcloud storage rm ${url}`);
318
+ stopHeartbeat();
319
+ const rm = run(["storage", "rm", url, `--if-generation-match=${generation}`]);
320
+ if (rm.ok) return;
321
+ if (PRECONDITION_FAILED.test(rm.stderr) || NOT_FOUND.test(rm.stderr)) {
322
+ throw new Error(
323
+ `The state lock ${url} was no longer this run's when it finished (another run broke it); it was left alone.`,
324
+ );
325
+ }
326
+ throw new Error(`Could not release ${url}; remove it: gcloud storage rm ${url}`);
218
327
  };
219
328
  }
220
329
 
330
+ type Ownership =
331
+ | { readonly kind: "owned"; readonly generation: string }
332
+ | { readonly kind: "lost"; readonly why: string }
333
+ | { readonly kind: "error"; readonly why: string };
334
+
335
+ /**
336
+ * The lock's generation, provided the lock is still the one with `id`. The
337
+ * generation is read before the body: if another run replaced the lock in
338
+ * between, the body names its id and this returns "lost" — never a
339
+ * generation that is not ours.
340
+ */
341
+ function ownedGeneration(url: string, run: SyncGcloud, id: string): Ownership {
342
+ const g = lockGeneration(url, run);
343
+ if (g.kind === "absent") return { kind: "lost", why: "the lock is gone" };
344
+ if (g.kind === "error") return { kind: "error", why: g.error };
345
+ const body = run(["storage", "cat", url]);
346
+ if (!body.ok) {
347
+ return NOT_FOUND.test(body.stderr)
348
+ ? { kind: "lost", why: "the lock is gone" }
349
+ : { kind: "error", why: body.stderr.trim() || `gcloud exited ${body.exitCode}` };
350
+ }
351
+ const parsed = parseLockBody(body.stdout);
352
+ if (parsed?.id !== id) {
353
+ return { kind: "lost", why: `another run holds it now (${body.stdout.trim().slice(0, 200)})` };
354
+ }
355
+ return { kind: "owned", generation: g.generation };
356
+ }
357
+
358
+ /**
359
+ * One heartbeat: rewrite the lock with a later lease, only if it is still
360
+ * at `generation` (ours), and return its new generation.
361
+ */
362
+ function renewLock(
363
+ url: string,
364
+ run: SyncGcloud,
365
+ id: string,
366
+ generation: string,
367
+ now: Date,
368
+ leaseMs: number,
369
+ ): Ownership {
370
+ const current = run(["storage", "cat", url]);
371
+ if (!current.ok) {
372
+ return NOT_FOUND.test(current.stderr)
373
+ ? { kind: "lost", why: "the lock is gone" }
374
+ : { kind: "error", why: current.stderr.trim() };
375
+ }
376
+ const body = parseLockBody(current.stdout);
377
+ if (body?.id !== id) {
378
+ return {
379
+ kind: "lost",
380
+ why: `another run holds it now (${current.stdout.trim().slice(0, 200)})`,
381
+ };
382
+ }
383
+ const w = run(
384
+ ["storage", "cp", "-", url, `--if-generation-match=${generation}`],
385
+ renewedLockBody(body, now, leaseMs),
386
+ );
387
+ if (!w.ok) {
388
+ return PRECONDITION_FAILED.test(w.stderr)
389
+ ? { kind: "lost", why: "another run replaced it" }
390
+ : { kind: "error", why: w.stderr.trim() };
391
+ }
392
+ return ownedGeneration(url, run, id);
393
+ }
394
+
395
+ type LockGeneration =
396
+ | { readonly kind: "found"; readonly generation: string }
397
+ | { readonly kind: "absent" }
398
+ | { readonly kind: "error"; readonly error: string };
399
+
400
+ /** The lock object's current generation. */
401
+ function lockGeneration(url: string, run: SyncGcloud): LockGeneration {
402
+ const d = run(["storage", "objects", "describe", url, "--format=json"]);
403
+ if (!d.ok) {
404
+ return NOT_FOUND.test(d.stderr)
405
+ ? { kind: "absent" }
406
+ : { kind: "error", error: d.stderr.trim() || `gcloud exited ${d.exitCode}` };
407
+ }
408
+ try {
409
+ // `gcloud storage objects describe` prints the generation as a string
410
+ // (`generation: '1706304784726341'`, cloud.google.com/storage/docs/
411
+ // viewing-editing-metadata); a JSON number would lose digits past 2^53,
412
+ // so only a string of digits is a generation the delete may match.
413
+ const g = (JSON.parse(d.stdout) as { generation?: unknown }).generation;
414
+ if (typeof g === "string" && /^[1-9][0-9]*$/.test(g)) {
415
+ return { kind: "found", generation: g };
416
+ }
417
+ } catch {
418
+ // Reported below with the same message as a missing field.
419
+ }
420
+ return { kind: "error", error: "gcloud described the lock without a generation" };
421
+ }
422
+
423
+ function readLockText(url: string, run: SyncGcloud): string {
424
+ const held = run(["storage", "cat", url]);
425
+ return held.ok ? held.stdout.trim() : url;
426
+ }
427
+
428
+ /**
429
+ * Judge the held lock at `url` and break it when its holder is dead.
430
+ * Returns normally when the lock was broken or vanished meanwhile, so the
431
+ * caller creates it once more; throws when it is live or cannot be judged.
432
+ */
433
+ function breakDeadLock(
434
+ url: string,
435
+ run: SyncGcloud,
436
+ now: Date,
437
+ onBreak: ((message: string) => void) | undefined,
438
+ here: LocalHost | undefined,
439
+ ): void {
440
+ const found = lockGeneration(url, run);
441
+ if (found.kind === "absent") return;
442
+ if (found.kind === "error") {
443
+ throw new Error(`Could not read the state lock ${url} another run holds: ${found.error}`);
444
+ }
445
+ const held = run(["storage", "cat", url]);
446
+ if (!held.ok) {
447
+ if (NOT_FOUND.test(held.stderr)) return;
448
+ throw new Error(`Could not read the state lock ${url}: ${held.stderr.trim()}`);
449
+ }
450
+ const verdict = judgeHeldLock(
451
+ held.stdout,
452
+ (ref) => parseExecutionState(run(describeExecutionArgs(ref))),
453
+ now,
454
+ here,
455
+ );
456
+ if (verdict.kind === "unknown") {
457
+ // Not contention: retrying will not help, and guessing "dead" could let
458
+ // two runs write the same state.
459
+ throw new Error(`The state lock ${url} is held and ${verdict.why}`);
460
+ }
461
+ if (verdict.kind === "held") {
462
+ throw new StateLockHeldError(
463
+ verdict.manualRemoval
464
+ ? `Another run is changing this install's state (${verdict.why}). Re-run once it has finished; if none is running, remove the stale lock: gcloud storage rm ${url}`
465
+ : `Another run is changing this install's state: ${verdict.why}. Re-run once it has finished.`,
466
+ );
467
+ }
468
+ const rm = run(["storage", "rm", url, `--if-generation-match=${found.generation}`]);
469
+ if (!rm.ok && !NOT_FOUND.test(rm.stderr)) {
470
+ if (PRECONDITION_FAILED.test(rm.stderr)) {
471
+ throw new StateLockHeldError(
472
+ `Another run took this install's state lock while this one removed a stale one (${readLockText(url, run)}). Re-run once it has finished.`,
473
+ );
474
+ }
475
+ throw new Error(`Could not remove the stale state lock ${url}: ${rm.stderr.trim()}`);
476
+ }
477
+ onBreak?.(`Removed a stale state lock: ${verdict.why}.`);
478
+ }
479
+
221
480
  /**
222
481
  * The same lock for an install whose state is on this machine:
223
- * `~/.caelo-<id>/state.lock`, created exclusively.
482
+ * `~/.caelo-<id>/state.lock`, created exclusively. A lock left by a
483
+ * process that is gone on this machine is taken over (#815).
224
484
  *
485
+ * @param here this host and its pid check; tests pass a fake
225
486
  * @returns the release function
226
487
  */
227
- export function acquireLocalStateLock(root: string, purpose: string): () => void {
488
+ export function acquireLocalStateLock(root: string, purpose: string, here?: LocalHost): () => void {
228
489
  const path = join(root, "state.lock");
490
+ const take = () =>
491
+ writeFileSync(path, lockBody(purpose, new Date()), { flag: "wx", mode: 0o600 });
229
492
  try {
230
- writeFileSync(path, lockBody(purpose), { flag: "wx", mode: 0o600 });
493
+ try {
494
+ take();
495
+ } catch (e) {
496
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
497
+ // #815 — the file is on this machine, so a holder whose process has
498
+ // ended is known dead: take its lock over instead of asking for `rm`.
499
+ const verdict = judgeHeldLock(
500
+ readFileSync(path, "utf8"),
501
+ () => ({ kind: "unknown", error: "a machine-local lock names no Cloud Run execution" }),
502
+ new Date(),
503
+ here,
504
+ );
505
+ if (verdict.kind !== "break") throw e;
506
+ rmSync(path, { force: true });
507
+ take();
508
+ }
231
509
  } catch (e) {
232
510
  if ((e as NodeJS.ErrnoException).code === "EEXIST") {
233
511
  throw new StateLockHeldError(
@@ -245,8 +523,50 @@ export function acquireStateLock(
245
523
  root: string,
246
524
  purpose: string,
247
525
  run: SyncGcloud = realSyncGcloud,
526
+ opts: BucketLockOptions = {},
248
527
  ): () => void {
249
528
  return backend.kind === "gcs"
250
- ? acquireBucketStateLock(backend.bucket, purpose, run)
251
- : acquireLocalStateLock(root, purpose);
529
+ ? acquireBucketStateLock(backend.bucket, purpose, run, opts)
530
+ : acquireLocalStateLock(root, purpose, opts.here);
531
+ }
532
+
533
+ /** The part of `process` {@link releaseOnSignal} needs; tests pass a fake. */
534
+ export interface SignalTarget {
535
+ once(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
536
+ removeListener(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
537
+ exit(code: number): never;
538
+ }
539
+
540
+ /**
541
+ * Give the state lock back when the CLI is stopped (Ctrl-C, a closed Cloud
542
+ * Shell tab's SIGTERM), then exit with the signal's conventional code
543
+ * (#815). Without this a stopped run leaves its lock until its lease ends.
544
+ *
545
+ * @param report told what happened to the lock
546
+ * @returns removes the handlers; call it once the run released the lock itself
547
+ */
548
+ export function releaseOnSignal(
549
+ release: () => void,
550
+ report: (message: string) => void,
551
+ proc: SignalTarget = process,
552
+ ): () => void {
553
+ const dispose = () => {
554
+ proc.removeListener("SIGINT", onInt);
555
+ proc.removeListener("SIGTERM", onTerm);
556
+ };
557
+ const stop = (signal: "SIGINT" | "SIGTERM", code: number): never => {
558
+ dispose();
559
+ try {
560
+ release();
561
+ report(`Stopped (${signal}); the install's state lock was released.`);
562
+ } catch (e) {
563
+ report(`Stopped (${signal}); ${e instanceof Error ? e.message : String(e)}`);
564
+ }
565
+ return proc.exit(code);
566
+ };
567
+ const onInt = () => stop("SIGINT", 130);
568
+ const onTerm = () => stop("SIGTERM", 143);
569
+ proc.once("SIGINT", onInt);
570
+ proc.once("SIGTERM", onTerm);
571
+ return dispose;
252
572
  }
@@ -27,7 +27,13 @@
27
27
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
28
28
  import { gatewayServiceAccountEmail, runServiceAccountEmail } from "../gcp-names.js";
29
29
  import type { ImageDigests } from "../install-state.js";
30
- import { adminMediaVolume } from "../stack-contract.js";
30
+ import {
31
+ adminMediaVolume,
32
+ type CloudRunEnvVar,
33
+ GATEWAY_TIMEOUT_SECONDS,
34
+ RETIRED_SECRET_FILES,
35
+ SERVICE_SECRET_FILES,
36
+ } from "../stack-contract.js";
31
37
  import {
32
38
  type DeployedService,
33
39
  type EnvChange,
@@ -35,10 +41,13 @@ import {
35
41
  type LiveVolumes,
36
42
  liveContainerEnv,
37
43
  liveContainerMemory,
44
+ liveTimeoutSeconds,
38
45
  liveVolumes,
39
46
  planAdminMemory,
40
47
  planContractEnv,
41
48
  planMediaVolume,
49
+ planRequestTimeout,
50
+ planSecretFiles,
42
51
  rollService,
43
52
  serviceRollArgs,
44
53
  } from "../stack-converge.js";
@@ -89,15 +98,25 @@ export interface ServicePlan {
89
98
  /** Container memory limit now (null = Cloud Run's default). */
90
99
  readonly liveMemory: string | null;
91
100
  readonly liveVolumes: LiveVolumes;
101
+ /** Request timeout now, in seconds (null = Cloud Run's default). */
102
+ readonly liveTimeout: number | null;
92
103
  }
93
104
 
94
105
  /** A service plan plus the changes its roll applies. */
95
106
  export interface RollPlan extends ServicePlan {
96
107
  readonly envFlags: readonly string[];
97
108
  readonly envChanges: readonly EnvChange[];
98
- /** #553 — e.g. `--memory=2Gi` when the admin runs below the stack default. */
109
+ /**
110
+ * #553 — e.g. `--memory=2Gi` when the admin runs below the stack default;
111
+ * #806 — `--timeout=3600` when the gateway runs below its contract timeout.
112
+ */
99
113
  readonly resourceFlags: readonly string[];
100
- /** The admin's media bucket volume when it is (partly) missing; empty otherwise. */
114
+ readonly resourceChanges: readonly string[];
115
+ /**
116
+ * The admin's media bucket volume when it is (partly) missing, the
117
+ * service's secret files (#806, the gateway's OAuth client) when not
118
+ * mounted; empty otherwise.
119
+ */
101
120
  readonly volumeFlags: readonly string[];
102
121
  readonly volumeChanges: readonly string[];
103
122
  }
@@ -164,9 +183,10 @@ export async function planAppRoll(
164
183
  liveEnv: liveContainerEnv(json),
165
184
  liveMemory: liveContainerMemory(json),
166
185
  liveVolumes: liveVolumes(json),
186
+ liveTimeout: liveTimeoutSeconds(json),
167
187
  });
168
188
  }
169
- const env = planEnv(install, services, []);
189
+ const env = planEnv(install, services, {});
170
190
  if (!env.ok)
171
191
  return { ok: false, error: `the install's env contract can't be applied: ${env.error}` };
172
192
  // Without the media volume every upload is lost on the next revision or
@@ -178,28 +198,65 @@ export async function planAppRoll(
178
198
  );
179
199
  if (!media.ok)
180
200
  return { ok: false, error: `the admin's media volume can't be set up: ${media.error}` };
201
+ // #806/#914 — each service's secret files (the gateway's Google sign-in
202
+ // client). A file the roll cannot mount fails the plan: the new gateway
203
+ // reads its path from the env contract.
204
+ const secretFiles = {} as Record<RollSlug, { flags: string[]; changes: string[] }>;
205
+ for (const s of services) {
206
+ const files = planSecretFiles(
207
+ s.liveVolumes,
208
+ SERVICE_SECRET_FILES[s.slug],
209
+ install.env,
210
+ RETIRED_SECRET_FILES[s.slug],
211
+ );
212
+ if (!files.ok) {
213
+ return { ok: false, error: `the ${s.slug}'s secret files can't be mounted: ${files.error}` };
214
+ }
215
+ secretFiles[s.slug] = files;
216
+ }
181
217
  const warnings: string[] = [];
182
218
  const rolls = services.map((s): RollPlan => {
183
219
  // #553 — the admin runs the Lighthouse quality audit; raise it to the
184
220
  // stack's memory default (never lowered; an unparsable value is kept).
185
221
  const memory = s.slug === "admin" ? planAdminMemory(s.liveMemory) : null;
186
222
  if (memory && !memory.ok) warnings.push(`admin memory left as is: ${memory.error}`);
223
+ // #806 — the gateway streams remote-MCP chat turns (stack-contract.ts
224
+ // GATEWAY_TIMEOUT_SECONDS). The admin's timeout is the stacks' alone.
225
+ const timeout =
226
+ s.slug === "gateway" ? planRequestTimeout(s.liveTimeout, GATEWAY_TIMEOUT_SECONDS) : null;
227
+ const memoryFlags = memory?.ok ? memory.flags : [];
228
+ const timeoutFlags = timeout?.ok ? timeout.flags : [];
187
229
  return {
188
230
  ...s,
189
231
  envFlags: env.services[s.slug].flags,
190
232
  envChanges: env.services[s.slug].changes,
191
- resourceFlags: memory?.ok ? memory.flags : [],
192
- volumeFlags: s.slug === "admin" ? media.flags : [],
193
- volumeChanges: s.slug === "admin" ? media.changes : [],
233
+ resourceFlags: [...memoryFlags, ...timeoutFlags],
234
+ resourceChanges: [
235
+ ...memoryFlags.map((f) => `${s.liveMemory ?? "(default)"} → ${f}`),
236
+ ...(timeout?.ok ? timeout.changes : []),
237
+ ],
238
+ volumeFlags: [...(s.slug === "admin" ? media.flags : []), ...secretFiles[s.slug].flags],
239
+ volumeChanges: [...(s.slug === "admin" ? media.changes : []), ...secretFiles[s.slug].changes],
194
240
  };
195
241
  });
196
242
  return { ok: true, plan: { install, services: rolls, warnings } };
197
243
  }
198
244
 
245
+ /** Env the converge half adds to, or keeps out of, the rolls ({@link AppRollHooks.afterMigrations}). */
246
+ export interface EnvAdjustments {
247
+ /** Contract vars to leave exactly as the services have them. */
248
+ readonly leaveUntouched?: readonly string[];
249
+ /**
250
+ * Vars outside the env contract the rolls set too — #806's remote-MCP
251
+ * identity env, which only the converge (operator identity) can resolve.
252
+ */
253
+ readonly extraEnv?: Readonly<Partial<Record<RollSlug, readonly CloudRunEnvVar[]>>>;
254
+ }
255
+
199
256
  function planEnv(
200
257
  install: AppRollInstall,
201
258
  services: readonly ServicePlan[],
202
- leaveUntouched: readonly string[],
259
+ adjust: EnvAdjustments,
203
260
  ) {
204
261
  const deployed = Object.fromEntries(
205
262
  services.map((s) => [s.slug, { serviceName: s.serviceName, liveEnv: s.liveEnv }]),
@@ -214,17 +271,22 @@ function planEnv(
214
271
  stagingHost: install.stagingHost,
215
272
  },
216
273
  deployed,
217
- { leaveUntouched },
274
+ {
275
+ leaveUntouched: adjust.leaveUntouched ?? [],
276
+ ...(adjust.extraEnv ? { extraEnv: adjust.extraEnv } : {}),
277
+ },
218
278
  );
219
279
  }
220
280
 
221
281
  /**
222
- * The plan with `names` left exactly as the services have them — for env
223
- * vars that point at something a converge step could not set up.
282
+ * The plan with the converge's adjustments: vars left exactly as the
283
+ * services have them (they point at something a converge step could not set
284
+ * up) and vars outside the contract the rolls set too.
224
285
  */
225
- export function leavingEnvUntouched(plan: AppRollPlan, names: readonly string[]): AppRollPlan {
226
- if (names.length === 0) return plan;
227
- const env = planEnv(plan.install, plan.services, names);
286
+ export function adjustingEnv(plan: AppRollPlan, adjust: EnvAdjustments): AppRollPlan {
287
+ const extra = Object.values(adjust.extraEnv ?? {}).some((v) => (v?.length ?? 0) > 0);
288
+ if ((adjust.leaveUntouched?.length ?? 0) === 0 && !extra) return plan;
289
+ const env = planEnv(plan.install, plan.services, adjust);
228
290
  if (!env.ok) return plan;
229
291
  return {
230
292
  ...plan,
@@ -240,10 +302,8 @@ export function leavingEnvUntouched(plan: AppRollPlan, names: readonly string[])
240
302
  export function reportPlan(plan: AppRollPlan, reporter: UpgradeReporter): void {
241
303
  for (const w of plan.warnings) reporter.warn(w);
242
304
  for (const s of plan.services) {
243
- for (const c of s.volumeChanges) reporter.info(`${s.slug} media: ${c}`);
244
- for (const f of s.resourceFlags) {
245
- reporter.info(`${s.slug} resources: ${s.liveMemory ?? "(default)"} → ${f}`);
246
- }
305
+ for (const c of s.volumeChanges) reporter.info(`${s.slug} volumes: ${c}`);
306
+ for (const c of s.resourceChanges) reporter.info(`${s.slug} resources: ${c}`);
247
307
  for (const c of s.envChanges) {
248
308
  reporter.info(
249
309
  `${s.slug} env ${c.name}: ${c.from === undefined ? "(unset)" : c.from} → ${c.to === undefined ? "(removed)" : c.to}`,
@@ -260,7 +320,11 @@ export interface AppRollHooks {
260
320
  * not be set up; `ok: false` aborts the upgrade (nothing rolled yet).
261
321
  */
262
322
  afterMigrations?(): Promise<
263
- { readonly ok: true; readonly leaveUntouched: readonly string[] } | { readonly ok: false }
323
+ | ({ readonly ok: true; readonly leaveUntouched: readonly string[] } & Pick<
324
+ EnvAdjustments,
325
+ "extraEnv"
326
+ >)
327
+ | { readonly ok: false }
264
328
  >;
265
329
  /** Right before `slug` rolls; `ok: false` aborts and rolls back what rolled. */
266
330
  beforeRoll?(
@@ -309,6 +373,13 @@ export type AppRollResult =
309
373
  readonly phase: AppRollPhase;
310
374
  /** Whether migrations ran before the failure (the backup matters then). */
311
375
  readonly migrated: boolean;
376
+ /**
377
+ * Whether services had rolled and EVERY one of them was pinned back to
378
+ * the revision that served before — i.e. the previous release serves
379
+ * again. False when nothing had rolled, or when a pin failed (then a
380
+ * person must pin it; the reporter named which).
381
+ */
382
+ readonly rolledBack: boolean;
312
383
  };
313
384
 
314
385
  /** Run the app-roll. See the file comment for the sequence. */
@@ -320,10 +391,13 @@ export async function executeAppRoll(
320
391
  const { reporter: r, run } = deps;
321
392
  const { install } = input.plan;
322
393
  let plan = input.plan;
394
+ /** Set by {@link rollBack}: null until one ran with services to restore. */
395
+ let rollbackOk: boolean | null = null;
323
396
  const fail = (phase: AppRollPhase, migrated: boolean): AppRollResult => ({
324
397
  ok: false,
325
398
  phase,
326
399
  migrated,
400
+ rolledBack: rollbackOk === true,
327
401
  });
328
402
 
329
403
  // A probe that cannot reach the install now would only fail after the
@@ -363,7 +437,10 @@ export async function executeAppRoll(
363
437
  if (hooks.afterMigrations) {
364
438
  const converged = await hooks.afterMigrations();
365
439
  if (!converged.ok) return fail("converge", true);
366
- plan = leavingEnvUntouched(plan, converged.leaveUntouched);
440
+ plan = adjustingEnv(plan, {
441
+ leaveUntouched: converged.leaveUntouched,
442
+ ...(converged.extraEnv ? { extraEnv: converged.extraEnv } : {}),
443
+ });
367
444
  }
368
445
 
369
446
  const rolled: RollPlan[] = [];
@@ -381,6 +458,7 @@ export async function executeAppRoll(
381
458
  const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
382
459
  if (services.length === 0) return;
383
460
  r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
461
+ rollbackOk = true;
384
462
  for (const p of [...services].reverse()) {
385
463
  const ok = await pinTraffic(
386
464
  run,
@@ -390,7 +468,10 @@ export async function executeAppRoll(
390
468
  p.priorRevision,
391
469
  );
392
470
  if (ok) r.info(` ${p.slug} traffic restored to ${p.priorRevision}`);
393
- else r.error(` ${p.slug} rollback FAILED — pin ${p.priorRevision} by hand`);
471
+ else {
472
+ rollbackOk = false;
473
+ r.error(` ${p.slug} rollback FAILED — pin ${p.priorRevision} by hand`);
474
+ }
394
475
  await hooks.afterRollback?.(p.slug);
395
476
  }
396
477
  };