@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
@@ -18,6 +18,9 @@
18
18
  * - {@link planMediaVolume}: the admin's media bucket volume (gen2 + Cloud
19
19
  * Storage volume + mount), added in the same `services update` when
20
20
  * missing.
21
+ * - {@link planSecretFiles} / {@link planRequestTimeout} (#806): the
22
+ * gateway's secret file (the remote-MCP OAuth client) and its request
23
+ * timeout, in the same `services update`.
21
24
  */
22
25
 
23
26
  import { removeDefaultFirewallRules } from "./default-firewall.js";
@@ -40,11 +43,15 @@ import {
40
43
  type MediaVolumeContract,
41
44
  memoryQuantityMiB,
42
45
  RETIRED_IAM_BINDINGS,
46
+ RETIRED_RUNTIME_SECRETS,
43
47
  RETIRED_SERVICE_ENV,
44
48
  type RetiredIamBinding,
49
+ type RetiredSecretFile,
45
50
  type RuntimeEnvInputs,
51
+ type SecretFileContract,
46
52
  type ServiceEnvInputs,
47
53
  STATIC_CDN_POLICY,
54
+ secretFilePath,
48
55
  stackIamInvariants,
49
56
  staticBackendBucketPrefix,
50
57
  } from "./stack-contract.js";
@@ -285,6 +292,12 @@ export function planContractEnv(
285
292
  opts: {
286
293
  /** Contract vars to leave exactly as the services have them. */
287
294
  readonly leaveUntouched?: readonly string[];
295
+ /**
296
+ * Vars beyond the contract to set as well (#806: the remote-MCP identity
297
+ * env, resolved by the converge). A name the contract also sets is
298
+ * refused rather than silently doubled.
299
+ */
300
+ readonly extraEnv?: Readonly<Partial<Record<CloudRunSlug, readonly CloudRunEnvVar[]>>>;
288
301
  } = {},
289
302
  ):
290
303
  | {
@@ -320,16 +333,25 @@ export function planContractEnv(
320
333
  inputs = { ...runtime, provider: "gcp" };
321
334
  }
322
335
  const skip = new Set(opts.leaveUntouched ?? []);
323
- const owned = (contract: readonly CloudRunEnvVar[]) => contract.filter((e) => !skip.has(e.name));
324
- const admin = planEnvUpdate(
325
- services.admin.liveEnv,
326
- owned(adminEnvContract(inputs)),
327
- RETIRED_SERVICE_ENV.admin,
328
- );
336
+ const desired = (slug: CloudRunSlug, contract: readonly CloudRunEnvVar[]) => {
337
+ const extra = opts.extraEnv?.[slug] ?? [];
338
+ const clash = extra.find((e) => contract.some((c) => c.name === e.name));
339
+ if (clash) throw new Error(`${slug}: ${clash.name} is both in the contract and extra env`);
340
+ return [...contract, ...extra].filter((e) => !skip.has(e.name));
341
+ };
342
+ let adminDesired: CloudRunEnvVar[];
343
+ let gatewayDesired: CloudRunEnvVar[];
344
+ try {
345
+ adminDesired = desired("admin", adminEnvContract(inputs));
346
+ gatewayDesired = desired("gateway", gatewayEnvContract(runtime));
347
+ } catch (e) {
348
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
349
+ }
350
+ const admin = planEnvUpdate(services.admin.liveEnv, adminDesired, RETIRED_SERVICE_ENV.admin);
329
351
  if (!admin.ok) return { ok: false, error: `admin: ${admin.error}` };
330
352
  const gateway = planEnvUpdate(
331
353
  services.gateway.liveEnv,
332
- owned(gatewayEnvContract(runtime)),
354
+ gatewayDesired,
333
355
  RETIRED_SERVICE_ENV.gateway,
334
356
  );
335
357
  if (!gateway.ok) return { ok: false, error: `gateway: ${gateway.error}` };
@@ -662,6 +684,53 @@ export async function removeRetiredIamBindings(
662
684
  return outcomes;
663
685
  }
664
686
 
687
+ /**
688
+ * Delete the secrets older contracts created and nothing reads any more
689
+ * ({@link RETIRED_RUNTIME_SECRETS}) — run by upgrade AFTER both services
690
+ * rolled onto revisions that no longer reference them (Cloud Run would
691
+ * refuse a revision whose secret is gone). Deleting a secret drops its IAM
692
+ * bindings with it. An absent secret is `absent`; never throws for a
693
+ * gcloud failure.
694
+ */
695
+ export async function deleteRetiredSecrets(
696
+ install: Pick<InstallTarget, "projectId" | "env">,
697
+ deps: {
698
+ run?: GcloudRunner;
699
+ retired?: readonly { readonly name: string; readonly why: string }[];
700
+ } = {},
701
+ ): Promise<RetiredBindingOutcome[]> {
702
+ const run = deps.run ?? defaultGcloud;
703
+ const outcomes: RetiredBindingOutcome[] = [];
704
+ for (const s of deps.retired ?? RETIRED_RUNTIME_SECRETS) {
705
+ const id = `secret ${gcpSecretId(install.env, s.name)}`;
706
+ const project = `--project=${install.projectId}`;
707
+ const describe = await run([
708
+ "secrets",
709
+ "describe",
710
+ gcpSecretId(install.env, s.name),
711
+ project,
712
+ "--format=value(name)",
713
+ ]);
714
+ if (!describe.ok && RESOURCE_MISSING.test(describe.stderr)) {
715
+ outcomes.push({ id, status: "absent", why: s.why });
716
+ continue;
717
+ }
718
+ const del = await run([
719
+ "secrets",
720
+ "delete",
721
+ gcpSecretId(install.env, s.name),
722
+ project,
723
+ "--quiet",
724
+ ]);
725
+ outcomes.push(
726
+ del.ok || RESOURCE_MISSING.test(del.stderr)
727
+ ? { id, status: del.ok ? "removed" : "absent", why: s.why }
728
+ : { id, status: "failed", why: s.why, error: del.stderr.trim() },
729
+ );
730
+ }
731
+ return outcomes;
732
+ }
733
+
665
734
  /** The memory limit of the service's container (`512Mi`, `2Gi`), or null
666
735
  * when the service declares none (Cloud Run then runs it with 512Mi). */
667
736
  export function liveContainerMemory(serviceJson: string): string | null {
@@ -713,6 +782,11 @@ const EXECUTION_ENVIRONMENT_ANNOTATION = "run.googleapis.com/execution-environme
713
782
 
714
783
  interface KnativeVolume {
715
784
  readonly name: string;
785
+ /** A Secret Manager volume (#806 secret files). */
786
+ readonly secret?: {
787
+ readonly secretName?: string;
788
+ readonly items?: readonly { readonly key?: string; readonly path?: string }[];
789
+ };
716
790
  readonly csi?: {
717
791
  readonly driver?: string;
718
792
  readonly readOnly?: boolean;
@@ -843,6 +917,87 @@ export function planMediaVolume(live: LiveVolumes, desired: MediaVolumeContract)
843
917
  return { ok: true, flags, changes };
844
918
  }
845
919
 
920
+ // ===========================================================================
921
+ // Secret files + request timeout (#806)
922
+ // ===========================================================================
923
+
924
+ export type RollAdditionsPlan =
925
+ | { readonly ok: true; readonly flags: string[]; readonly changes: string[] }
926
+ | { readonly ok: false; readonly error: string };
927
+
928
+ /** `projects/<p>/secrets/<id>` or `<id>` → `<id>`. */
929
+ function bareSecretId(name: string | undefined): string {
930
+ return (name ?? "").slice((name ?? "").lastIndexOf("/") + 1);
931
+ }
932
+
933
+ /**
934
+ * The flags that mount each of `files` (stack-contract.ts
935
+ * SERVICE_SECRET_FILES) where the stacks mount it, adding only what is
936
+ * missing — `--update-secrets=<dir>/<file>=<secret>:latest`, the form
937
+ * cloud.google.com/run/docs/configuring/services/secrets documents (gcloud
938
+ * names the volume itself). A file already mounted from the same secret at
939
+ * `latest` is converged whatever its volume is called. Anything else at that
940
+ * directory is refused with what was found: replacing it could feed the
941
+ * gateway a different credential.
942
+ */
943
+ export function planSecretFiles(
944
+ live: LiveVolumes,
945
+ files: readonly SecretFileContract[],
946
+ env: string,
947
+ retired: readonly RetiredSecretFile[] = [],
948
+ ): RollAdditionsPlan {
949
+ const flags: string[] = [];
950
+ const changes: string[] = [];
951
+ // #914 — mounts an older contract made and nothing reads any more
952
+ // (RETIRED_SECRET_FILES) go in the same revision that mounts their
953
+ // replacement. `--remove-secrets=<dir>/<file>` drops the file, and the
954
+ // mount with it when it was the volume's only file (gcloud
955
+ // `_PruneManagedVolumeMapping`). Repeated `--remove-secrets` /
956
+ // `--update-secrets` flags merge (gcloud map flags use UpdateAction).
957
+ for (const old of retired) {
958
+ if (!live.mounts.some((m) => m.mountPath === old.mountDir)) continue;
959
+ flags.push(`--remove-secrets=${old.mountDir}/${old.fileName}`);
960
+ changes.push(`secret file ${old.mountDir}/${old.fileName}: removed (${old.why})`);
961
+ }
962
+ for (const file of files) {
963
+ const secretId = gcpSecretId(env, file.secret);
964
+ const mount = live.mounts.find((m) => m.mountPath === file.mountDir);
965
+ if (mount) {
966
+ const volume = live.volumes.find((v) => v.name === mount.name);
967
+ const item = volume?.secret?.items?.find((i) => i.path === file.fileName);
968
+ const inShape =
969
+ bareSecretId(volume?.secret?.secretName) === secretId && item?.key === file.version;
970
+ if (inShape) continue;
971
+ return {
972
+ ok: false,
973
+ error: `the service mounts volume "${mount.name}" at ${file.mountDir}, where ${secretId}:${file.version} belongs as ${file.fileName}. Remove it (gcloud run services update <service> --remove-volume-mount=${file.mountDir}) only if nothing else needs it, then re-run upgrade.`,
974
+ };
975
+ }
976
+ flags.push(`--update-secrets=${secretFilePath(file)}=${secretId}:${file.version}`);
977
+ changes.push(`secret file ${secretFilePath(file)}: (none) → ${secretId}:${file.version}`);
978
+ }
979
+ return { ok: true, flags, changes };
980
+ }
981
+
982
+ /** The request timeout of a described service, in seconds (null: Cloud Run's default, 300). */
983
+ export function liveTimeoutSeconds(serviceJson: string): number | null {
984
+ const svc = JSON.parse(serviceJson) as {
985
+ spec?: { template?: { spec?: { timeoutSeconds?: number | string } } };
986
+ };
987
+ const t = svc.spec?.template?.spec?.timeoutSeconds;
988
+ return t === undefined ? null : Number(t);
989
+ }
990
+
991
+ /** `--timeout=<desired>` when the live timeout differs; nothing when it matches. */
992
+ export function planRequestTimeout(live: number | null, desired: number): RollAdditionsPlan {
993
+ if (live === desired) return { ok: true, flags: [], changes: [] };
994
+ return {
995
+ ok: true,
996
+ flags: [`--timeout=${desired}`],
997
+ changes: [`request timeout: ${live === null ? "(default 300)" : live}s → ${desired}s`],
998
+ };
999
+ }
1000
+
846
1001
  /**
847
1002
  * The single `gcloud run services update` that rolls a service to a new
848
1003
  * image, its run SA and its env changes, so all land in one new revision.
@@ -186,12 +186,18 @@ describe("state locks", () => {
186
186
  }
187
187
  if (args[1] === "cat")
188
188
  return objects.has(url) ? ok(objects.get(url)) : fail("matched no objects");
189
+ if (args[1] === "objects") {
190
+ const target = args[3] ?? "";
191
+ return objects.has(target)
192
+ ? ok(JSON.stringify({ generation: "1" }))
193
+ : fail("matched no objects");
194
+ }
189
195
  if (args[1] === "rm") return objects.delete(url) ? ok() : fail("matched no objects");
190
196
  return fail("unexpected");
191
197
  };
192
198
  const release = acquireBucketStateLock("acme-site-caelo-ops", "state migrate", run);
193
199
  expect(() => acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)).toThrow(
194
- /gcloud storage rm gs:\/\/acme-site-caelo-ops\/install\/state.lock/,
200
+ /held by state migrate .*lease runs until/,
195
201
  );
196
202
  release();
197
203
  acquireBucketStateLock("acme-site-caelo-ops", "installer pulumi up", run)();
@@ -0,0 +1,302 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The state lock's lease (state-lock-lease.ts, #815): when a held lock may
5
+ * be broken, and how Cloud Run's execution status is read.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import type { GcloudResult } from "./gcloud.js";
10
+ import {
11
+ type CloudRunExecutionRef,
12
+ type ExecutionState,
13
+ judgeHeldLock,
14
+ LEASE_SKEW_MS,
15
+ type LocalHost,
16
+ lockBody,
17
+ machineId,
18
+ PROCESS_LEASE_MS,
19
+ parseExecutionState,
20
+ parseLockBody,
21
+ renewedLockBody,
22
+ } from "./state-lock-lease.js";
23
+
24
+ const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
25
+ const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
26
+
27
+ const EXEC: CloudRunExecutionRef = {
28
+ projectId: "acme",
29
+ region: "europe-west1",
30
+ name: "caelo-production-upgrader-x1",
31
+ };
32
+ const START = new Date("2026-10-16T10:00:00Z");
33
+ const HOUR = 60 * 60_000;
34
+ /** The lease of a lock taken at START by a one-hour job. */
35
+ const LEASE_END = START.getTime() + HOUR + LEASE_SKEW_MS;
36
+
37
+ const jobLock = lockBody("caelo upgrader job", START, { execution: EXEC, maxRuntimeMs: HOUR });
38
+ const at = (ms: number) => new Date(ms);
39
+ const state =
40
+ (s: ExecutionState) =>
41
+ (ref: CloudRunExecutionRef): ExecutionState => {
42
+ expect(ref).toEqual(EXEC);
43
+ return s;
44
+ };
45
+
46
+ describe("lockBody", () => {
47
+ it("an execution's lock names the execution and a lease of task timeout + skew", () => {
48
+ const body = parseLockBody(jobLock);
49
+ expect(body?.execution).toEqual(EXEC);
50
+ expect(body?.leaseUntil).toBe(new Date(LEASE_END).toISOString());
51
+ });
52
+
53
+ it("a machine-local lock (no holder) carries neither", () => {
54
+ const body = parseLockBody(lockBody("cms-provision upgrade", START));
55
+ expect(body?.purpose).toBe("cms-provision upgrade");
56
+ expect(body?.execution).toBeUndefined();
57
+ expect(body?.leaseUntil).toBeUndefined();
58
+ });
59
+
60
+ it("a CLI process's lock names host + pid, a unique id and a lease of leaseMs + skew", () => {
61
+ const a = parseLockBody(
62
+ lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }),
63
+ );
64
+ const b = parseLockBody(
65
+ lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }),
66
+ );
67
+ expect(a?.pid).toBe(process.pid);
68
+ expect(a?.host).toBeTruthy();
69
+ expect(a?.execution).toBeUndefined();
70
+ expect(a?.leaseUntil).toBe(
71
+ new Date(START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
72
+ );
73
+ expect(a?.id).toBeTruthy();
74
+ expect(a?.id).not.toBe(b?.id);
75
+ });
76
+
77
+ it("a renewed body keeps its id and start and moves only the lease", () => {
78
+ const body = parseLockBody(
79
+ lockBody("cms-provision upgrade", START, { leaseMs: PROCESS_LEASE_MS }, "id-1"),
80
+ );
81
+ if (!body) throw new Error("unparsable");
82
+ const later = at(START.getTime() + 3 * HOUR);
83
+ const renewed = parseLockBody(renewedLockBody(body, later, PROCESS_LEASE_MS));
84
+ expect(renewed).toMatchObject({ id: "id-1", startedAt: START.toISOString() });
85
+ expect(renewed?.leaseUntil).toBe(
86
+ new Date(later.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("judgeHeldLock — a CLI process holder (#815 review)", () => {
92
+ const HERE: LocalHost = { host: "laptop", alive: (pid) => pid === 4242 };
93
+ const cli = (host: string, pid: number) =>
94
+ JSON.stringify({
95
+ purpose: "cms-provision upgrade",
96
+ host,
97
+ pid,
98
+ startedAt: START.toISOString(),
99
+ leaseUntil: new Date(START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
100
+ });
101
+ const LEASE = START.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS;
102
+ const never = () => {
103
+ throw new Error("Cloud Run must not be asked about a CLI holder");
104
+ };
105
+
106
+ it("its own host's lock whose pid is dead is broken at once, inside the lease", () => {
107
+ const v = judgeHeldLock(cli("laptop", 9999), never, at(START.getTime() + 1), HERE);
108
+ expect(v.kind).toBe("break");
109
+ expect(v.why).toContain("process is gone");
110
+ });
111
+
112
+ it("its own host's lock whose pid lives is held until the lease ends", () => {
113
+ expect(judgeHeldLock(cli("laptop", 4242), never, at(LEASE), HERE)).toMatchObject({
114
+ kind: "held",
115
+ manualRemoval: false,
116
+ });
117
+ expect(judgeHeldLock(cli("laptop", 4242), never, at(LEASE + 1), HERE).kind).toBe("break");
118
+ });
119
+
120
+ it("another host's lock (a Cloud Shell) is held within the lease, broken after it", () => {
121
+ expect(judgeHeldLock(cli("cs-1", 9999), never, at(LEASE), HERE).kind).toBe("held");
122
+ const v = judgeHeldLock(cli("cs-1", 9999), never, at(LEASE + 1), HERE);
123
+ expect(v.kind).toBe("break");
124
+ expect(v.why).toContain("stopped renewing");
125
+ });
126
+
127
+ it("#815 review: a dead pid is only judged on the machine that took the lock", () => {
128
+ const at1 = at(START.getTime() + 1);
129
+ const withMachine = (machine: string | null) =>
130
+ JSON.stringify({ ...JSON.parse(cli("localhost", 9999)), machine });
131
+ const here = (machine: string | null): LocalHost => ({
132
+ host: "localhost",
133
+ machine,
134
+ alive: () => false,
135
+ });
136
+ // Same host name, another machine (two Cloud Run containers, two
137
+ // laptops called alike): the pid is someone else's, the lease decides.
138
+ expect(judgeHeldLock(withMachine("m-a"), never, at1, here("m-b")).kind).toBe("held");
139
+ // A lock from Cloud Run names no machine: never judged by its pid.
140
+ expect(judgeHeldLock(withMachine(null), never, at1, here("m-b")).kind).toBe("held");
141
+ // A judge in Cloud Run has no machine id: it never trusts a pid, not
142
+ // even one of a lock written before machine ids.
143
+ expect(judgeHeldLock(cli("localhost", 9999), never, at1, here(null)).kind).toBe("held");
144
+ // The same machine: the dead pid ends it at once.
145
+ expect(judgeHeldLock(withMachine("m-a"), never, at1, here("m-a")).kind).toBe("break");
146
+ // Whatever the machine, the lease still ends it.
147
+ expect(judgeHeldLock(withMachine("m-a"), never, at(LEASE + 1), here("m-b")).kind).toBe("break");
148
+ });
149
+
150
+ it("#815 review: machineId is null inside Cloud Run and stable outside", () => {
151
+ expect(machineId({ CLOUD_RUN_EXECUTION: "caelo-upgrader-abc" })).toBeNull();
152
+ expect(machineId({ K_SERVICE: "caelo-production-admin" })).toBeNull();
153
+ expect(machineId({})).toBe(machineId({}));
154
+ const body = parseLockBody(lockBody("cms-provision upgrade", START, { leaseMs: 1 }));
155
+ expect(body && "machine" in body).toBe(true);
156
+ });
157
+
158
+ it("an unreadable lease is held for a person to remove", () => {
159
+ const body = JSON.stringify({
160
+ purpose: "cms-provision upgrade",
161
+ host: "cs-1",
162
+ pid: 1,
163
+ leaseUntil: "soon",
164
+ });
165
+ expect(judgeHeldLock(body, never, at(LEASE + HOUR), HERE)).toMatchObject({
166
+ kind: "held",
167
+ manualRemoval: true,
168
+ });
169
+ });
170
+ });
171
+
172
+ describe("judgeHeldLock", () => {
173
+ it("breaks the lock of an execution that has finished — succeeded, failed or cancelled", () => {
174
+ for (const how of ["succeeded", "failed", "cancelled"]) {
175
+ const v = judgeHeldLock(
176
+ jobLock,
177
+ state({ kind: "finished", how }),
178
+ at(START.getTime() + 60_000),
179
+ );
180
+ expect(v.kind).toBe("break");
181
+ expect(v.why).toContain(`has ${how}`);
182
+ }
183
+ });
184
+
185
+ it("never breaks the lock of a running execution, even long past its lease", () => {
186
+ for (const now of [START.getTime() + 1, LEASE_END + 1, LEASE_END + 24 * HOUR]) {
187
+ const v = judgeHeldLock(jobLock, state({ kind: "running" }), at(now));
188
+ expect(v).toMatchObject({ kind: "held", manualRemoval: false });
189
+ expect(v.why).toContain("still running");
190
+ }
191
+ });
192
+
193
+ it("an execution Cloud Run does not know: held within the lease, broken after it", () => {
194
+ const gone = state({ kind: "not-found" });
195
+ expect(judgeHeldLock(jobLock, gone, at(START.getTime())).kind).toBe("held");
196
+ // Exactly at the lease's end it still holds; one millisecond later it is dead.
197
+ expect(judgeHeldLock(jobLock, gone, at(LEASE_END)).kind).toBe("held");
198
+ expect(judgeHeldLock(jobLock, gone, at(LEASE_END + 1)).kind).toBe("break");
199
+ });
200
+
201
+ it("a lock whose lease is unreadable is never broken on 'not found'", () => {
202
+ const body = JSON.stringify({
203
+ purpose: "caelo upgrader job",
204
+ execution: EXEC,
205
+ leaseUntil: "soon",
206
+ });
207
+ const v = judgeHeldLock(body, state({ kind: "not-found" }), at(LEASE_END + 10 * HOUR));
208
+ expect(v).toMatchObject({ kind: "held", manualRemoval: true });
209
+ });
210
+
211
+ it("a holder Cloud Run cannot be asked about is undecided, never dead", () => {
212
+ const v = judgeHeldLock(
213
+ jobLock,
214
+ state({ kind: "unknown", error: "PERMISSION_DENIED" }),
215
+ at(LEASE_END + HOUR),
216
+ );
217
+ expect(v.kind).toBe("unknown");
218
+ expect(v.why).toContain("PERMISSION_DENIED");
219
+ });
220
+
221
+ it("a pre-#815 lock (no lease) from another host is never broken automatically, and Cloud Run is not asked", () => {
222
+ const describe = () => {
223
+ throw new Error("must not be asked");
224
+ };
225
+ const cli = judgeHeldLock(
226
+ lockBody("cms-provision upgrade", START),
227
+ describe,
228
+ at(START.getTime() + 100 * HOUR),
229
+ );
230
+ expect(cli).toMatchObject({ kind: "held", manualRemoval: true });
231
+ const legacy = JSON.stringify({
232
+ purpose: "caelo upgrader job",
233
+ host: "localhost",
234
+ pid: 1,
235
+ startedAt: START.toISOString(),
236
+ });
237
+ expect(
238
+ judgeHeldLock(legacy, describe, at(START.getTime() + 100 * HOUR), {
239
+ host: "laptop",
240
+ alive: () => false,
241
+ }).kind,
242
+ ).toBe("held");
243
+ });
244
+
245
+ it("an unreadable lock body is held, with the body quoted", () => {
246
+ const v = judgeHeldLock("not json", state({ kind: "running" }), START);
247
+ expect(v).toMatchObject({ kind: "held", manualRemoval: true });
248
+ expect(v.why).toContain("not json");
249
+ });
250
+ });
251
+
252
+ describe("parseExecutionState", () => {
253
+ const exec = (status: unknown) => ok(JSON.stringify({ status }));
254
+
255
+ it("reads the Completed condition: True succeeded, False failed or cancelled", () => {
256
+ expect(
257
+ parseExecutionState(exec({ conditions: [{ type: "Completed", status: "True" }] })),
258
+ ).toEqual({
259
+ kind: "finished",
260
+ how: "succeeded",
261
+ });
262
+ expect(
263
+ parseExecutionState(
264
+ exec({ conditions: [{ type: "Completed", status: "False", reason: "Cancelled" }] }),
265
+ ),
266
+ ).toEqual({ kind: "finished", how: "cancelled" });
267
+ expect(
268
+ parseExecutionState(exec({ conditions: [{ type: "Completed", status: "False" }] })),
269
+ ).toEqual({
270
+ kind: "finished",
271
+ how: "failed",
272
+ });
273
+ });
274
+
275
+ it("a completion time without a decided condition is finished too", () => {
276
+ expect(parseExecutionState(exec({ completionTime: "2026-10-16T11:00:00Z" })).kind).toBe(
277
+ "finished",
278
+ );
279
+ });
280
+
281
+ it("an undecided or not yet started execution is running", () => {
282
+ expect(
283
+ parseExecutionState(exec({ conditions: [{ type: "Completed", status: "Unknown" }] })).kind,
284
+ ).toBe("running");
285
+ expect(parseExecutionState(exec({})).kind).toBe("running");
286
+ expect(parseExecutionState(ok("{}")).kind).toBe("running");
287
+ });
288
+
289
+ it("not found vs. any other gcloud failure", () => {
290
+ expect(
291
+ parseExecutionState(fail("ERROR: (gcloud.run.jobs.executions.describe) NOT_FOUND")).kind,
292
+ ).toBe("not-found");
293
+ expect(parseExecutionState(fail("PERMISSION_DENIED: run.executions.get"))).toEqual({
294
+ kind: "unknown",
295
+ error: "PERMISSION_DENIED: run.executions.get",
296
+ });
297
+ expect(parseExecutionState(ok("garbage")).kind).toBe("unknown");
298
+ expect(parseExecutionState(ok(JSON.stringify({ status: { conditions: "x" } }))).kind).toBe(
299
+ "unknown",
300
+ );
301
+ });
302
+ });