@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
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,12 @@
|
|
|
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
|
+
SERVICE_SECRET_FILES,
|
|
35
|
+
} from "../stack-contract.js";
|
|
31
36
|
import {
|
|
32
37
|
type DeployedService,
|
|
33
38
|
type EnvChange,
|
|
@@ -35,10 +40,13 @@ import {
|
|
|
35
40
|
type LiveVolumes,
|
|
36
41
|
liveContainerEnv,
|
|
37
42
|
liveContainerMemory,
|
|
43
|
+
liveTimeoutSeconds,
|
|
38
44
|
liveVolumes,
|
|
39
45
|
planAdminMemory,
|
|
40
46
|
planContractEnv,
|
|
41
47
|
planMediaVolume,
|
|
48
|
+
planRequestTimeout,
|
|
49
|
+
planSecretFiles,
|
|
42
50
|
rollService,
|
|
43
51
|
serviceRollArgs,
|
|
44
52
|
} from "../stack-converge.js";
|
|
@@ -89,15 +97,25 @@ export interface ServicePlan {
|
|
|
89
97
|
/** Container memory limit now (null = Cloud Run's default). */
|
|
90
98
|
readonly liveMemory: string | null;
|
|
91
99
|
readonly liveVolumes: LiveVolumes;
|
|
100
|
+
/** Request timeout now, in seconds (null = Cloud Run's default). */
|
|
101
|
+
readonly liveTimeout: number | null;
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
/** A service plan plus the changes its roll applies. */
|
|
95
105
|
export interface RollPlan extends ServicePlan {
|
|
96
106
|
readonly envFlags: readonly string[];
|
|
97
107
|
readonly envChanges: readonly EnvChange[];
|
|
98
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* #553 — e.g. `--memory=2Gi` when the admin runs below the stack default;
|
|
110
|
+
* #806 — `--timeout=3600` when the gateway runs below its contract timeout.
|
|
111
|
+
*/
|
|
99
112
|
readonly resourceFlags: readonly string[];
|
|
100
|
-
|
|
113
|
+
readonly resourceChanges: readonly string[];
|
|
114
|
+
/**
|
|
115
|
+
* The admin's media bucket volume when it is (partly) missing, the
|
|
116
|
+
* service's secret files (#806, the gateway's OAuth client) when not
|
|
117
|
+
* mounted; empty otherwise.
|
|
118
|
+
*/
|
|
101
119
|
readonly volumeFlags: readonly string[];
|
|
102
120
|
readonly volumeChanges: readonly string[];
|
|
103
121
|
}
|
|
@@ -164,9 +182,10 @@ export async function planAppRoll(
|
|
|
164
182
|
liveEnv: liveContainerEnv(json),
|
|
165
183
|
liveMemory: liveContainerMemory(json),
|
|
166
184
|
liveVolumes: liveVolumes(json),
|
|
185
|
+
liveTimeout: liveTimeoutSeconds(json),
|
|
167
186
|
});
|
|
168
187
|
}
|
|
169
|
-
const env = planEnv(install, services,
|
|
188
|
+
const env = planEnv(install, services, {});
|
|
170
189
|
if (!env.ok)
|
|
171
190
|
return { ok: false, error: `the install's env contract can't be applied: ${env.error}` };
|
|
172
191
|
// Without the media volume every upload is lost on the next revision or
|
|
@@ -178,28 +197,60 @@ export async function planAppRoll(
|
|
|
178
197
|
);
|
|
179
198
|
if (!media.ok)
|
|
180
199
|
return { ok: false, error: `the admin's media volume can't be set up: ${media.error}` };
|
|
200
|
+
// #806 — each service's secret files (the gateway's remote-MCP OAuth
|
|
201
|
+
// client). A file the roll cannot mount fails the plan: the new gateway
|
|
202
|
+
// reads its path from the env contract.
|
|
203
|
+
const secretFiles = {} as Record<RollSlug, { flags: string[]; changes: string[] }>;
|
|
204
|
+
for (const s of services) {
|
|
205
|
+
const files = planSecretFiles(s.liveVolumes, SERVICE_SECRET_FILES[s.slug], install.env);
|
|
206
|
+
if (!files.ok) {
|
|
207
|
+
return { ok: false, error: `the ${s.slug}'s secret files can't be mounted: ${files.error}` };
|
|
208
|
+
}
|
|
209
|
+
secretFiles[s.slug] = files;
|
|
210
|
+
}
|
|
181
211
|
const warnings: string[] = [];
|
|
182
212
|
const rolls = services.map((s): RollPlan => {
|
|
183
213
|
// #553 — the admin runs the Lighthouse quality audit; raise it to the
|
|
184
214
|
// stack's memory default (never lowered; an unparsable value is kept).
|
|
185
215
|
const memory = s.slug === "admin" ? planAdminMemory(s.liveMemory) : null;
|
|
186
216
|
if (memory && !memory.ok) warnings.push(`admin memory left as is: ${memory.error}`);
|
|
217
|
+
// #806 — the gateway streams remote-MCP chat turns (stack-contract.ts
|
|
218
|
+
// GATEWAY_TIMEOUT_SECONDS). The admin's timeout is the stacks' alone.
|
|
219
|
+
const timeout =
|
|
220
|
+
s.slug === "gateway" ? planRequestTimeout(s.liveTimeout, GATEWAY_TIMEOUT_SECONDS) : null;
|
|
221
|
+
const memoryFlags = memory?.ok ? memory.flags : [];
|
|
222
|
+
const timeoutFlags = timeout?.ok ? timeout.flags : [];
|
|
187
223
|
return {
|
|
188
224
|
...s,
|
|
189
225
|
envFlags: env.services[s.slug].flags,
|
|
190
226
|
envChanges: env.services[s.slug].changes,
|
|
191
|
-
resourceFlags:
|
|
192
|
-
|
|
193
|
-
|
|
227
|
+
resourceFlags: [...memoryFlags, ...timeoutFlags],
|
|
228
|
+
resourceChanges: [
|
|
229
|
+
...memoryFlags.map((f) => `${s.liveMemory ?? "(default)"} → ${f}`),
|
|
230
|
+
...(timeout?.ok ? timeout.changes : []),
|
|
231
|
+
],
|
|
232
|
+
volumeFlags: [...(s.slug === "admin" ? media.flags : []), ...secretFiles[s.slug].flags],
|
|
233
|
+
volumeChanges: [...(s.slug === "admin" ? media.changes : []), ...secretFiles[s.slug].changes],
|
|
194
234
|
};
|
|
195
235
|
});
|
|
196
236
|
return { ok: true, plan: { install, services: rolls, warnings } };
|
|
197
237
|
}
|
|
198
238
|
|
|
239
|
+
/** Env the converge half adds to, or keeps out of, the rolls ({@link AppRollHooks.afterMigrations}). */
|
|
240
|
+
export interface EnvAdjustments {
|
|
241
|
+
/** Contract vars to leave exactly as the services have them. */
|
|
242
|
+
readonly leaveUntouched?: readonly string[];
|
|
243
|
+
/**
|
|
244
|
+
* Vars outside the env contract the rolls set too — #806's remote-MCP
|
|
245
|
+
* identity env, which only the converge (operator identity) can resolve.
|
|
246
|
+
*/
|
|
247
|
+
readonly extraEnv?: Readonly<Partial<Record<RollSlug, readonly CloudRunEnvVar[]>>>;
|
|
248
|
+
}
|
|
249
|
+
|
|
199
250
|
function planEnv(
|
|
200
251
|
install: AppRollInstall,
|
|
201
252
|
services: readonly ServicePlan[],
|
|
202
|
-
|
|
253
|
+
adjust: EnvAdjustments,
|
|
203
254
|
) {
|
|
204
255
|
const deployed = Object.fromEntries(
|
|
205
256
|
services.map((s) => [s.slug, { serviceName: s.serviceName, liveEnv: s.liveEnv }]),
|
|
@@ -214,17 +265,22 @@ function planEnv(
|
|
|
214
265
|
stagingHost: install.stagingHost,
|
|
215
266
|
},
|
|
216
267
|
deployed,
|
|
217
|
-
{
|
|
268
|
+
{
|
|
269
|
+
leaveUntouched: adjust.leaveUntouched ?? [],
|
|
270
|
+
...(adjust.extraEnv ? { extraEnv: adjust.extraEnv } : {}),
|
|
271
|
+
},
|
|
218
272
|
);
|
|
219
273
|
}
|
|
220
274
|
|
|
221
275
|
/**
|
|
222
|
-
* The plan with
|
|
223
|
-
*
|
|
276
|
+
* The plan with the converge's adjustments: vars left exactly as the
|
|
277
|
+
* services have them (they point at something a converge step could not set
|
|
278
|
+
* up) and vars outside the contract the rolls set too.
|
|
224
279
|
*/
|
|
225
|
-
export function
|
|
226
|
-
|
|
227
|
-
|
|
280
|
+
export function adjustingEnv(plan: AppRollPlan, adjust: EnvAdjustments): AppRollPlan {
|
|
281
|
+
const extra = Object.values(adjust.extraEnv ?? {}).some((v) => (v?.length ?? 0) > 0);
|
|
282
|
+
if ((adjust.leaveUntouched?.length ?? 0) === 0 && !extra) return plan;
|
|
283
|
+
const env = planEnv(plan.install, plan.services, adjust);
|
|
228
284
|
if (!env.ok) return plan;
|
|
229
285
|
return {
|
|
230
286
|
...plan,
|
|
@@ -240,10 +296,8 @@ export function leavingEnvUntouched(plan: AppRollPlan, names: readonly string[])
|
|
|
240
296
|
export function reportPlan(plan: AppRollPlan, reporter: UpgradeReporter): void {
|
|
241
297
|
for (const w of plan.warnings) reporter.warn(w);
|
|
242
298
|
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
|
-
}
|
|
299
|
+
for (const c of s.volumeChanges) reporter.info(`${s.slug} volumes: ${c}`);
|
|
300
|
+
for (const c of s.resourceChanges) reporter.info(`${s.slug} resources: ${c}`);
|
|
247
301
|
for (const c of s.envChanges) {
|
|
248
302
|
reporter.info(
|
|
249
303
|
`${s.slug} env ${c.name}: ${c.from === undefined ? "(unset)" : c.from} → ${c.to === undefined ? "(removed)" : c.to}`,
|
|
@@ -260,7 +314,11 @@ export interface AppRollHooks {
|
|
|
260
314
|
* not be set up; `ok: false` aborts the upgrade (nothing rolled yet).
|
|
261
315
|
*/
|
|
262
316
|
afterMigrations?(): Promise<
|
|
263
|
-
{ readonly ok: true; readonly leaveUntouched: readonly string[] }
|
|
317
|
+
| ({ readonly ok: true; readonly leaveUntouched: readonly string[] } & Pick<
|
|
318
|
+
EnvAdjustments,
|
|
319
|
+
"extraEnv"
|
|
320
|
+
>)
|
|
321
|
+
| { readonly ok: false }
|
|
264
322
|
>;
|
|
265
323
|
/** Right before `slug` rolls; `ok: false` aborts and rolls back what rolled. */
|
|
266
324
|
beforeRoll?(
|
|
@@ -309,6 +367,13 @@ export type AppRollResult =
|
|
|
309
367
|
readonly phase: AppRollPhase;
|
|
310
368
|
/** Whether migrations ran before the failure (the backup matters then). */
|
|
311
369
|
readonly migrated: boolean;
|
|
370
|
+
/**
|
|
371
|
+
* Whether services had rolled and EVERY one of them was pinned back to
|
|
372
|
+
* the revision that served before — i.e. the previous release serves
|
|
373
|
+
* again. False when nothing had rolled, or when a pin failed (then a
|
|
374
|
+
* person must pin it; the reporter named which).
|
|
375
|
+
*/
|
|
376
|
+
readonly rolledBack: boolean;
|
|
312
377
|
};
|
|
313
378
|
|
|
314
379
|
/** Run the app-roll. See the file comment for the sequence. */
|
|
@@ -320,10 +385,13 @@ export async function executeAppRoll(
|
|
|
320
385
|
const { reporter: r, run } = deps;
|
|
321
386
|
const { install } = input.plan;
|
|
322
387
|
let plan = input.plan;
|
|
388
|
+
/** Set by {@link rollBack}: null until one ran with services to restore. */
|
|
389
|
+
let rollbackOk: boolean | null = null;
|
|
323
390
|
const fail = (phase: AppRollPhase, migrated: boolean): AppRollResult => ({
|
|
324
391
|
ok: false,
|
|
325
392
|
phase,
|
|
326
393
|
migrated,
|
|
394
|
+
rolledBack: rollbackOk === true,
|
|
327
395
|
});
|
|
328
396
|
|
|
329
397
|
// A probe that cannot reach the install now would only fail after the
|
|
@@ -363,7 +431,10 @@ export async function executeAppRoll(
|
|
|
363
431
|
if (hooks.afterMigrations) {
|
|
364
432
|
const converged = await hooks.afterMigrations();
|
|
365
433
|
if (!converged.ok) return fail("converge", true);
|
|
366
|
-
plan =
|
|
434
|
+
plan = adjustingEnv(plan, {
|
|
435
|
+
leaveUntouched: converged.leaveUntouched,
|
|
436
|
+
...(converged.extraEnv ? { extraEnv: converged.extraEnv } : {}),
|
|
437
|
+
});
|
|
367
438
|
}
|
|
368
439
|
|
|
369
440
|
const rolled: RollPlan[] = [];
|
|
@@ -381,6 +452,7 @@ export async function executeAppRoll(
|
|
|
381
452
|
const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
|
|
382
453
|
if (services.length === 0) return;
|
|
383
454
|
r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
|
|
455
|
+
rollbackOk = true;
|
|
384
456
|
for (const p of [...services].reverse()) {
|
|
385
457
|
const ok = await pinTraffic(
|
|
386
458
|
run,
|
|
@@ -390,7 +462,10 @@ export async function executeAppRoll(
|
|
|
390
462
|
p.priorRevision,
|
|
391
463
|
);
|
|
392
464
|
if (ok) r.info(` ${p.slug} traffic restored to ${p.priorRevision}`);
|
|
393
|
-
else
|
|
465
|
+
else {
|
|
466
|
+
rollbackOk = false;
|
|
467
|
+
r.error(` ${p.slug} rollback FAILED — pin ${p.priorRevision} by hand`);
|
|
468
|
+
}
|
|
394
469
|
await hooks.afterRollback?.(p.slug);
|
|
395
470
|
}
|
|
396
471
|
};
|