@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.
- package/dist/agent-db.js +97 -24
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +4 -1
- package/dist/cli.js.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +66 -0
- package/dist/converge-checkpoint.d.ts.map +1 -0
- package/dist/converge-checkpoint.js +61 -0
- package/dist/converge-checkpoint.js.map +1 -0
- package/dist/converge-job-gcp.d.ts +46 -0
- package/dist/converge-job-gcp.d.ts.map +1 -0
- package/dist/converge-job-gcp.js +368 -0
- package/dist/converge-job-gcp.js.map +1 -0
- package/dist/converge-job-setup.d.ts +62 -0
- package/dist/converge-job-setup.d.ts.map +1 -0
- package/dist/converge-job-setup.js +228 -0
- package/dist/converge-job-setup.js.map +1 -0
- package/dist/converge-job.d.ts +141 -0
- package/dist/converge-job.d.ts.map +1 -0
- package/dist/converge-job.js +325 -0
- package/dist/converge-job.js.map +1 -0
- package/dist/gcp-names.d.ts +9 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +13 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +33 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +11 -3
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js +22 -12
- package/dist/install-job.js.map +1 -1
- package/dist/lifecycle.d.ts +13 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +115 -58
- package/dist/lifecycle.js.map +1 -1
- package/dist/operator-access.d.ts +4 -0
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +43 -7
- package/dist/operator-access.js.map +1 -1
- package/dist/release-contract.d.ts +3 -2
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +5 -2
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts +2 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp-edge.d.ts +114 -0
- package/dist/remote-mcp-edge.d.ts.map +1 -0
- package/dist/remote-mcp-edge.js +116 -0
- package/dist/remote-mcp-edge.js.map +1 -0
- package/dist/remote-mcp.d.ts +139 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +434 -0
- package/dist/remote-mcp.js.map +1 -0
- package/dist/runtime-secrets.d.ts +12 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +32 -4
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/seal-key-rotation.d.ts +38 -0
- package/dist/seal-key-rotation.d.ts.map +1 -0
- package/dist/seal-key-rotation.js +206 -0
- package/dist/seal-key-rotation.js.map +1 -0
- package/dist/stack-contract.d.ts +206 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +219 -4
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +53 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +124 -4
- package/dist/stack-converge.js.map +1 -1
- package/dist/state-lock-lease.d.ts +142 -0
- package/dist/state-lock-lease.d.ts.map +1 -0
- package/dist/state-lock-lease.js +347 -0
- package/dist/state-lock-lease.js.map +1 -0
- package/dist/state-store.d.ts +66 -9
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +249 -30
- package/dist/state-store.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts +36 -7
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +53 -19
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -1
- package/dist/upgrade/audit-job-roll.js +3 -1
- package/dist/upgrade/audit-job-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +125 -12
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +27 -10
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +46 -7
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/record-rolled.d.ts +40 -0
- package/dist/upgrade/record-rolled.d.ts.map +1 -0
- package/dist/upgrade/record-rolled.js +46 -0
- package/dist/upgrade/record-rolled.js.map +1 -0
- package/dist/upgrade/upgrade.d.ts +45 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +60 -17
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +15 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +36 -6
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +15 -0
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +42 -7
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +123 -0
- package/dist/upgrader-iam-preflight.d.ts.map +1 -0
- package/dist/upgrader-iam-preflight.js +414 -0
- package/dist/upgrader-iam-preflight.js.map +1 -0
- package/dist/upgrader-job.d.ts +33 -9
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +169 -27
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +58 -0
- package/dist/upgrader-records.d.ts.map +1 -0
- package/dist/upgrader-records.js +87 -0
- package/dist/upgrader-records.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +3 -2
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +10 -2
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/upgrader-setup.d.ts +25 -3
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +123 -11
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.d.ts.map +1 -1
- package/dist/wizards/gcp-cost.js +11 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +14 -2
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +106 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -1
- package/src/audit-job-setup.test.ts +7 -1
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +4 -1
- package/src/compose.ts +5 -0
- package/src/converge-checkpoint.ts +75 -0
- package/src/converge-entrypoint.test.ts +213 -0
- package/src/converge-job-gcp.test.ts +222 -0
- package/src/converge-job-gcp.ts +443 -0
- package/src/converge-job-setup.test.ts +152 -0
- package/src/converge-job-setup.ts +268 -0
- package/src/converge-job.test.ts +486 -0
- package/src/converge-job.ts +542 -0
- package/src/gateway-env-isolation.test.ts +8 -1
- package/src/gcp-names.ts +15 -0
- package/src/gcp-url-map.test.ts +9 -1
- package/src/index.ts +8 -0
- package/src/infra-contract.test.ts +8 -4
- package/src/infra-contract.ts +33 -1
- package/src/install-job.test.ts +3 -1
- package/src/install-job.ts +31 -14
- package/src/lifecycle.ts +137 -71
- package/src/operator-access.test.ts +24 -1
- package/src/operator-access.ts +44 -6
- package/src/release-contract.ts +5 -2
- package/src/release-manifest.test.ts +42 -0
- package/src/release-manifest.ts +2 -0
- package/src/remote-mcp-edge.ts +137 -0
- package/src/remote-mcp.test.ts +690 -0
- package/src/remote-mcp.ts +524 -0
- package/src/runtime-secrets.test.ts +31 -6
- package/src/runtime-secrets.ts +41 -3
- package/src/seal-key-rotation.test.ts +131 -0
- package/src/seal-key-rotation.ts +258 -0
- package/src/stack-contract-parity.test.ts +20 -3
- package/src/stack-contract.test.ts +32 -2
- package/src/stack-contract.ts +363 -4
- package/src/stack-converge-secrets.test.ts +4 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +162 -7
- package/src/state-attach.test.ts +7 -1
- package/src/state-lock-lease.test.ts +302 -0
- package/src/state-lock-lease.ts +423 -0
- package/src/state-lock.test.ts +568 -0
- package/src/state-store.ts +347 -27
- package/src/upgrade/app-roll.ts +102 -21
- package/src/upgrade/audit-job-roll.ts +3 -1
- package/src/upgrade/converge.ts +148 -10
- package/src/upgrade/gcp-lookup.test.ts +80 -0
- package/src/upgrade/gcp-lookup.ts +65 -8
- package/src/upgrade/record-rolled.test.ts +105 -0
- package/src/upgrade/record-rolled.ts +91 -0
- package/src/upgrade/upgrade.test.ts +203 -3
- package/src/upgrade/upgrade.ts +120 -24
- package/src/upgrader-assess.test.ts +92 -3
- package/src/upgrader-assess.ts +58 -9
- package/src/upgrader-gcp.ts +47 -8
- package/src/upgrader-iam-preflight.test.ts +393 -0
- package/src/upgrader-iam-preflight.ts +510 -0
- package/src/upgrader-job.test.ts +586 -15
- package/src/upgrader-job.ts +237 -53
- package/src/upgrader-records.ts +151 -0
- package/src/upgrader-reporter.test.ts +6 -0
- package/src/upgrader-reporter.ts +7 -2
- package/src/upgrader-setup.test.ts +3 -3
- package/src/upgrader-setup.ts +128 -11
- package/src/wizards/gcp-cost.ts +12 -3
- package/src/wizards/gcp-pulumi.test.ts +15 -0
- package/src/wizards/gcp-pulumi.ts +18 -2
- package/src/wizards/gcp.ts +134 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +47 -7
- package/stacks/gcp-firebase/index.ts +38 -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
|
+
}
|