@steve31415/baselib 3.2.0 → 3.3.1
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/deploy/deploy.js +4 -6
- package/dist/deploy/gcs.d.ts +5 -0
- package/dist/deploy/gcs.js +55 -7
- package/dist/deploy/plan.d.ts +27 -3
- package/dist/deploy/plan.js +55 -5
- package/dist/deploy/rollback.js +3 -6
- package/package.json +1 -1
package/dist/deploy/deploy.js
CHANGED
|
@@ -12,7 +12,7 @@ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
|
|
|
12
12
|
import { must, mustJson } from './exec.js';
|
|
13
13
|
import { openEvidence, PhaseTimer } from './evidence.js';
|
|
14
14
|
import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
|
|
15
|
-
import { consistentBaseSha,
|
|
15
|
+
import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
|
|
16
16
|
const RELEASE_DEADLINE_MS = 25 * 60_000;
|
|
17
17
|
const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
|
|
18
18
|
export async function runDeploy(options) {
|
|
@@ -87,12 +87,9 @@ export async function runDeploy(options) {
|
|
|
87
87
|
}
|
|
88
88
|
const serving = [];
|
|
89
89
|
for (const service of config.services) {
|
|
90
|
-
const described = await
|
|
91
|
-
'run', 'services', 'describe', service.name,
|
|
92
|
-
...gcloudBase, '--region', config.region, '--format=json',
|
|
93
|
-
]);
|
|
90
|
+
const { described, state } = await readServingState(runner, gcloudBase, config.region, service.name);
|
|
94
91
|
await evidence.save(`${service.name}-before.json`, JSON.stringify(described, null, 2));
|
|
95
|
-
serving.push(
|
|
92
|
+
serving.push(state);
|
|
96
93
|
}
|
|
97
94
|
const base = consistentBaseSha(serving, sha);
|
|
98
95
|
if (!base.ok)
|
|
@@ -228,6 +225,7 @@ export async function runDeploy(options) {
|
|
|
228
225
|
throw new Error('deploy exceeded its 25-minute deadline; lock may be breakable — aborting before traffic');
|
|
229
226
|
}
|
|
230
227
|
if (!(await verifyLock(runner, lock))) {
|
|
228
|
+
lock.stopHeartbeat();
|
|
231
229
|
lock = null; // someone broke and superseded us; never touch their lock
|
|
232
230
|
throw new Error('deploy lock lost (superseded by another deploy or rollback); aborting before traffic');
|
|
233
231
|
}
|
package/dist/deploy/gcs.d.ts
CHANGED
|
@@ -2,11 +2,16 @@ import type { HoldRecord, ReleaseRecord, Runner } from './types.js';
|
|
|
2
2
|
export interface LockHandle {
|
|
3
3
|
uri: string;
|
|
4
4
|
generation: string;
|
|
5
|
+
/** Ends the heartbeat; releaseLock calls it, and so must anyone who gives
|
|
6
|
+
* the lock up without releasing it. */
|
|
7
|
+
stopHeartbeat(): void;
|
|
5
8
|
}
|
|
6
9
|
export interface AcquireOptions {
|
|
7
10
|
waitMs?: number;
|
|
8
11
|
staleMs?: number;
|
|
9
12
|
pollMs?: number;
|
|
13
|
+
/** Refresh interval; 0 disables the heartbeat (tests). */
|
|
14
|
+
heartbeatMs?: number;
|
|
10
15
|
now?: () => number;
|
|
11
16
|
sleep?: (ms: number) => Promise<void>;
|
|
12
17
|
log?: (message: string) => void;
|
package/dist/deploy/gcs.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// GCS-backed coordination for pw-deploy/pw-rollback: the per-app deploy lock
|
|
2
|
-
// (create-only, generation-fenced, server-time
|
|
3
|
-
// release records, and the default
|
|
2
|
+
// (create-only, generation-fenced, heartbeat-refreshed, server-time
|
|
3
|
+
// staleness), the rollback hold, release records, and the default
|
|
4
|
+
// retained-asset publisher.
|
|
4
5
|
import { readdir, stat, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { tmpdir } from 'node:os';
|
|
7
8
|
import { CommandFailure, must } from './exec.js';
|
|
8
9
|
import { contentTypeFor, lockIsStale, SHA40 } from './plan.js';
|
|
9
|
-
|
|
10
|
+
/** A holder refreshes its lock every HEARTBEAT_MS; a lock not refreshed for
|
|
11
|
+
* STALE_MS is a dead holder's and may be broken — well inside the WAIT_MS a
|
|
12
|
+
* waiter is prepared to spend, so a killed deploy no longer needs a human
|
|
13
|
+
* to delete its lock. (Until 2026-09-04 staleness was judged from creation
|
|
14
|
+
* time with a 30-minute threshold, read from a key gcloud does not emit:
|
|
15
|
+
* the automatic break never fired.) */
|
|
16
|
+
const HEARTBEAT_MS = 30_000;
|
|
17
|
+
const STALE_MS = 3 * 60_000;
|
|
10
18
|
const WAIT_MS = 5 * 60_000;
|
|
11
19
|
const POLL_MS = 10_000;
|
|
12
20
|
async function describeObject(runner, uri) {
|
|
@@ -45,17 +53,23 @@ export async function acquireLock(runner, uri, body, opts = {}) {
|
|
|
45
53
|
const meta = await describeObject(runner, uri);
|
|
46
54
|
if (!meta?.generation)
|
|
47
55
|
throw new Error(`lock created but not describable: ${uri}`);
|
|
48
|
-
|
|
56
|
+
const generation = String(meta.generation);
|
|
57
|
+
const stopHeartbeat = startHeartbeat(runner, uri, generation, {
|
|
58
|
+
everyMs: opts.heartbeatMs ?? HEARTBEAT_MS,
|
|
59
|
+
now,
|
|
60
|
+
log,
|
|
61
|
+
});
|
|
62
|
+
return { uri, generation, stopHeartbeat };
|
|
49
63
|
}
|
|
50
64
|
const meta = await describeObject(runner, uri);
|
|
51
65
|
if (meta?.generation) {
|
|
52
66
|
const stale = opts.breakExisting ||
|
|
53
|
-
(meta.
|
|
54
|
-
lockIsStale(meta.
|
|
67
|
+
(meta.update_time !== undefined &&
|
|
68
|
+
lockIsStale(meta.update_time, now(), opts.staleMs ?? STALE_MS));
|
|
55
69
|
if (stale) {
|
|
56
70
|
log(opts.breakExisting
|
|
57
71
|
? `breaking existing lock (rollback takes priority): ${uri}`
|
|
58
|
-
: `breaking stale lock (
|
|
72
|
+
: `breaking stale lock (last refreshed ${meta.update_time}): ${uri}`);
|
|
59
73
|
// Conditional on the observed generation: two breakers cannot both
|
|
60
74
|
// win, and a just-released-and-reacquired lock is not clobbered.
|
|
61
75
|
await conditionalDelete(runner, uri, String(meta.generation));
|
|
@@ -77,12 +91,46 @@ export async function acquireLock(runner, uri, body, opts = {}) {
|
|
|
77
91
|
await sleep(opts.pollMs ?? POLL_MS);
|
|
78
92
|
}
|
|
79
93
|
}
|
|
94
|
+
/** Refresh the lock's custom metadata, fenced on our generation: a metadata
|
|
95
|
+
* update advances the object's server-side update_time (what staleness is
|
|
96
|
+
* judged from) without changing its generation (what the fence is). A
|
|
97
|
+
* refresh that fails the fence means a successor broke us — stop, and let
|
|
98
|
+
* verifyLock report it before traffic. Refreshes run one at a time. */
|
|
99
|
+
function startHeartbeat(runner, uri, generation, opts) {
|
|
100
|
+
if (opts.everyMs <= 0)
|
|
101
|
+
return () => { };
|
|
102
|
+
let stopped = false;
|
|
103
|
+
let chain = Promise.resolve();
|
|
104
|
+
const stop = () => {
|
|
105
|
+
stopped = true;
|
|
106
|
+
clearInterval(timer);
|
|
107
|
+
};
|
|
108
|
+
const refresh = async () => {
|
|
109
|
+
if (stopped)
|
|
110
|
+
return;
|
|
111
|
+
const result = await runner('gcloud', [
|
|
112
|
+
'storage', 'objects', 'update', uri,
|
|
113
|
+
`--custom-metadata=heartbeat=${new Date(opts.now()).toISOString()}`,
|
|
114
|
+
`--if-generation-match=${generation}`, '--quiet',
|
|
115
|
+
]);
|
|
116
|
+
if (result.code !== 0) {
|
|
117
|
+
opts.log(`lock heartbeat failed (generation ${generation} gone?): ${result.stderr.trim()}`);
|
|
118
|
+
stop();
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const timer = setInterval(() => {
|
|
122
|
+
chain = chain.then(refresh);
|
|
123
|
+
}, opts.everyMs);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
return stop;
|
|
126
|
+
}
|
|
80
127
|
/** The fence: true only while our exact generation still exists. */
|
|
81
128
|
export async function verifyLock(runner, handle) {
|
|
82
129
|
const meta = await describeObject(runner, handle.uri);
|
|
83
130
|
return meta !== null && String(meta.generation) === handle.generation;
|
|
84
131
|
}
|
|
85
132
|
export async function releaseLock(runner, handle) {
|
|
133
|
+
handle.stopHeartbeat();
|
|
86
134
|
const ok = await conditionalDelete(runner, handle.uri, handle.generation);
|
|
87
135
|
if (!ok) {
|
|
88
136
|
// A successor broke us; their lock must survive. Nothing to clean up.
|
package/dist/deploy/plan.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ServingState } from './types.js';
|
|
1
|
+
import type { Runner, ServingState } from './types.js';
|
|
2
2
|
export declare const SHA40: RegExp;
|
|
3
3
|
/** Lock staleness is judged from the GCS object's server-side timeCreated,
|
|
4
4
|
* never from anything the lock writer wrote (its clock may be wrong). */
|
|
@@ -41,8 +41,32 @@ export declare function pickRevisionForSha(revisions: unknown[], targetSha: stri
|
|
|
41
41
|
revision: string;
|
|
42
42
|
imageDigest: string;
|
|
43
43
|
} | null;
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/** The one revision carrying 100% of traffic in a `gcloud run services
|
|
45
|
+
* describe --format=json`, from *observed* traffic (`status.traffic`, never
|
|
46
|
+
* `spec.traffic`, which is only the desired state). */
|
|
47
|
+
export declare function servingRevisionName(service: string, described: unknown): string;
|
|
48
|
+
/**
|
|
49
|
+
* Extract serving state from a `gcloud run services describe --format=json`.
|
|
50
|
+
*
|
|
51
|
+
* The serving revision's BUILD_ID and image are read from the service
|
|
52
|
+
* template only when that revision IS the latest created one. When it is
|
|
53
|
+
* not — a previous release created a revision that never took traffic (a
|
|
54
|
+
* failed secret-access check, a crashed startup, an aborted deploy) — the
|
|
55
|
+
* template describes the *wrong* revision, so the caller must pass that
|
|
56
|
+
* revision's own `gcloud run revisions describe --format=json` as
|
|
57
|
+
* `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
|
|
58
|
+
* a re-run after a failed traffic switch read the new SHA out of the
|
|
59
|
+
* template, logged "already serving", wrote the release record, and left
|
|
60
|
+
* the old revision serving.
|
|
61
|
+
*/
|
|
62
|
+
export declare function servingStateOf(service: string, described: unknown, servingRevision?: unknown): ServingState;
|
|
63
|
+
/** Describe a service and resolve its serving state, describing the serving
|
|
64
|
+
* revision too whenever the template no longer belongs to it. Returns the
|
|
65
|
+
* raw service description as well, for evidence. */
|
|
66
|
+
export declare function readServingState(runner: Runner, gcloudBase: string[], region: string, service: string): Promise<{
|
|
67
|
+
described: unknown;
|
|
68
|
+
state: ServingState;
|
|
69
|
+
}>;
|
|
46
70
|
export declare function contentTypeFor(path: string): string;
|
|
47
71
|
/** Parse release-record listing rows into {sha, at} entries. */
|
|
48
72
|
export declare function releaseEntriesFrom(rows: {
|
package/dist/deploy/plan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure decision logic for pw-deploy/pw-rollback: everything here is
|
|
2
2
|
// side-effect-free so the concurrency-sensitive choices (lock staleness,
|
|
3
3
|
// rollback targeting, serving-state consistency) are unit-tested directly.
|
|
4
|
+
import { mustJson } from './exec.js';
|
|
4
5
|
export const SHA40 = /^[0-9a-f]{40}$/;
|
|
5
6
|
/** Lock staleness is judged from the GCS object's server-side timeCreated,
|
|
6
7
|
* never from anything the lock writer wrote (its clock may be wrong). */
|
|
@@ -101,22 +102,71 @@ export function pickRevisionForSha(revisions, targetSha) {
|
|
|
101
102
|
matches.sort((a, b) => b.created - a.created);
|
|
102
103
|
return matches[0] ? { revision: matches[0].revision, imageDigest: matches[0].imageDigest } : null;
|
|
103
104
|
}
|
|
104
|
-
/**
|
|
105
|
-
|
|
105
|
+
/** The one revision carrying 100% of traffic in a `gcloud run services
|
|
106
|
+
* describe --format=json`, from *observed* traffic (`status.traffic`, never
|
|
107
|
+
* `spec.traffic`, which is only the desired state). */
|
|
108
|
+
export function servingRevisionName(service, described) {
|
|
106
109
|
const d = described;
|
|
107
110
|
const active = (d.status?.traffic ?? []).filter((t) => (t.percent ?? 0) === 100);
|
|
108
111
|
if (active.length !== 1 || !active[0].revisionName) {
|
|
109
112
|
throw new Error(`${service}: expected exactly one 100% traffic target`);
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
return active[0].revisionName;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Extract serving state from a `gcloud run services describe --format=json`.
|
|
118
|
+
*
|
|
119
|
+
* The serving revision's BUILD_ID and image are read from the service
|
|
120
|
+
* template only when that revision IS the latest created one. When it is
|
|
121
|
+
* not — a previous release created a revision that never took traffic (a
|
|
122
|
+
* failed secret-access check, a crashed startup, an aborted deploy) — the
|
|
123
|
+
* template describes the *wrong* revision, so the caller must pass that
|
|
124
|
+
* revision's own `gcloud run revisions describe --format=json` as
|
|
125
|
+
* `servingRevision` (readServingState does). Lesson 2026-09-04, watchdog2:
|
|
126
|
+
* a re-run after a failed traffic switch read the new SHA out of the
|
|
127
|
+
* template, logged "already serving", wrote the release record, and left
|
|
128
|
+
* the old revision serving.
|
|
129
|
+
*/
|
|
130
|
+
export function servingStateOf(service, described, servingRevision) {
|
|
131
|
+
const d = described;
|
|
132
|
+
const revision = servingRevisionName(service, described);
|
|
133
|
+
const latest = d.status?.latestCreatedRevisionName;
|
|
134
|
+
let container;
|
|
135
|
+
if (latest === undefined || latest === revision) {
|
|
136
|
+
container = d.spec?.template?.spec?.containers?.[0];
|
|
137
|
+
}
|
|
138
|
+
else if (servingRevision !== undefined) {
|
|
139
|
+
container = servingRevision.spec?.containers?.[0];
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
throw new Error(`${service}: traffic serves ${revision} but the latest revision is ${latest}; ` +
|
|
143
|
+
'the serving revision must be described separately');
|
|
144
|
+
}
|
|
145
|
+
const env = container?.env ?? [];
|
|
112
146
|
const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value ?? null;
|
|
113
147
|
return {
|
|
114
148
|
service,
|
|
115
|
-
revision
|
|
149
|
+
revision,
|
|
116
150
|
buildSha: buildSha !== null && SHA40.test(buildSha) ? buildSha : null,
|
|
117
|
-
imageDigest:
|
|
151
|
+
imageDigest: container?.image ?? null,
|
|
118
152
|
};
|
|
119
153
|
}
|
|
154
|
+
/** Describe a service and resolve its serving state, describing the serving
|
|
155
|
+
* revision too whenever the template no longer belongs to it. Returns the
|
|
156
|
+
* raw service description as well, for evidence. */
|
|
157
|
+
export async function readServingState(runner, gcloudBase, region, service) {
|
|
158
|
+
const described = await mustJson(runner, 'gcloud', [
|
|
159
|
+
'run', 'services', 'describe', service, ...gcloudBase, '--region', region, '--format=json',
|
|
160
|
+
]);
|
|
161
|
+
const revision = servingRevisionName(service, described);
|
|
162
|
+
const latest = described.status?.latestCreatedRevisionName;
|
|
163
|
+
const servingRevision = latest !== undefined && latest !== revision
|
|
164
|
+
? await mustJson(runner, 'gcloud', [
|
|
165
|
+
'run', 'revisions', 'describe', revision, ...gcloudBase, '--region', region, '--format=json',
|
|
166
|
+
])
|
|
167
|
+
: undefined;
|
|
168
|
+
return { described, state: servingStateOf(service, described, servingRevision) };
|
|
169
|
+
}
|
|
120
170
|
const CONTENT_TYPES = {
|
|
121
171
|
'.js': 'text/javascript',
|
|
122
172
|
'.mjs': 'text/javascript',
|
package/dist/deploy/rollback.js
CHANGED
|
@@ -8,7 +8,7 @@ import { must, mustJson } from './exec.js';
|
|
|
8
8
|
import { openEvidence, PhaseTimer } from './evidence.js';
|
|
9
9
|
import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
|
|
10
10
|
import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
|
|
11
|
-
import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget,
|
|
11
|
+
import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
|
|
12
12
|
export async function runRollback(options) {
|
|
13
13
|
const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
|
|
14
14
|
const runner = options.runner;
|
|
@@ -39,11 +39,8 @@ export async function runRollback(options) {
|
|
|
39
39
|
timer.enter('target');
|
|
40
40
|
const serving = [];
|
|
41
41
|
for (const service of config.services) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
...gcloudBase, '--region', config.region, '--format=json',
|
|
45
|
-
]);
|
|
46
|
-
serving.push(servingStateOf(service.name, described));
|
|
42
|
+
const { state } = await readServingState(runner, gcloudBase, config.region, service.name);
|
|
43
|
+
serving.push(state);
|
|
47
44
|
}
|
|
48
45
|
const evidence = await openEvidence(config.app, requested ?? serving[0].buildSha ?? 'rollback');
|
|
49
46
|
evidenceDir = evidence.dir;
|
package/package.json
CHANGED