@caelo-cms/provisioning 0.10.53 → 0.10.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/agent-db.js +272 -128
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli-routing.js +1 -1
  11. package/dist/cli-routing.js.map +1 -1
  12. package/dist/cli.js +36 -9
  13. package/dist/cli.js.map +1 -1
  14. package/dist/compose.js +5 -0
  15. package/dist/compose.js.map +1 -1
  16. package/dist/converge-checkpoint.d.ts +66 -0
  17. package/dist/converge-checkpoint.d.ts.map +1 -0
  18. package/dist/converge-checkpoint.js +61 -0
  19. package/dist/converge-checkpoint.js.map +1 -0
  20. package/dist/converge-job-gcp.d.ts +46 -0
  21. package/dist/converge-job-gcp.d.ts.map +1 -0
  22. package/dist/converge-job-gcp.js +368 -0
  23. package/dist/converge-job-gcp.js.map +1 -0
  24. package/dist/converge-job-setup.d.ts +62 -0
  25. package/dist/converge-job-setup.d.ts.map +1 -0
  26. package/dist/converge-job-setup.js +228 -0
  27. package/dist/converge-job-setup.js.map +1 -0
  28. package/dist/converge-job.d.ts +141 -0
  29. package/dist/converge-job.d.ts.map +1 -0
  30. package/dist/converge-job.js +325 -0
  31. package/dist/converge-job.js.map +1 -0
  32. package/dist/gcp-names.d.ts +25 -1
  33. package/dist/gcp-names.d.ts.map +1 -1
  34. package/dist/gcp-names.js +36 -0
  35. package/dist/gcp-names.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/infra-contract.d.ts +1 -1
  41. package/dist/infra-contract.d.ts.map +1 -1
  42. package/dist/infra-contract.js +28 -1
  43. package/dist/infra-contract.js.map +1 -1
  44. package/dist/install-job.d.ts +11 -3
  45. package/dist/install-job.d.ts.map +1 -1
  46. package/dist/install-job.js +22 -12
  47. package/dist/install-job.js.map +1 -1
  48. package/dist/lifecycle.d.ts +13 -1
  49. package/dist/lifecycle.d.ts.map +1 -1
  50. package/dist/lifecycle.js +115 -58
  51. package/dist/lifecycle.js.map +1 -1
  52. package/dist/operator-access.d.ts.map +1 -1
  53. package/dist/operator-access.js +13 -3
  54. package/dist/operator-access.js.map +1 -1
  55. package/dist/private-file.d.ts +26 -0
  56. package/dist/private-file.d.ts.map +1 -0
  57. package/dist/private-file.js +49 -0
  58. package/dist/private-file.js.map +1 -0
  59. package/dist/release-contract.d.ts +3 -2
  60. package/dist/release-contract.d.ts.map +1 -1
  61. package/dist/release-contract.js +8 -2
  62. package/dist/release-contract.js.map +1 -1
  63. package/dist/release-manifest.d.ts +2 -0
  64. package/dist/release-manifest.d.ts.map +1 -1
  65. package/dist/release-manifest.js.map +1 -1
  66. package/dist/remote-mcp-edge.d.ts +114 -0
  67. package/dist/remote-mcp-edge.d.ts.map +1 -0
  68. package/dist/remote-mcp-edge.js +116 -0
  69. package/dist/remote-mcp-edge.js.map +1 -0
  70. package/dist/remote-mcp.d.ts +117 -0
  71. package/dist/remote-mcp.d.ts.map +1 -0
  72. package/dist/remote-mcp.js +379 -0
  73. package/dist/remote-mcp.js.map +1 -0
  74. package/dist/runtime-secrets.d.ts +12 -0
  75. package/dist/runtime-secrets.d.ts.map +1 -1
  76. package/dist/runtime-secrets.js +32 -4
  77. package/dist/runtime-secrets.js.map +1 -1
  78. package/dist/seal-key-rotation.d.ts +38 -0
  79. package/dist/seal-key-rotation.d.ts.map +1 -0
  80. package/dist/seal-key-rotation.js +206 -0
  81. package/dist/seal-key-rotation.js.map +1 -0
  82. package/dist/stack-contract.d.ts +175 -3
  83. package/dist/stack-contract.d.ts.map +1 -1
  84. package/dist/stack-contract.js +183 -3
  85. package/dist/stack-contract.js.map +1 -1
  86. package/dist/stack-converge.d.ts +38 -1
  87. package/dist/stack-converge.d.ts.map +1 -1
  88. package/dist/stack-converge.js +74 -4
  89. package/dist/stack-converge.js.map +1 -1
  90. package/dist/state-lock-lease.d.ts +142 -0
  91. package/dist/state-lock-lease.d.ts.map +1 -0
  92. package/dist/state-lock-lease.js +347 -0
  93. package/dist/state-lock-lease.js.map +1 -0
  94. package/dist/state-store.d.ts +66 -9
  95. package/dist/state-store.d.ts.map +1 -1
  96. package/dist/state-store.js +249 -30
  97. package/dist/state-store.js.map +1 -1
  98. package/dist/tar-entries.d.ts.map +1 -1
  99. package/dist/tar-entries.js +84 -9
  100. package/dist/tar-entries.js.map +1 -1
  101. package/dist/upgrade/app-roll.d.ts +36 -7
  102. package/dist/upgrade/app-roll.d.ts.map +1 -1
  103. package/dist/upgrade/app-roll.js +88 -19
  104. package/dist/upgrade/app-roll.js.map +1 -1
  105. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  106. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  107. package/dist/upgrade/audit-job-roll.js +43 -0
  108. package/dist/upgrade/audit-job-roll.js.map +1 -0
  109. package/dist/upgrade/converge.d.ts.map +1 -1
  110. package/dist/upgrade/converge.js +141 -8
  111. package/dist/upgrade/converge.js.map +1 -1
  112. package/dist/upgrade/gcp-lookup.d.ts +27 -10
  113. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  114. package/dist/upgrade/gcp-lookup.js +46 -7
  115. package/dist/upgrade/gcp-lookup.js.map +1 -1
  116. package/dist/upgrade/record-rolled.d.ts +40 -0
  117. package/dist/upgrade/record-rolled.d.ts.map +1 -0
  118. package/dist/upgrade/record-rolled.js +46 -0
  119. package/dist/upgrade/record-rolled.js.map +1 -0
  120. package/dist/upgrade/upgrade.d.ts +45 -1
  121. package/dist/upgrade/upgrade.d.ts.map +1 -1
  122. package/dist/upgrade/upgrade.js +60 -17
  123. package/dist/upgrade/upgrade.js.map +1 -1
  124. package/dist/upgrader-assess.d.ts +15 -1
  125. package/dist/upgrader-assess.d.ts.map +1 -1
  126. package/dist/upgrader-assess.js +36 -6
  127. package/dist/upgrader-assess.js.map +1 -1
  128. package/dist/upgrader-gcp.d.ts +15 -0
  129. package/dist/upgrader-gcp.d.ts.map +1 -1
  130. package/dist/upgrader-gcp.js +42 -7
  131. package/dist/upgrader-gcp.js.map +1 -1
  132. package/dist/upgrader-iam-preflight.d.ts +123 -0
  133. package/dist/upgrader-iam-preflight.d.ts.map +1 -0
  134. package/dist/upgrader-iam-preflight.js +414 -0
  135. package/dist/upgrader-iam-preflight.js.map +1 -0
  136. package/dist/upgrader-job.d.ts +33 -9
  137. package/dist/upgrader-job.d.ts.map +1 -1
  138. package/dist/upgrader-job.js +169 -27
  139. package/dist/upgrader-job.js.map +1 -1
  140. package/dist/upgrader-records.d.ts +58 -0
  141. package/dist/upgrader-records.d.ts.map +1 -0
  142. package/dist/upgrader-records.js +87 -0
  143. package/dist/upgrader-records.js.map +1 -0
  144. package/dist/upgrader-reporter.d.ts +3 -2
  145. package/dist/upgrader-reporter.d.ts.map +1 -1
  146. package/dist/upgrader-reporter.js +10 -2
  147. package/dist/upgrader-reporter.js.map +1 -1
  148. package/dist/upgrader-setup.d.ts +25 -3
  149. package/dist/upgrader-setup.d.ts.map +1 -1
  150. package/dist/upgrader-setup.js +132 -14
  151. package/dist/upgrader-setup.js.map +1 -1
  152. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  153. package/dist/wizards/gcp-cost.js +14 -6
  154. package/dist/wizards/gcp-cost.js.map +1 -1
  155. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  156. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  157. package/dist/wizards/gcp-pulumi.js +7 -0
  158. package/dist/wizards/gcp-pulumi.js.map +1 -1
  159. package/dist/wizards/gcp.d.ts.map +1 -1
  160. package/dist/wizards/gcp.js +130 -4
  161. package/dist/wizards/gcp.js.map +1 -1
  162. package/package.json +4 -4
  163. package/src/agent/run.test.ts +15 -1
  164. package/src/audit-job-setup.test.ts +234 -0
  165. package/src/audit-job-setup.ts +360 -0
  166. package/src/caddy-redirects.docker.test.ts +19 -6
  167. package/src/caddy.test.ts +30 -0
  168. package/src/caddy.ts +43 -0
  169. package/src/cli-init-validation.test.ts +44 -0
  170. package/src/cli-routing.ts +1 -1
  171. package/src/cli.ts +40 -8
  172. package/src/compose.ts +5 -0
  173. package/src/converge-checkpoint.ts +75 -0
  174. package/src/converge-entrypoint.test.ts +213 -0
  175. package/src/converge-job-gcp.test.ts +222 -0
  176. package/src/converge-job-gcp.ts +443 -0
  177. package/src/converge-job-setup.test.ts +152 -0
  178. package/src/converge-job-setup.ts +268 -0
  179. package/src/converge-job.test.ts +486 -0
  180. package/src/converge-job.ts +542 -0
  181. package/src/gateway-env-isolation.test.ts +8 -1
  182. package/src/gcp-names.ts +43 -1
  183. package/src/gcp-url-map.test.ts +9 -1
  184. package/src/index.ts +8 -0
  185. package/src/infra-contract.test.ts +7 -4
  186. package/src/infra-contract.ts +28 -1
  187. package/src/install-job.test.ts +3 -1
  188. package/src/install-job.ts +31 -14
  189. package/src/lifecycle.ts +137 -71
  190. package/src/operator-access.test.ts +14 -0
  191. package/src/operator-access.ts +13 -3
  192. package/src/private-file.test.ts +80 -0
  193. package/src/private-file.ts +50 -0
  194. package/src/release-contract.ts +8 -2
  195. package/src/release-manifest.test.ts +42 -0
  196. package/src/release-manifest.ts +2 -0
  197. package/src/remote-mcp-edge.ts +137 -0
  198. package/src/remote-mcp.test.ts +517 -0
  199. package/src/remote-mcp.ts +460 -0
  200. package/src/runtime-secrets.test.ts +31 -6
  201. package/src/runtime-secrets.ts +41 -3
  202. package/src/seal-key-rotation.test.ts +131 -0
  203. package/src/seal-key-rotation.ts +258 -0
  204. package/src/stack-contract-parity.test.ts +20 -3
  205. package/src/stack-contract.test.ts +34 -2
  206. package/src/stack-contract.ts +305 -3
  207. package/src/stack-converge-secrets.test.ts +3 -0
  208. package/src/stack-converge.test.ts +21 -5
  209. package/src/stack-converge.ts +101 -7
  210. package/src/state-attach.test.ts +7 -1
  211. package/src/state-lock-lease.test.ts +302 -0
  212. package/src/state-lock-lease.ts +423 -0
  213. package/src/state-lock.test.ts +568 -0
  214. package/src/state-store.ts +347 -27
  215. package/src/tar-entries.test.ts +149 -7
  216. package/src/tar-entries.ts +90 -9
  217. package/src/upgrade/app-roll.ts +135 -21
  218. package/src/upgrade/audit-job-roll.ts +68 -0
  219. package/src/upgrade/converge.ts +170 -7
  220. package/src/upgrade/gcp-lookup.test.ts +80 -0
  221. package/src/upgrade/gcp-lookup.ts +65 -8
  222. package/src/upgrade/record-rolled.test.ts +105 -0
  223. package/src/upgrade/record-rolled.ts +91 -0
  224. package/src/upgrade/upgrade.test.ts +279 -5
  225. package/src/upgrade/upgrade.ts +120 -24
  226. package/src/upgrader-assess.test.ts +93 -3
  227. package/src/upgrader-assess.ts +58 -9
  228. package/src/upgrader-gcp.ts +47 -8
  229. package/src/upgrader-iam-preflight.test.ts +393 -0
  230. package/src/upgrader-iam-preflight.ts +510 -0
  231. package/src/upgrader-job.test.ts +587 -15
  232. package/src/upgrader-job.ts +237 -53
  233. package/src/upgrader-records.ts +151 -0
  234. package/src/upgrader-reporter.test.ts +6 -0
  235. package/src/upgrader-reporter.ts +7 -2
  236. package/src/upgrader-setup.test.ts +4 -3
  237. package/src/upgrader-setup.ts +137 -13
  238. package/src/wizards/gcp-cost.ts +15 -6
  239. package/src/wizards/gcp-pulumi.ts +7 -0
  240. package/src/wizards/gcp.ts +162 -4
  241. package/stacks/aws/index.ts +18 -0
  242. package/stacks/azure/index.ts +18 -0
  243. package/stacks/gcp/index.ts +59 -7
  244. package/stacks/gcp-firebase/index.ts +50 -6
@@ -0,0 +1,423 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The lease on the install's bucket state lock (#815): who holds it, and
5
+ * whether a later run may break it.
6
+ *
7
+ * A lock written by a Cloud Run job execution (the upgrader job, later the
8
+ * converge job #819) names that execution and a lease: the execution's
9
+ * start plus its task timeout, after which Cloud Run has killed it for
10
+ * sure. A run that finds the lock held asks Cloud Run about the holder:
11
+ *
12
+ * - the execution has finished (succeeded, failed, cancelled): it can no
13
+ * longer write, so its lock is dead and may be broken;
14
+ * - it is still running: the lock is live and is NEVER broken — not even
15
+ * past its lease (a lease is our estimate; Cloud Run's state is fact);
16
+ * - Cloud Run does not know it (deleted, or a name we cannot resolve):
17
+ * broken only once the lease has expired, when no execution of that
18
+ * job can still be alive;
19
+ * - Cloud Run cannot be asked (no access, gcloud error): not decided
20
+ * here — the caller reports the error; guessing "dead" could let two
21
+ * runs write the same state.
22
+ *
23
+ * A lock taken by a process outside Cloud Run (a CLI run on someone's
24
+ * machine or in Cloud Shell) names its host and pid and carries a short
25
+ * lease that the holder renews while it runs (the heartbeat in
26
+ * state-store.ts). Such a lock is broken when:
27
+ *
28
+ * - it was taken on THIS machine and its pid is no longer alive (a
29
+ * killed CLI on the same machine — no need to wait for the lease). The
30
+ * machine is told apart by {@link machineId}, not by its host name
31
+ * alone: every Cloud Run container calls itself the same, and two
32
+ * laptops may too, so a pid that is dead HERE says nothing about a
33
+ * process THERE. Inside Cloud Run there is no machine id at all, so a
34
+ * lock from there is only ever judged by its lease; or
35
+ * - its lease has expired: the holder stopped renewing it (killed, or a
36
+ * closed Cloud Shell), so it can no longer be writing.
37
+ *
38
+ * Only a lock written before #815 (no lease, no execution) is never broken
39
+ * automatically; its holder message names the command to remove it.
40
+ *
41
+ * Everything here is pure except {@link describeExecution}, which takes a
42
+ * {@link SyncGcloud}; the lock itself (create, generation-matched break,
43
+ * release) lives in state-store.ts.
44
+ */
45
+
46
+ import { createHash, randomUUID } from "node:crypto";
47
+ import { readlinkSync } from "node:fs";
48
+ import { hostname, networkInterfaces } from "node:os";
49
+ import { z } from "zod";
50
+ import type { GcloudResult } from "./gcloud.js";
51
+
52
+ /** A Cloud Run job execution, fully addressed. */
53
+ export interface CloudRunExecutionRef {
54
+ readonly projectId: string;
55
+ readonly region: string;
56
+ /** The execution's short name (`CLOUD_RUN_EXECUTION`). */
57
+ readonly name: string;
58
+ }
59
+
60
+ /**
61
+ * A lock holder that Cloud Run can vouch for: the execution and the
62
+ * longest it can run (its task timeout).
63
+ */
64
+ export interface ExecutionLockHolder {
65
+ readonly execution: CloudRunExecutionRef;
66
+ readonly maxRuntimeMs: number;
67
+ }
68
+
69
+ /**
70
+ * A lock holder outside Cloud Run (a CLI process): its lease is short and
71
+ * renewed by a heartbeat while it runs, so a holder that dies stops
72
+ * renewing and its lock expires on its own (#815).
73
+ */
74
+ export interface ProcessLockHolder {
75
+ readonly leaseMs: number;
76
+ }
77
+
78
+ /** Who takes a lock: a Cloud Run execution, or a renewing process. */
79
+ export type LockHolder = ExecutionLockHolder | ProcessLockHolder;
80
+
81
+ /** The lease of a CLI process's lock; renewed every third of it. */
82
+ export const PROCESS_LEASE_MS = 10 * 60_000;
83
+
84
+ /**
85
+ * Margin added to a lease for clock skew between the holder and the run
86
+ * judging it (both read their own clock; Cloud Run's are NTP-synced, a
87
+ * developer laptop's may not be).
88
+ */
89
+ export const LEASE_SKEW_MS = 5 * 60_000;
90
+
91
+ const executionRefSchema = z.object({
92
+ projectId: z.string().min(1),
93
+ region: z.string().min(1),
94
+ name: z.string().min(1),
95
+ });
96
+
97
+ /** The lock object's body. `execution` + `leaseUntil` appear together (#815). */
98
+ const lockBodySchema = z.object({
99
+ purpose: z.string(),
100
+ /** Unique per acquisition: proves a lock is still the one this run took. */
101
+ id: z.string().optional(),
102
+ host: z.string().optional(),
103
+ /** {@link machineId} of the writer; null when it has none (Cloud Run). */
104
+ machine: z.string().nullable().optional(),
105
+ pid: z.number().optional(),
106
+ startedAt: z.string().optional(),
107
+ execution: executionRefSchema.optional(),
108
+ leaseUntil: z.string().optional(),
109
+ });
110
+
111
+ /** The lock body as written by {@link lockBody}. */
112
+ export type LockBody = z.infer<typeof lockBodySchema>;
113
+
114
+ /**
115
+ * The body a new lock is written with.
116
+ *
117
+ * @param holder a Cloud Run execution (lease: `now + maxRuntimeMs +
118
+ * LEASE_SKEW_MS`) or a renewing process (lease: `now + leaseMs +
119
+ * LEASE_SKEW_MS`); none only for the machine-local lock file, which is
120
+ * judged by its pid alone
121
+ * @param id the acquisition's id; a fresh one when omitted
122
+ */
123
+ export function lockBody(
124
+ purpose: string,
125
+ now: Date,
126
+ holder?: LockHolder,
127
+ id: string = randomUUID(),
128
+ ): string {
129
+ const leaseMs = holder ? ("execution" in holder ? holder.maxRuntimeMs : holder.leaseMs) : null;
130
+ const body: LockBody = {
131
+ purpose,
132
+ id,
133
+ host: THIS_HOST.host,
134
+ machine: THIS_HOST.machine ?? null,
135
+ pid: process.pid,
136
+ startedAt: now.toISOString(),
137
+ ...(holder && "execution" in holder ? { execution: { ...holder.execution } } : {}),
138
+ ...(leaseMs !== null
139
+ ? { leaseUntil: new Date(now.getTime() + leaseMs + LEASE_SKEW_MS).toISOString() }
140
+ : {}),
141
+ };
142
+ return JSON.stringify(body);
143
+ }
144
+
145
+ /**
146
+ * The same lock body with its lease moved to `now + leaseMs + skew` — the
147
+ * heartbeat of a process holder. Everything else (id, start) is kept.
148
+ */
149
+ export function renewedLockBody(body: LockBody, now: Date, leaseMs: number): string {
150
+ return JSON.stringify({
151
+ ...body,
152
+ leaseUntil: new Date(now.getTime() + leaseMs + LEASE_SKEW_MS).toISOString(),
153
+ });
154
+ }
155
+
156
+ /** Whether process `pid` exists on this host (signal 0 checks without signalling). */
157
+ export function processAlive(pid: number): boolean {
158
+ try {
159
+ process.kill(pid, 0);
160
+ return true;
161
+ } catch (e) {
162
+ // EPERM: it exists, it is just someone else's.
163
+ return (e as NodeJS.ErrnoException).code === "EPERM";
164
+ }
165
+ }
166
+
167
+ /**
168
+ * An id for this machine AND pid namespace, so a pid is only ever checked
169
+ * where it was taken: the host name, the network cards' MAC addresses and
170
+ * (on Linux) the pid namespace. Null inside Cloud Run, where every
171
+ * container is called `localhost`, has its own pid space and no stable
172
+ * hardware to tell it apart — a lock from there is judged by its lease
173
+ * alone. Null too when the machine shows no network card. An id that
174
+ * changes while a lock is held (a new network card) only makes a later
175
+ * judge wait for the lease: a mismatch is always the safe direction.
176
+ */
177
+ export function machineId(env: NodeJS.ProcessEnv = process.env): string | null {
178
+ if (env.CLOUD_RUN_EXECUTION || env.K_SERVICE) return null;
179
+ const macs = new Set<string>();
180
+ for (const cards of Object.values(networkInterfaces())) {
181
+ for (const card of cards ?? []) {
182
+ if (!card.internal && card.mac && card.mac !== "00:00:00:00:00:00") macs.add(card.mac);
183
+ }
184
+ }
185
+ if (macs.size === 0) return null;
186
+ let pidSpace = "";
187
+ try {
188
+ pidSpace = readlinkSync("/proc/self/ns/pid");
189
+ } catch {
190
+ // Not Linux: the machine has one pid space, which the MACs already name.
191
+ }
192
+ return createHash("sha256")
193
+ .update([hostname(), pidSpace, ...[...macs].sort()].join("\n"))
194
+ .digest("hex")
195
+ .slice(0, 32);
196
+ }
197
+
198
+ /** The judging run's own host and how it checks a local pid. */
199
+ export interface LocalHost {
200
+ readonly host: string;
201
+ /**
202
+ * {@link machineId}; a lock that names another (or none) is never judged
203
+ * by its pid. Left out only by a lock from before the machine id, which
204
+ * falls back to comparing host names (state-lock-lease tests).
205
+ */
206
+ readonly machine?: string | null;
207
+ readonly alive: (pid: number) => boolean;
208
+ }
209
+
210
+ const THIS_HOST: LocalHost = { host: hostname(), machine: machineId(), alive: processAlive };
211
+
212
+ /** Parse a lock body; null when it is not one this code wrote. */
213
+ export function parseLockBody(text: string): LockBody | null {
214
+ let raw: unknown;
215
+ try {
216
+ raw = JSON.parse(text);
217
+ } catch {
218
+ return null;
219
+ }
220
+ const parsed = lockBodySchema.safeParse(raw);
221
+ return parsed.success ? parsed.data : null;
222
+ }
223
+
224
+ /** What Cloud Run says about the holder's execution. */
225
+ export type ExecutionState =
226
+ | { readonly kind: "running" }
227
+ | { readonly kind: "finished"; readonly how: string }
228
+ | { readonly kind: "not-found" }
229
+ /** Cloud Run could not be asked; `error` says why. */
230
+ | { readonly kind: "unknown"; readonly error: string };
231
+
232
+ const NOT_FOUND = /NOT_FOUND|not found|could not be found|does not exist/i;
233
+
234
+ const executionStatusSchema = z.object({
235
+ status: z
236
+ .object({
237
+ completionTime: z.string().optional(),
238
+ conditions: z
239
+ .array(
240
+ z.object({
241
+ type: z.string(),
242
+ status: z.string(),
243
+ reason: z.string().optional(),
244
+ }),
245
+ )
246
+ .optional(),
247
+ })
248
+ .optional(),
249
+ });
250
+
251
+ /**
252
+ * Read an execution's state from `gcloud run jobs executions describe
253
+ * --format=json`. An execution has finished once Cloud Run stamped its
254
+ * completion time or decided its `Completed` condition (True: succeeded;
255
+ * False: failed or cancelled). Anything else is still running — including
256
+ * an execution Cloud Run has not started yet.
257
+ */
258
+ export function parseExecutionState(result: GcloudResult): ExecutionState {
259
+ if (!result.ok) {
260
+ if (NOT_FOUND.test(result.stderr)) return { kind: "not-found" };
261
+ return {
262
+ kind: "unknown",
263
+ error: result.stderr.trim() || `gcloud exited ${result.exitCode}`,
264
+ };
265
+ }
266
+ let raw: unknown;
267
+ try {
268
+ raw = JSON.parse(result.stdout);
269
+ } catch {
270
+ return { kind: "unknown", error: "gcloud returned no JSON for the execution" };
271
+ }
272
+ const parsed = executionStatusSchema.safeParse(raw);
273
+ if (!parsed.success) {
274
+ return { kind: "unknown", error: "gcloud returned an execution without a readable status" };
275
+ }
276
+ const status = parsed.data.status;
277
+ const completed = status?.conditions?.find((c) => c.type === "Completed");
278
+ if (completed && (completed.status === "True" || completed.status === "False")) {
279
+ const how =
280
+ completed.status === "True" ? "succeeded" : (completed.reason ?? "failed").toLowerCase();
281
+ return { kind: "finished", how };
282
+ }
283
+ if (status?.completionTime) return { kind: "finished", how: "completed" };
284
+ return { kind: "running" };
285
+ }
286
+
287
+ /** `gcloud run jobs executions describe`, for {@link judgeHeldLock}. */
288
+ export function describeExecutionArgs(ref: CloudRunExecutionRef): string[] {
289
+ return [
290
+ "run",
291
+ "jobs",
292
+ "executions",
293
+ "describe",
294
+ ref.name,
295
+ `--region=${ref.region}`,
296
+ `--project=${ref.projectId}`,
297
+ "--format=json",
298
+ ];
299
+ }
300
+
301
+ /** Whether a held lock may be broken, and the words for either outcome. */
302
+ export type LockVerdict =
303
+ | { readonly kind: "break"; readonly why: string }
304
+ | { readonly kind: "held"; readonly why: string; readonly manualRemoval: boolean }
305
+ /** The holder could not be checked; never treated as "dead". */
306
+ | { readonly kind: "unknown"; readonly why: string };
307
+
308
+ /**
309
+ * Decide whether the lock with body `text` may be broken.
310
+ *
311
+ * @param describe looks the holder's execution up in Cloud Run; called only
312
+ * when the body names one
313
+ * @param now the judging run's clock, compared against the lease
314
+ * @param here this host's name and pid check, for a process holder's lock
315
+ */
316
+ export function judgeHeldLock(
317
+ text: string,
318
+ describe: (ref: CloudRunExecutionRef) => ExecutionState,
319
+ now: Date,
320
+ here: LocalHost = THIS_HOST,
321
+ ): LockVerdict {
322
+ const body = parseLockBody(text);
323
+ if (!body) {
324
+ return {
325
+ kind: "held",
326
+ why: `the lock's holder is not readable (${text.trim().slice(0, 200) || "empty"})`,
327
+ manualRemoval: true,
328
+ };
329
+ }
330
+ const who = holderText(body);
331
+ if (!body.execution) return judgeProcessLock(body, who, now, here);
332
+ const leaseUntil = body.leaseUntil ? Date.parse(body.leaseUntil) : Number.NaN;
333
+ const state = describe(body.execution);
334
+ switch (state.kind) {
335
+ case "finished":
336
+ return {
337
+ kind: "break",
338
+ why: `${who} has ${state.how}, but its lock was left behind`,
339
+ };
340
+ case "running":
341
+ return { kind: "held", why: `${who} is still running`, manualRemoval: false };
342
+ case "not-found":
343
+ if (Number.isNaN(leaseUntil)) {
344
+ return {
345
+ kind: "held",
346
+ why: `${who} is unknown to Cloud Run and its lock carries no readable lease`,
347
+ manualRemoval: true,
348
+ };
349
+ }
350
+ if (now.getTime() > leaseUntil) {
351
+ return {
352
+ kind: "break",
353
+ why: `${who} is unknown to Cloud Run and its lease ended at ${body.leaseUntil}`,
354
+ };
355
+ }
356
+ return {
357
+ kind: "held",
358
+ why: `${who} is unknown to Cloud Run, but its lease runs until ${body.leaseUntil}`,
359
+ manualRemoval: false,
360
+ };
361
+ case "unknown":
362
+ return {
363
+ kind: "unknown",
364
+ why: `could not check whether ${who} is still running: ${state.error}`,
365
+ };
366
+ }
367
+ }
368
+
369
+ /**
370
+ * A process holder's lock (a CLI run): broken when its pid is dead on this
371
+ * very host, or once its lease — renewed while the holder runs — expired.
372
+ */
373
+ function judgeProcessLock(body: LockBody, who: string, now: Date, here: LocalHost): LockVerdict {
374
+ if (body.pid !== undefined && sameMachine(body, here) && !here.alive(body.pid)) {
375
+ return { kind: "break", why: `${who} has ended (its process is gone on this machine)` };
376
+ }
377
+ if (body.leaseUntil === undefined) {
378
+ // Written before #815: no lease to wait out, and its host may be
379
+ // another machine — only a person may remove it.
380
+ return { kind: "held", why: `held by ${who}`, manualRemoval: true };
381
+ }
382
+ const leaseUntil = Date.parse(body.leaseUntil);
383
+ if (Number.isNaN(leaseUntil)) {
384
+ return {
385
+ kind: "held",
386
+ why: `held by ${who}, whose lock carries no readable lease`,
387
+ manualRemoval: true,
388
+ };
389
+ }
390
+ if (now.getTime() > leaseUntil) {
391
+ return {
392
+ kind: "break",
393
+ why: `${who} stopped renewing its lock; its lease ended at ${body.leaseUntil}`,
394
+ };
395
+ }
396
+ return {
397
+ kind: "held",
398
+ why: `held by ${who}; its lease runs until ${body.leaseUntil} unless renewed`,
399
+ manualRemoval: false,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Whether the lock was taken where `here` can check its pid. A lock naming
405
+ * a machine id needs this run's to match; one naming none (Cloud Run)
406
+ * never matches; a lock from before machine ids compares host names.
407
+ */
408
+ function sameMachine(body: LockBody, here: LocalHost): boolean {
409
+ if (body.host !== here.host) return false;
410
+ // A judge in Cloud Run shares its host name with every other container.
411
+ if (here.machine === null) return false;
412
+ if (body.machine === undefined) return true;
413
+ return body.machine !== null && here.machine != null && body.machine === here.machine;
414
+ }
415
+
416
+ /** "caelo upgrader job (run X, started T)" — for messages. */
417
+ function holderText(body: LockBody): string {
418
+ const parts: string[] = [];
419
+ if (body.execution) parts.push(`run ${body.execution.name}`);
420
+ else if (body.host) parts.push(`on ${body.host}${body.pid ? `, pid ${body.pid}` : ""}`);
421
+ if (body.startedAt) parts.push(`started ${body.startedAt}`);
422
+ return parts.length > 0 ? `${body.purpose} (${parts.join(", ")})` : body.purpose;
423
+ }