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