@caelo-cms/provisioning 0.10.55 → 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.
- package/dist/agent-db.js +76 -19
- 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 +22 -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.map +1 -1
- package/dist/operator-access.js +13 -3
- 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 +117 -0
- package/dist/remote-mcp.d.ts.map +1 -0
- package/dist/remote-mcp.js +379 -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 +167 -3
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +171 -3
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts +38 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +74 -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/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +111 -9
- 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.d.ts.map +1 -1
- package/dist/wizards/gcp.js +99 -4
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/agent/run.test.ts +14 -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 +7 -4
- package/src/infra-contract.ts +22 -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 +14 -0
- package/src/operator-access.ts +13 -3
- 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 +517 -0
- package/src/remote-mcp.ts +460 -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 +30 -2
- package/src/stack-contract.ts +291 -3
- package/src/stack-converge-secrets.test.ts +3 -0
- package/src/stack-converge.test.ts +21 -5
- package/src/stack-converge.ts +101 -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 +96 -21
- package/src/upgrade/converge.ts +130 -8
- 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.ts +125 -4
- package/stacks/aws/index.ts +10 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +46 -7
- package/stacks/gcp-firebase/index.ts +37 -6
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bucket state lock (state-store.ts acquireBucketStateLock, #815)
|
|
5
|
+
* against a fake bucket with object generations and a fake Cloud Run: a
|
|
6
|
+
* dead holder's lock is broken, a live one never; every delete is
|
|
7
|
+
* generation-matched, so two runs racing over one stale lock cannot both
|
|
8
|
+
* win and a run never deletes a lock it did not judge.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
16
|
+
import { LEASE_SKEW_MS, type LocalHost, lockBody, PROCESS_LEASE_MS } from "./state-lock-lease.js";
|
|
17
|
+
import {
|
|
18
|
+
acquireBucketStateLock,
|
|
19
|
+
acquireLocalStateLock,
|
|
20
|
+
bucketStateLockUrl,
|
|
21
|
+
releaseOnSignal,
|
|
22
|
+
type SignalTarget,
|
|
23
|
+
StateLockHeldError,
|
|
24
|
+
type SyncGcloud,
|
|
25
|
+
} from "./state-store.js";
|
|
26
|
+
|
|
27
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
28
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
29
|
+
const PRECONDITION =
|
|
30
|
+
"HTTPError 412: At least one of the pre-conditions you specified did not hold.";
|
|
31
|
+
|
|
32
|
+
const BUCKET = "acme-caelo-ops";
|
|
33
|
+
const URL = bucketStateLockUrl(BUCKET);
|
|
34
|
+
const HOUR = 60 * 60_000;
|
|
35
|
+
const T0 = new Date("2026-10-16T10:00:00Z");
|
|
36
|
+
|
|
37
|
+
type Execution = "running" | "succeeded" | "cancelled" | "missing" | "denied";
|
|
38
|
+
|
|
39
|
+
/** A bucket holding objects by generation, and Cloud Run's executions. */
|
|
40
|
+
function fakeCloud(executions: Record<string, Execution> = {}) {
|
|
41
|
+
const objects = new Map<string, { generation: number; body: string }>();
|
|
42
|
+
let nextGeneration = 1000;
|
|
43
|
+
const calls: string[] = [];
|
|
44
|
+
/** Runs once, right before the next generation-matched `rm` — to interleave a rival. */
|
|
45
|
+
let beforeRm: (() => void) | null = null;
|
|
46
|
+
const run: SyncGcloud = (args, input) => {
|
|
47
|
+
calls.push(args.join(" "));
|
|
48
|
+
const [svc, verb] = args;
|
|
49
|
+
if (svc === "storage" && verb === "cp") {
|
|
50
|
+
const url = args[3] ?? "";
|
|
51
|
+
const match = args.find((a) => a.startsWith("--if-generation-match="))?.split("=")[1];
|
|
52
|
+
const o = objects.get(url);
|
|
53
|
+
if (match === "0" && o) return fail(PRECONDITION);
|
|
54
|
+
if (match !== undefined && match !== "0" && o?.generation !== Number(match)) {
|
|
55
|
+
return fail(PRECONDITION);
|
|
56
|
+
}
|
|
57
|
+
objects.set(url, { generation: nextGeneration++, body: input ?? "" });
|
|
58
|
+
return ok();
|
|
59
|
+
}
|
|
60
|
+
if (svc === "storage" && verb === "objects") {
|
|
61
|
+
const o = objects.get(args[3] ?? "");
|
|
62
|
+
return o
|
|
63
|
+
? ok(JSON.stringify({ name: "install/state.lock", generation: String(o.generation) }))
|
|
64
|
+
: fail("ERROR: NotFound 404: gs://… does not exist");
|
|
65
|
+
}
|
|
66
|
+
if (svc === "storage" && verb === "cat") {
|
|
67
|
+
const o = objects.get(args[2] ?? "");
|
|
68
|
+
return o ? ok(o.body) : fail("One or more URLs matched no objects.");
|
|
69
|
+
}
|
|
70
|
+
if (svc === "storage" && verb === "rm") {
|
|
71
|
+
const url = args[2] ?? "";
|
|
72
|
+
const match = args.find((a) => a.startsWith("--if-generation-match="));
|
|
73
|
+
if (match && beforeRm) {
|
|
74
|
+
const hook = beforeRm;
|
|
75
|
+
beforeRm = null;
|
|
76
|
+
hook();
|
|
77
|
+
}
|
|
78
|
+
const o = objects.get(url);
|
|
79
|
+
if (!o) return fail("One or more URLs matched no objects.");
|
|
80
|
+
if (match && match.split("=")[1] !== String(o.generation)) return fail(PRECONDITION);
|
|
81
|
+
objects.delete(url);
|
|
82
|
+
return ok();
|
|
83
|
+
}
|
|
84
|
+
if (svc === "run" && args[2] === "executions") {
|
|
85
|
+
const state = executions[args[4] ?? ""] ?? "missing";
|
|
86
|
+
const status = (s: string, reason?: string) =>
|
|
87
|
+
ok(JSON.stringify({ status: { conditions: [{ type: "Completed", status: s, reason }] } }));
|
|
88
|
+
switch (state) {
|
|
89
|
+
case "running":
|
|
90
|
+
return status("Unknown");
|
|
91
|
+
case "succeeded":
|
|
92
|
+
return status("True");
|
|
93
|
+
case "cancelled":
|
|
94
|
+
return status("False", "Cancelled");
|
|
95
|
+
case "missing":
|
|
96
|
+
return fail("ERROR: (gcloud.run.jobs.executions.describe) NOT_FOUND: Resource not found");
|
|
97
|
+
case "denied":
|
|
98
|
+
return fail("ERROR: PERMISSION_DENIED: run.executions.get");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return fail(`unexpected: ${args.join(" ")}`);
|
|
102
|
+
};
|
|
103
|
+
return {
|
|
104
|
+
run,
|
|
105
|
+
calls,
|
|
106
|
+
objects,
|
|
107
|
+
lockBody: () => objects.get(URL)?.body ?? null,
|
|
108
|
+
plantLock: (body: string) => objects.set(URL, { generation: nextGeneration++, body }),
|
|
109
|
+
interleave: (hook: () => void) => {
|
|
110
|
+
beforeRm = hook;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const jobHolder = (name: string) => ({
|
|
116
|
+
execution: { projectId: "acme", region: "europe-west1", name },
|
|
117
|
+
maxRuntimeMs: HOUR,
|
|
118
|
+
});
|
|
119
|
+
/** A lock run `name` took at T0. */
|
|
120
|
+
const jobLockAtT0 = (name: string) =>
|
|
121
|
+
lockBody("caelo upgrader job", T0, jobHolder(name), `id-${name}`);
|
|
122
|
+
|
|
123
|
+
describe("acquireBucketStateLock", () => {
|
|
124
|
+
it("takes a free lock with the holder's execution and lease, and releases only its own generation", () => {
|
|
125
|
+
const cloud = fakeCloud();
|
|
126
|
+
const release = acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
127
|
+
holder: jobHolder("x2"),
|
|
128
|
+
now: () => T0,
|
|
129
|
+
});
|
|
130
|
+
expect(JSON.parse(cloud.lockBody() ?? "")).toMatchObject({
|
|
131
|
+
execution: { name: "x2" },
|
|
132
|
+
leaseUntil: new Date(T0.getTime() + HOUR + LEASE_SKEW_MS).toISOString(),
|
|
133
|
+
});
|
|
134
|
+
release();
|
|
135
|
+
expect(cloud.objects.size).toBe(0);
|
|
136
|
+
expect(cloud.calls.at(-1)).toBe(`storage rm ${URL} --if-generation-match=1000`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("#818: a generation gcloud does not print as a digit string is not trusted for the release", () => {
|
|
140
|
+
// `gcloud storage objects describe` prints `generation: '1706304784726341'`
|
|
141
|
+
// (a string); a JSON number past 2^53 would have lost digits.
|
|
142
|
+
for (const generation of [1000, "", "12a"]) {
|
|
143
|
+
const cloud = fakeCloud();
|
|
144
|
+
const run: SyncGcloud = (args, input) =>
|
|
145
|
+
args[1] === "objects"
|
|
146
|
+
? ok(JSON.stringify({ name: "install/state.lock", generation }))
|
|
147
|
+
: cloud.run(args, input);
|
|
148
|
+
expect(() =>
|
|
149
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", run, {
|
|
150
|
+
holder: jobHolder("x2"),
|
|
151
|
+
now: () => T0,
|
|
152
|
+
}),
|
|
153
|
+
).toThrow(/without a generation.*expires with its lease/);
|
|
154
|
+
// Never deleted blind: an unverifiable generation leaves the lock to
|
|
155
|
+
// its lease (#872 review), and the error says so.
|
|
156
|
+
expect(cloud.objects.size).toBe(1);
|
|
157
|
+
expect(cloud.calls.some((c) => c.startsWith("storage rm"))).toBe(false);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("breaks the lock of a finished run and takes it — and says so", () => {
|
|
162
|
+
for (const how of ["succeeded", "cancelled"] as const) {
|
|
163
|
+
const cloud = fakeCloud({ x1: how });
|
|
164
|
+
cloud.plantLock(jobLockAtT0("x1"));
|
|
165
|
+
const broke: string[] = [];
|
|
166
|
+
const release = acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
167
|
+
holder: jobHolder("x2"),
|
|
168
|
+
onBreak: (m) => broke.push(m),
|
|
169
|
+
now: () => new Date(T0.getTime() + 5 * 60_000),
|
|
170
|
+
});
|
|
171
|
+
expect(JSON.parse(cloud.lockBody() ?? "").execution.name).toBe("x2");
|
|
172
|
+
expect(broke).toHaveLength(1);
|
|
173
|
+
expect(broke[0]).toContain("run x1");
|
|
174
|
+
// The stale lock was removed by its generation, never blindly.
|
|
175
|
+
expect(cloud.calls).toContain(`storage rm ${URL} --if-generation-match=1000`);
|
|
176
|
+
release();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("never breaks the lock of a running execution, even after its lease", () => {
|
|
181
|
+
const cloud = fakeCloud({ x1: "running" });
|
|
182
|
+
cloud.plantLock(jobLockAtT0("x1"));
|
|
183
|
+
const before = cloud.lockBody();
|
|
184
|
+
expect(() =>
|
|
185
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
186
|
+
holder: jobHolder("x2"),
|
|
187
|
+
now: () => new Date(T0.getTime() + 10 * HOUR),
|
|
188
|
+
}),
|
|
189
|
+
).toThrow(StateLockHeldError);
|
|
190
|
+
expect(cloud.lockBody()).toBe(before);
|
|
191
|
+
expect(cloud.calls.some((c) => c.startsWith("storage rm"))).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("a run Cloud Run no longer knows: held within its lease, broken after it", () => {
|
|
195
|
+
const within = fakeCloud();
|
|
196
|
+
within.plantLock(jobLockAtT0("x1"));
|
|
197
|
+
expect(() =>
|
|
198
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", within.run, {
|
|
199
|
+
now: () => new Date(T0.getTime() + 30 * 60_000),
|
|
200
|
+
}),
|
|
201
|
+
).toThrow(/lease runs until/);
|
|
202
|
+
|
|
203
|
+
const after = fakeCloud();
|
|
204
|
+
after.plantLock(jobLockAtT0("x1"));
|
|
205
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", after.run, {
|
|
206
|
+
now: () => new Date(T0.getTime() + HOUR + LEASE_SKEW_MS + 1),
|
|
207
|
+
})();
|
|
208
|
+
expect(after.objects.size).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("a holder it cannot check is an access error, not 'busy', and the lock stays", () => {
|
|
212
|
+
const cloud = fakeCloud({ x1: "denied" });
|
|
213
|
+
cloud.plantLock(jobLockAtT0("x1"));
|
|
214
|
+
let thrown: unknown;
|
|
215
|
+
try {
|
|
216
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, { now: () => T0 });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
thrown = e;
|
|
219
|
+
}
|
|
220
|
+
expect(thrown).toBeInstanceOf(Error);
|
|
221
|
+
expect(thrown).not.toBeInstanceOf(StateLockHeldError);
|
|
222
|
+
expect(String(thrown)).toContain("PERMISSION_DENIED");
|
|
223
|
+
expect(cloud.lockBody()).toBe(jobLockAtT0("x1"));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("a CLI run's lock is held, with the command to remove it by hand", () => {
|
|
227
|
+
const cloud = fakeCloud();
|
|
228
|
+
cloud.plantLock(lockBody("cms-provision upgrade", T0));
|
|
229
|
+
expect(() =>
|
|
230
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
231
|
+
now: () => new Date(T0.getTime() + 100 * HOUR),
|
|
232
|
+
}),
|
|
233
|
+
).toThrow(`gcloud storage rm ${URL}`);
|
|
234
|
+
expect(cloud.calls.some((c) => c.startsWith("run "))).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("adversarial: two runs racing over one stale lock — exactly one wins, the other is told 'held'", () => {
|
|
238
|
+
const cloud = fakeCloud({ x1: "succeeded", x2: "running", x3: "running" });
|
|
239
|
+
cloud.plantLock(jobLockAtT0("x1"));
|
|
240
|
+
const now = () => new Date(T0.getTime() + 2 * HOUR);
|
|
241
|
+
let rivalRelease: (() => void) | null = null;
|
|
242
|
+
// Run x3 has judged x1's lock dead; before its delete lands, run x2
|
|
243
|
+
// breaks the same lock and takes it.
|
|
244
|
+
cloud.interleave(() => {
|
|
245
|
+
rivalRelease = acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
246
|
+
holder: jobHolder("x2"),
|
|
247
|
+
now,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
expect(() =>
|
|
251
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
252
|
+
holder: jobHolder("x3"),
|
|
253
|
+
now,
|
|
254
|
+
}),
|
|
255
|
+
).toThrow(StateLockHeldError);
|
|
256
|
+
// x3's generation-matched delete did not touch x2's fresh lock.
|
|
257
|
+
expect(JSON.parse(cloud.lockBody() ?? "").execution.name).toBe("x2");
|
|
258
|
+
expect(rivalRelease).not.toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("adversarial: two runs both breaking — the slower create loses instead of overwriting", () => {
|
|
262
|
+
const cloud = fakeCloud({ x1: "succeeded", x2: "running" });
|
|
263
|
+
cloud.plantLock(jobLockAtT0("x1"));
|
|
264
|
+
const now = () => new Date(T0.getTime() + 2 * HOUR);
|
|
265
|
+
// Right after x3 removed x1's lock, x2 (which found it free) takes it.
|
|
266
|
+
let first = true;
|
|
267
|
+
const run: SyncGcloud = (args, input) => {
|
|
268
|
+
const r = cloud.run(args, input);
|
|
269
|
+
if (first && args[1] === "rm" && r.ok) {
|
|
270
|
+
first = false;
|
|
271
|
+
cloud.run(["storage", "cp", "-", URL, "--if-generation-match=0"], jobLockAtT0("x2"));
|
|
272
|
+
}
|
|
273
|
+
return r;
|
|
274
|
+
};
|
|
275
|
+
expect(() =>
|
|
276
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", run, { holder: jobHolder("x3"), now }),
|
|
277
|
+
).toThrow(/at the same moment/);
|
|
278
|
+
expect(JSON.parse(cloud.lockBody() ?? "").execution.name).toBe("x2");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("a release after its lock was broken leaves the successor's lock alone", () => {
|
|
282
|
+
const cloud = fakeCloud({ x1: "succeeded" });
|
|
283
|
+
const release = acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
284
|
+
holder: jobHolder("x1"),
|
|
285
|
+
now: () => T0,
|
|
286
|
+
});
|
|
287
|
+
// Cloud Run says x1 finished (it was, say, a zombie); x2 takes over.
|
|
288
|
+
const successor = acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
289
|
+
holder: jobHolder("x2"),
|
|
290
|
+
now: () => new Date(T0.getTime() + HOUR),
|
|
291
|
+
});
|
|
292
|
+
expect(() => release()).toThrow(/no longer this run's/);
|
|
293
|
+
expect(JSON.parse(cloud.lockBody() ?? "").execution.name).toBe("x2");
|
|
294
|
+
successor();
|
|
295
|
+
expect(cloud.objects.size).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** A heartbeat the test drives by hand. */
|
|
300
|
+
function manualHeartbeat() {
|
|
301
|
+
let tick: (() => void) | null = null;
|
|
302
|
+
let every = 0;
|
|
303
|
+
let stopped = false;
|
|
304
|
+
return {
|
|
305
|
+
schedule: (fn: () => void, ms: number) => {
|
|
306
|
+
tick = fn;
|
|
307
|
+
every = ms;
|
|
308
|
+
return () => {
|
|
309
|
+
stopped = true;
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
beat: () => tick?.(),
|
|
313
|
+
every: () => every,
|
|
314
|
+
stopped: () => stopped,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A CLI lock taken on `host` by `pid`, with a lease renewed last at `renewedAt`. */
|
|
319
|
+
const cliLock = (host: string, pid: number, renewedAt: Date) =>
|
|
320
|
+
JSON.stringify({
|
|
321
|
+
purpose: "cms-provision upgrade",
|
|
322
|
+
id: `cli-${host}-${pid}`,
|
|
323
|
+
host,
|
|
324
|
+
pid,
|
|
325
|
+
startedAt: T0.toISOString(),
|
|
326
|
+
leaseUntil: new Date(renewedAt.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const HERE: LocalHost = { host: "laptop", alive: (pid) => pid === 4242 };
|
|
330
|
+
|
|
331
|
+
describe("acquireBucketStateLock — a CLI holder (#815 review)", () => {
|
|
332
|
+
it("a CLI lock names host + pid and a lease, renewed by its heartbeat until released", () => {
|
|
333
|
+
const cloud = fakeCloud();
|
|
334
|
+
const hb = manualHeartbeat();
|
|
335
|
+
let clock = T0;
|
|
336
|
+
const release = acquireBucketStateLock(BUCKET, "cms-provision upgrade", cloud.run, {
|
|
337
|
+
now: () => clock,
|
|
338
|
+
schedule: hb.schedule,
|
|
339
|
+
});
|
|
340
|
+
const first = JSON.parse(cloud.lockBody() ?? "");
|
|
341
|
+
expect(first).toMatchObject({ purpose: "cms-provision upgrade", pid: process.pid });
|
|
342
|
+
expect(typeof first.host).toBe("string");
|
|
343
|
+
expect(first.leaseUntil).toBe(
|
|
344
|
+
new Date(T0.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
345
|
+
);
|
|
346
|
+
expect(hb.every()).toBe(Math.floor(PROCESS_LEASE_MS / 3));
|
|
347
|
+
|
|
348
|
+
// An hour-long upgrade keeps its lock: every beat moves the lease on.
|
|
349
|
+
clock = new Date(T0.getTime() + HOUR);
|
|
350
|
+
hb.beat();
|
|
351
|
+
const renewed = JSON.parse(cloud.lockBody() ?? "");
|
|
352
|
+
expect(renewed.id).toBe(first.id);
|
|
353
|
+
expect(renewed.leaseUntil).toBe(
|
|
354
|
+
new Date(clock.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS).toISOString(),
|
|
355
|
+
);
|
|
356
|
+
// A run judging at that moment finds it held.
|
|
357
|
+
expect(() =>
|
|
358
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", cloud.run, {
|
|
359
|
+
holder: jobHolder("x2"),
|
|
360
|
+
now: () => new Date(clock.getTime() + 60_000),
|
|
361
|
+
}),
|
|
362
|
+
).toThrow(StateLockHeldError);
|
|
363
|
+
|
|
364
|
+
// Release stops the heartbeat and deletes the renewed generation.
|
|
365
|
+
release();
|
|
366
|
+
expect(hb.stopped()).toBe(true);
|
|
367
|
+
expect(cloud.objects.size).toBe(0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("a killed CLI on another machine (closed Cloud Shell): held within its lease, broken after it", () => {
|
|
371
|
+
const within = fakeCloud();
|
|
372
|
+
within.plantLock(cliLock("cs-123", 77, T0));
|
|
373
|
+
expect(() =>
|
|
374
|
+
acquireBucketStateLock(BUCKET, "caelo upgrader job", within.run, {
|
|
375
|
+
holder: jobHolder("x2"),
|
|
376
|
+
now: () => new Date(T0.getTime() + PROCESS_LEASE_MS),
|
|
377
|
+
here: HERE,
|
|
378
|
+
}),
|
|
379
|
+
).toThrow(/unless renewed/);
|
|
380
|
+
|
|
381
|
+
const after = fakeCloud();
|
|
382
|
+
after.plantLock(cliLock("cs-123", 77, T0));
|
|
383
|
+
const broke: string[] = [];
|
|
384
|
+
const release = acquireBucketStateLock(BUCKET, "caelo upgrader job", after.run, {
|
|
385
|
+
holder: jobHolder("x2"),
|
|
386
|
+
now: () => new Date(T0.getTime() + PROCESS_LEASE_MS + LEASE_SKEW_MS + 1),
|
|
387
|
+
here: HERE,
|
|
388
|
+
onBreak: (m) => broke.push(m),
|
|
389
|
+
});
|
|
390
|
+
expect(broke[0]).toContain("stopped renewing its lock");
|
|
391
|
+
expect(JSON.parse(after.lockBody() ?? "").execution.name).toBe("x2");
|
|
392
|
+
release();
|
|
393
|
+
// Never asked Cloud Run about a CLI holder.
|
|
394
|
+
expect(after.calls.some((c) => c.startsWith("run "))).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("a CLI lock of this machine whose process is gone is broken at once, a live one is not", () => {
|
|
398
|
+
const dead = fakeCloud();
|
|
399
|
+
dead.plantLock(cliLock("laptop", 9999, T0));
|
|
400
|
+
acquireBucketStateLock(BUCKET, "cms-provision upgrade", dead.run, {
|
|
401
|
+
now: () => new Date(T0.getTime() + 60_000),
|
|
402
|
+
here: HERE,
|
|
403
|
+
schedule: manualHeartbeat().schedule,
|
|
404
|
+
})();
|
|
405
|
+
expect(dead.objects.size).toBe(0);
|
|
406
|
+
|
|
407
|
+
const live = fakeCloud();
|
|
408
|
+
live.plantLock(cliLock("laptop", 4242, T0));
|
|
409
|
+
expect(() =>
|
|
410
|
+
acquireBucketStateLock(BUCKET, "cms-provision upgrade", live.run, {
|
|
411
|
+
now: () => new Date(T0.getTime() + 60_000),
|
|
412
|
+
here: HERE,
|
|
413
|
+
}),
|
|
414
|
+
).toThrow(StateLockHeldError);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("a heartbeat that finds its lock broken reports it, stops renewing, and leaves the successor", () => {
|
|
418
|
+
const cloud = fakeCloud();
|
|
419
|
+
const hb = manualHeartbeat();
|
|
420
|
+
const lost: string[] = [];
|
|
421
|
+
const release = acquireBucketStateLock(BUCKET, "cms-provision upgrade", cloud.run, {
|
|
422
|
+
now: () => T0,
|
|
423
|
+
schedule: hb.schedule,
|
|
424
|
+
onLost: (m) => lost.push(m),
|
|
425
|
+
});
|
|
426
|
+
// The laptop slept past the lease; a job broke the lock and took it.
|
|
427
|
+
cloud.objects.delete(URL);
|
|
428
|
+
cloud.plantLock(jobLockAtT0("x9"));
|
|
429
|
+
const successor = cloud.lockBody();
|
|
430
|
+
hb.beat();
|
|
431
|
+
hb.beat();
|
|
432
|
+
expect(lost).toHaveLength(1);
|
|
433
|
+
expect(lost[0]).toContain("no longer holds");
|
|
434
|
+
expect(cloud.lockBody()).toBe(successor);
|
|
435
|
+
expect(() => release()).toThrow(/no longer this run's/);
|
|
436
|
+
expect(cloud.lockBody()).toBe(successor);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("give-back after an unreadable generation: deleted only by a verified generation, else left to the lease", () => {
|
|
440
|
+
// The first describe fails, the second works: the lock is verified as
|
|
441
|
+
// this run's and removed by its generation.
|
|
442
|
+
const flaky = fakeCloud();
|
|
443
|
+
let describes = 0;
|
|
444
|
+
const once: SyncGcloud = (args, input) => {
|
|
445
|
+
if (args[1] === "objects" && describes++ === 0) return fail("ERROR: 503 backend error");
|
|
446
|
+
return flaky.run(args, input);
|
|
447
|
+
};
|
|
448
|
+
expect(() =>
|
|
449
|
+
acquireBucketStateLock(BUCKET, "cms-provision upgrade", once, { now: () => T0 }),
|
|
450
|
+
).toThrow(/It was given back/);
|
|
451
|
+
expect(flaky.objects.size).toBe(0);
|
|
452
|
+
expect(flaky.calls.filter((c) => c.startsWith("storage rm"))).toEqual([
|
|
453
|
+
`storage rm ${URL} --if-generation-match=1000`,
|
|
454
|
+
]);
|
|
455
|
+
|
|
456
|
+
// Never readable: nothing is deleted blindly — the lease ends the lock.
|
|
457
|
+
const blind = fakeCloud();
|
|
458
|
+
const never: SyncGcloud = (args, input) =>
|
|
459
|
+
args[1] === "objects" ? fail("ERROR: 503 backend error") : blind.run(args, input);
|
|
460
|
+
expect(() =>
|
|
461
|
+
acquireBucketStateLock(BUCKET, "cms-provision upgrade", never, { now: () => T0 }),
|
|
462
|
+
).toThrow(/expires with its lease/);
|
|
463
|
+
expect(blind.objects.size).toBe(1);
|
|
464
|
+
expect(blind.calls.some((c) => c.startsWith("storage rm"))).toBe(false);
|
|
465
|
+
|
|
466
|
+
// Replaced by another run in between: its lock is never touched.
|
|
467
|
+
const raced = fakeCloud();
|
|
468
|
+
let first = true;
|
|
469
|
+
const rival: SyncGcloud = (args, input) => {
|
|
470
|
+
if (args[1] === "objects" && first) {
|
|
471
|
+
first = false;
|
|
472
|
+
raced.objects.delete(URL);
|
|
473
|
+
raced.plantLock(jobLockAtT0("x5"));
|
|
474
|
+
return fail("ERROR: 503 backend error");
|
|
475
|
+
}
|
|
476
|
+
return raced.run(args, input);
|
|
477
|
+
};
|
|
478
|
+
expect(() =>
|
|
479
|
+
acquireBucketStateLock(BUCKET, "cms-provision upgrade", rival, { now: () => T0 }),
|
|
480
|
+
).toThrow(/expires with its lease/);
|
|
481
|
+
expect(JSON.parse(raced.lockBody() ?? "").execution.name).toBe("x5");
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("acquireLocalStateLock (#815 review)", () => {
|
|
486
|
+
it("takes over a lock whose process is gone on this machine; a live holder's stays", () => {
|
|
487
|
+
const root = mkdtempSync(join(tmpdir(), "caelo-lock-"));
|
|
488
|
+
try {
|
|
489
|
+
writeFileSync(join(root, "state.lock"), cliLock("laptop", 9999, T0));
|
|
490
|
+
const release = acquireLocalStateLock(root, "cms-provision upgrade", HERE);
|
|
491
|
+
expect(JSON.parse(readFileSync(join(root, "state.lock"), "utf8")).pid).toBe(process.pid);
|
|
492
|
+
release();
|
|
493
|
+
|
|
494
|
+
writeFileSync(join(root, "state.lock"), cliLock("laptop", 4242, new Date()));
|
|
495
|
+
expect(() => acquireLocalStateLock(root, "cms-provision upgrade", HERE)).toThrow(
|
|
496
|
+
StateLockHeldError,
|
|
497
|
+
);
|
|
498
|
+
} finally {
|
|
499
|
+
rmSync(root, { recursive: true, force: true });
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("releaseOnSignal (#815 review)", () => {
|
|
505
|
+
function fakeProcess() {
|
|
506
|
+
const listeners = new Map<string, () => void>();
|
|
507
|
+
const exits: number[] = [];
|
|
508
|
+
const proc: SignalTarget = {
|
|
509
|
+
once: (signal, listener) => listeners.set(signal, listener),
|
|
510
|
+
removeListener: (signal, listener) => {
|
|
511
|
+
if (listeners.get(signal) === listener) listeners.delete(signal);
|
|
512
|
+
},
|
|
513
|
+
exit: (code) => {
|
|
514
|
+
exits.push(code);
|
|
515
|
+
return undefined as never;
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
return { proc, listeners, exits };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
it("Ctrl-C or SIGTERM gives the lock back, then exits with the signal's code", () => {
|
|
522
|
+
for (const [signal, code] of [
|
|
523
|
+
["SIGINT", 130],
|
|
524
|
+
["SIGTERM", 143],
|
|
525
|
+
] as const) {
|
|
526
|
+
const p = fakeProcess();
|
|
527
|
+
const released: string[] = [];
|
|
528
|
+
const said: string[] = [];
|
|
529
|
+
releaseOnSignal(
|
|
530
|
+
() => released.push("lock"),
|
|
531
|
+
(m) => said.push(m),
|
|
532
|
+
p.proc,
|
|
533
|
+
);
|
|
534
|
+
p.listeners.get(signal)?.();
|
|
535
|
+
expect(released).toEqual(["lock"]);
|
|
536
|
+
expect(p.exits).toEqual([code]);
|
|
537
|
+
expect(said[0]).toContain("state lock was released");
|
|
538
|
+
expect(p.listeners.size).toBe(0);
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("a failed release is reported, and the process still exits", () => {
|
|
543
|
+
const p = fakeProcess();
|
|
544
|
+
const said: string[] = [];
|
|
545
|
+
releaseOnSignal(
|
|
546
|
+
() => {
|
|
547
|
+
throw new Error("Could not release gs://x; remove it");
|
|
548
|
+
},
|
|
549
|
+
(m) => said.push(m),
|
|
550
|
+
p.proc,
|
|
551
|
+
);
|
|
552
|
+
p.listeners.get("SIGTERM")?.();
|
|
553
|
+
expect(said[0]).toContain("Could not release");
|
|
554
|
+
expect(p.exits).toEqual([143]);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("once the run released the lock itself, the handlers are gone", () => {
|
|
558
|
+
const p = fakeProcess();
|
|
559
|
+
const dispose = releaseOnSignal(
|
|
560
|
+
() => {},
|
|
561
|
+
() => {},
|
|
562
|
+
p.proc,
|
|
563
|
+
);
|
|
564
|
+
expect(p.listeners.size).toBe(2);
|
|
565
|
+
dispose();
|
|
566
|
+
expect(p.listeners.size).toBe(0);
|
|
567
|
+
});
|
|
568
|
+
});
|