@steve31415/baselib 2.2.2 → 2.4.0

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.
@@ -0,0 +1,10 @@
1
+ import type { ExecOptions, ExecResult, Runner } from './types.js';
2
+ export declare const realRunner: Runner;
3
+ export declare class CommandFailure extends Error {
4
+ readonly result: ExecResult;
5
+ constructor(message: string, result: ExecResult);
6
+ }
7
+ /** Run and throw a CommandFailure (with a stderr/stdout tail) on nonzero. */
8
+ export declare function must(runner: Runner, file: string, args: string[], opts?: ExecOptions): Promise<ExecResult>;
9
+ /** must() + parse stdout as JSON. */
10
+ export declare function mustJson<T>(runner: Runner, file: string, args: string[], opts?: ExecOptions): Promise<T>;
@@ -0,0 +1,41 @@
1
+ // Process runner for pw-deploy/pw-rollback. All external commands (gcloud,
2
+ // git, npm, bash) flow through the injected Runner so orchestration logic is
3
+ // testable against a scripted fake.
4
+ import { execFile } from 'node:child_process';
5
+ const DEFAULT_TIMEOUT_MS = 15 * 60_000;
6
+ const MAX_BUFFER = 32 * 1024 * 1024;
7
+ export const realRunner = (file, args, opts) => new Promise((resolve) => {
8
+ const child = execFile(file, args, {
9
+ timeout: opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
10
+ killSignal: 'SIGTERM',
11
+ maxBuffer: MAX_BUFFER,
12
+ cwd: opts?.cwd,
13
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
14
+ }, (error, stdout, stderr) => {
15
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
16
+ resolve({ stdout: String(stdout), stderr: String(stderr), code });
17
+ });
18
+ if (opts?.input !== undefined)
19
+ child.stdin?.end(opts.input);
20
+ });
21
+ export class CommandFailure extends Error {
22
+ result;
23
+ constructor(message, result) {
24
+ super(message);
25
+ this.result = result;
26
+ }
27
+ }
28
+ /** Run and throw a CommandFailure (with a stderr/stdout tail) on nonzero. */
29
+ export async function must(runner, file, args, opts) {
30
+ const result = await runner(file, args, opts);
31
+ if (result.code !== 0) {
32
+ const tail = (result.stderr.trim() || result.stdout.trim()).slice(-2000);
33
+ throw new CommandFailure(`${file} ${args.join(' ')} exited ${result.code}\n${tail}`, result);
34
+ }
35
+ return result;
36
+ }
37
+ /** must() + parse stdout as JSON. */
38
+ export async function mustJson(runner, file, args, opts) {
39
+ const result = await must(runner, file, args, opts);
40
+ return JSON.parse(result.stdout);
41
+ }
@@ -0,0 +1,43 @@
1
+ import type { HoldRecord, ReleaseRecord, Runner } from './types.js';
2
+ export interface LockHandle {
3
+ uri: string;
4
+ generation: string;
5
+ }
6
+ export interface AcquireOptions {
7
+ waitMs?: number;
8
+ staleMs?: number;
9
+ pollMs?: number;
10
+ now?: () => number;
11
+ sleep?: (ms: number) => Promise<void>;
12
+ log?: (message: string) => void;
13
+ /** Rollback mode: break any existing lock immediately (a rollback must not
14
+ * queue behind the stuck deploy that motivated it). */
15
+ breakExisting?: boolean;
16
+ }
17
+ export declare function acquireLock(runner: Runner, uri: string, body: string, opts?: AcquireOptions): Promise<LockHandle>;
18
+ /** The fence: true only while our exact generation still exists. */
19
+ export declare function verifyLock(runner: Runner, handle: LockHandle): Promise<boolean>;
20
+ export declare function releaseLock(runner: Runner, handle: LockHandle): Promise<void>;
21
+ export declare function readHold(runner: Runner, uri: string): Promise<HoldRecord | null>;
22
+ export declare function placeHold(runner: Runner, uri: string, hold: HoldRecord): Promise<void>;
23
+ export declare function clearHold(runner: Runner, uri: string): Promise<void>;
24
+ export declare function listReleaseRecords(runner: Runner, bucket: string, prefix: string): Promise<{
25
+ url: string;
26
+ metadata?: {
27
+ timeCreated?: string;
28
+ };
29
+ }[]>;
30
+ export declare function readReleaseRecord(runner: Runner, bucket: string, prefix: string, sha: string): Promise<ReleaseRecord | null>;
31
+ /** Create-only; an existing record for the SHA is tolerated (re-release). */
32
+ export declare function writeReleaseRecord(runner: Runner, bucket: string, prefix: string, record: ReleaseRecord): Promise<'written' | 'already-present'>;
33
+ /** Idempotent content-addressed publish: create-only per object, immutable
34
+ * caching, build-id metadata. Reused hashed files are left untouched. */
35
+ export declare function publishRetainedAssets(runner: Runner, input: {
36
+ dir: string;
37
+ bucket: string;
38
+ prefix: string;
39
+ buildId: string;
40
+ }): Promise<{
41
+ published: number;
42
+ reused: number;
43
+ }>;
@@ -0,0 +1,189 @@
1
+ // GCS-backed coordination for pw-deploy/pw-rollback: the per-app deploy lock
2
+ // (create-only, generation-fenced, server-time staleness), the rollback hold,
3
+ // release records, and the default retained-asset publisher.
4
+ import { readdir, stat, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { CommandFailure, must } from './exec.js';
8
+ import { contentTypeFor, lockIsStale, SHA40 } from './plan.js';
9
+ const STALE_MS = 30 * 60_000;
10
+ const WAIT_MS = 5 * 60_000;
11
+ const POLL_MS = 10_000;
12
+ async function describeObject(runner, uri) {
13
+ const result = await runner('gcloud', ['storage', 'objects', 'describe', uri, '--format=json']);
14
+ if (result.code !== 0)
15
+ return null;
16
+ try {
17
+ return JSON.parse(result.stdout);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ async function createOnly(runner, uri, body) {
24
+ const tmp = join(tmpdir(), `pw-lock-${process.pid}-${Date.now()}`);
25
+ await writeFile(tmp, body);
26
+ const result = await runner('gcloud', [
27
+ 'storage', 'cp', tmp, uri, '--if-generation-match=0', '--quiet',
28
+ ]);
29
+ return result.code === 0;
30
+ }
31
+ /** Delete only the observed generation; false when someone else won. */
32
+ async function conditionalDelete(runner, uri, generation) {
33
+ const result = await runner('gcloud', [
34
+ 'storage', 'rm', uri, `--if-generation-match=${generation}`, '--quiet',
35
+ ]);
36
+ return result.code === 0;
37
+ }
38
+ export async function acquireLock(runner, uri, body, opts = {}) {
39
+ const now = opts.now ?? Date.now;
40
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
41
+ const log = opts.log ?? (() => { });
42
+ const deadline = now() + (opts.waitMs ?? WAIT_MS);
43
+ for (;;) {
44
+ if (await createOnly(runner, uri, body)) {
45
+ const meta = await describeObject(runner, uri);
46
+ if (!meta?.generation)
47
+ throw new Error(`lock created but not describable: ${uri}`);
48
+ return { uri, generation: String(meta.generation) };
49
+ }
50
+ const meta = await describeObject(runner, uri);
51
+ if (meta?.generation) {
52
+ const stale = opts.breakExisting ||
53
+ (meta.timeCreated !== undefined &&
54
+ lockIsStale(meta.timeCreated, now(), opts.staleMs ?? STALE_MS));
55
+ if (stale) {
56
+ log(opts.breakExisting
57
+ ? `breaking existing lock (rollback takes priority): ${uri}`
58
+ : `breaking stale lock (created ${meta.timeCreated}): ${uri}`);
59
+ // Conditional on the observed generation: two breakers cannot both
60
+ // win, and a just-released-and-reacquired lock is not clobbered.
61
+ await conditionalDelete(runner, uri, String(meta.generation));
62
+ continue; // fresh create-only attempt decides the winner
63
+ }
64
+ let holder = '';
65
+ try {
66
+ const read = await runner('gcloud', ['storage', 'cat', uri]);
67
+ holder = read.code === 0 ? read.stdout.trim().split('\n').slice(0, 2).join(' ') : '';
68
+ }
69
+ catch {
70
+ /* informational only */
71
+ }
72
+ if (now() >= deadline) {
73
+ throw new Error(`deploy lock held: ${uri} (${holder || 'holder unknown'})`);
74
+ }
75
+ log(`waiting for deploy lock: ${uri} (${holder || 'holder unknown'})`);
76
+ }
77
+ await sleep(opts.pollMs ?? POLL_MS);
78
+ }
79
+ }
80
+ /** The fence: true only while our exact generation still exists. */
81
+ export async function verifyLock(runner, handle) {
82
+ const meta = await describeObject(runner, handle.uri);
83
+ return meta !== null && String(meta.generation) === handle.generation;
84
+ }
85
+ export async function releaseLock(runner, handle) {
86
+ const ok = await conditionalDelete(runner, handle.uri, handle.generation);
87
+ if (!ok) {
88
+ // A successor broke us; their lock must survive. Nothing to clean up.
89
+ }
90
+ }
91
+ // ---- hold ----
92
+ export async function readHold(runner, uri) {
93
+ const result = await runner('gcloud', ['storage', 'cat', uri]);
94
+ if (result.code !== 0)
95
+ return null;
96
+ try {
97
+ const parsed = JSON.parse(result.stdout);
98
+ return Array.isArray(parsed.heldShas) ? parsed : null;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ export async function placeHold(runner, uri, hold) {
105
+ const tmp = join(tmpdir(), `pw-hold-${process.pid}-${Date.now()}`);
106
+ await writeFile(tmp, JSON.stringify(hold, null, 2));
107
+ await must(runner, 'gcloud', [
108
+ 'storage', 'cp', tmp, uri, '--cache-control=no-store', '--quiet',
109
+ ]);
110
+ }
111
+ export async function clearHold(runner, uri) {
112
+ await runner('gcloud', ['storage', 'rm', uri, '--quiet']);
113
+ }
114
+ // ---- release records ----
115
+ export async function listReleaseRecords(runner, bucket, prefix) {
116
+ const result = await runner('gcloud', ['storage', 'ls', '--json', `gs://${bucket}/${prefix}**`]);
117
+ if (result.code !== 0)
118
+ return []; // no records yet
119
+ try {
120
+ return JSON.parse(result.stdout);
121
+ }
122
+ catch {
123
+ return [];
124
+ }
125
+ }
126
+ export async function readReleaseRecord(runner, bucket, prefix, sha) {
127
+ if (!SHA40.test(sha))
128
+ return null;
129
+ const result = await runner('gcloud', ['storage', 'cat', `gs://${bucket}/${prefix}${sha}.json`]);
130
+ if (result.code !== 0)
131
+ return null;
132
+ try {
133
+ return JSON.parse(result.stdout);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /** Create-only; an existing record for the SHA is tolerated (re-release). */
140
+ export async function writeReleaseRecord(runner, bucket, prefix, record) {
141
+ const tmp = join(tmpdir(), `pw-release-${process.pid}-${Date.now()}`);
142
+ await writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`);
143
+ const uri = `gs://${bucket}/${prefix}${record.sha}.json`;
144
+ const result = await runner('gcloud', [
145
+ 'storage', 'cp', tmp, uri,
146
+ '--if-generation-match=0', '--cache-control=no-store',
147
+ '--content-type=application/json', '--quiet',
148
+ ]);
149
+ if (result.code === 0)
150
+ return 'written';
151
+ const existing = await readReleaseRecord(runner, bucket, prefix, record.sha);
152
+ if (existing)
153
+ return 'already-present';
154
+ throw new CommandFailure(`release record write failed: ${uri}`, result);
155
+ }
156
+ // ---- retained assets (default publisher) ----
157
+ /** Idempotent content-addressed publish: create-only per object, immutable
158
+ * caching, build-id metadata. Reused hashed files are left untouched. */
159
+ export async function publishRetainedAssets(runner, input) {
160
+ const entries = await readdir(input.dir, { recursive: true });
161
+ let published = 0;
162
+ let reused = 0;
163
+ for (const entry of entries.map((e) => e.split('\\').join('/')).sort()) {
164
+ const path = join(input.dir, entry);
165
+ if (!(await stat(path)).isFile())
166
+ continue;
167
+ const uri = `gs://${input.bucket}/${input.prefix}${entry}`;
168
+ const result = await runner('gcloud', [
169
+ 'storage', 'cp', path, uri,
170
+ '--if-generation-match=0',
171
+ '--cache-control=public, max-age=31536000, immutable',
172
+ `--content-type=${contentTypeFor(entry)}`,
173
+ `--custom-metadata=pwBuildId=${input.buildId}`,
174
+ '--quiet',
175
+ ]);
176
+ if (result.code === 0)
177
+ published += 1;
178
+ else {
179
+ // Exists already (content-addressed name): reuse. Anything else is a
180
+ // real failure we surface by re-checking existence.
181
+ const meta = await describeObject(runner, uri);
182
+ if (!meta) {
183
+ throw new CommandFailure(`asset publish failed: ${uri}`, result);
184
+ }
185
+ reused += 1;
186
+ }
187
+ }
188
+ return { published, reused };
189
+ }
@@ -0,0 +1,56 @@
1
+ import type { ServingState } from './types.js';
2
+ export declare const SHA40: RegExp;
3
+ /** Lock staleness is judged from the GCS object's server-side timeCreated,
4
+ * never from anything the lock writer wrote (its clock may be wrong). */
5
+ export declare function lockIsStale(timeCreatedIso: string, nowMs: number, staleMs: number): boolean;
6
+ /** Consistent serving base across an app's services (notes2's rule,
7
+ * generalized): all services on one SHA -> that SHA; some already on the
8
+ * target -> the rest must agree on one other SHA (the base); anything else
9
+ * is a skew that needs a human. A null buildSha (pre-unification revision)
10
+ * yields base null — callers skip base-dependent guards. */
11
+ export declare function consistentBaseSha(serving: ServingState[], targetSha: string): {
12
+ ok: true;
13
+ baseSha: string | null;
14
+ } | {
15
+ ok: false;
16
+ detail: string;
17
+ };
18
+ export interface RollbackSelection {
19
+ ok: boolean;
20
+ sha?: string;
21
+ refusal?: string;
22
+ }
23
+ /** Rollback target selection. A rollback may only move backward: the target
24
+ * must come from a successful release record, differ from every serving
25
+ * SHA, and be an ancestor of each serving SHA (per isAncestor). Re-running
26
+ * the no-arg form converges instead of oscillating. */
27
+ export declare function selectRollbackTarget(input: {
28
+ requested?: string;
29
+ records: {
30
+ sha: string;
31
+ at: string;
32
+ }[];
33
+ serving: ServingState[];
34
+ isAncestor: (maybeAncestor: string, descendant: string) => Promise<boolean>;
35
+ }): Promise<RollbackSelection>;
36
+ /** Pick the revision to roll back to for one service: newest ready revision
37
+ * carrying the target BUILD_ID (same-SHA revisions with different digests
38
+ * can exist after a rebuilt image; newest wins). Input rows are
39
+ * `gcloud run revisions list --format=json` items. */
40
+ export declare function pickRevisionForSha(revisions: unknown[], targetSha: string): {
41
+ revision: string;
42
+ imageDigest: string;
43
+ } | null;
44
+ /** Extract serving state from a `gcloud run services describe --format=json`. */
45
+ export declare function servingStateOf(service: string, described: unknown): ServingState;
46
+ export declare function contentTypeFor(path: string): string;
47
+ /** Parse release-record listing rows into {sha, at} entries. */
48
+ export declare function releaseEntriesFrom(rows: {
49
+ url?: string;
50
+ metadata?: {
51
+ timeCreated?: string;
52
+ };
53
+ }[] | unknown, prefix: string): {
54
+ sha: string;
55
+ at: string;
56
+ }[];
@@ -0,0 +1,151 @@
1
+ // Pure decision logic for pw-deploy/pw-rollback: everything here is
2
+ // side-effect-free so the concurrency-sensitive choices (lock staleness,
3
+ // rollback targeting, serving-state consistency) are unit-tested directly.
4
+ export const SHA40 = /^[0-9a-f]{40}$/;
5
+ /** Lock staleness is judged from the GCS object's server-side timeCreated,
6
+ * never from anything the lock writer wrote (its clock may be wrong). */
7
+ export function lockIsStale(timeCreatedIso, nowMs, staleMs) {
8
+ const created = Date.parse(timeCreatedIso);
9
+ if (!Number.isFinite(created))
10
+ return false;
11
+ return nowMs - created > staleMs;
12
+ }
13
+ /** Consistent serving base across an app's services (notes2's rule,
14
+ * generalized): all services on one SHA -> that SHA; some already on the
15
+ * target -> the rest must agree on one other SHA (the base); anything else
16
+ * is a skew that needs a human. A null buildSha (pre-unification revision)
17
+ * yields base null — callers skip base-dependent guards. */
18
+ export function consistentBaseSha(serving, targetSha) {
19
+ if (serving.some((s) => s.buildSha === null))
20
+ return { ok: true, baseSha: null };
21
+ const shas = [...new Set(serving.map((s) => s.buildSha))];
22
+ if (shas.length === 1)
23
+ return { ok: true, baseSha: shas[0] };
24
+ const others = shas.filter((sha) => sha !== targetSha);
25
+ if (others.length === 1 && shas.length === 2)
26
+ return { ok: true, baseSha: others[0] };
27
+ return {
28
+ ok: false,
29
+ detail: `services serve inconsistent builds: ${serving
30
+ .map((s) => `${s.service}=${s.buildSha?.slice(0, 7)}`)
31
+ .join(' ')}`,
32
+ };
33
+ }
34
+ /** Rollback target selection. A rollback may only move backward: the target
35
+ * must come from a successful release record, differ from every serving
36
+ * SHA, and be an ancestor of each serving SHA (per isAncestor). Re-running
37
+ * the no-arg form converges instead of oscillating. */
38
+ export async function selectRollbackTarget(input) {
39
+ const servingShas = input.serving.map((s) => s.buildSha);
40
+ if (servingShas.some((sha) => sha === null)) {
41
+ return {
42
+ ok: false,
43
+ refusal: 'a serving revision has no BUILD_ID (pre-unification); use the manual ' +
44
+ 'gcloud run services update-traffic recipe in OPERATIONS.md',
45
+ };
46
+ }
47
+ const distinctServing = [...new Set(servingShas)];
48
+ const validate = async (sha) => {
49
+ if (distinctServing.includes(sha))
50
+ return `target ${sha.slice(0, 7)} is already serving`;
51
+ for (const serving of distinctServing) {
52
+ if (!(await input.isAncestor(sha, serving))) {
53
+ return `target ${sha.slice(0, 7)} is not an ancestor of serving ${serving.slice(0, 7)} — rollback only moves backward`;
54
+ }
55
+ }
56
+ return null;
57
+ };
58
+ if (input.requested) {
59
+ if (!SHA40.test(input.requested))
60
+ return { ok: false, refusal: 'target must be a full 40-char SHA' };
61
+ if (!input.records.some((r) => r.sha === input.requested)) {
62
+ return { ok: false, refusal: `no successful release record for ${input.requested.slice(0, 7)}` };
63
+ }
64
+ const problem = await validate(input.requested);
65
+ return problem ? { ok: false, refusal: problem } : { ok: true, sha: input.requested };
66
+ }
67
+ const newestFirst = [...input.records].sort((a, b) => b.at.localeCompare(a.at));
68
+ for (const record of newestFirst) {
69
+ if (distinctServing.includes(record.sha))
70
+ continue;
71
+ if ((await validate(record.sha)) === null)
72
+ return { ok: true, sha: record.sha };
73
+ }
74
+ return {
75
+ ok: false,
76
+ refusal: 'no earlier successful release record qualifies; pass an explicit SHA or ' +
77
+ 'use the manual traffic-move recipe',
78
+ };
79
+ }
80
+ /** Pick the revision to roll back to for one service: newest ready revision
81
+ * carrying the target BUILD_ID (same-SHA revisions with different digests
82
+ * can exist after a rebuilt image; newest wins). Input rows are
83
+ * `gcloud run revisions list --format=json` items. */
84
+ export function pickRevisionForSha(revisions, targetSha) {
85
+ const matches = revisions.flatMap((raw) => {
86
+ const row = raw;
87
+ const env = row.spec?.containers?.[0]?.env ?? [];
88
+ const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value;
89
+ const ready = (row.status?.conditions ?? []).some((c) => c.type === 'Ready' && c.status === 'True');
90
+ const created = Date.parse(row.metadata?.creationTimestamp ?? '');
91
+ if (!ready || buildSha !== targetSha || !row.metadata?.name || !Number.isFinite(created))
92
+ return [];
93
+ return [
94
+ {
95
+ revision: row.metadata.name,
96
+ imageDigest: row.spec?.containers?.[0]?.image ?? '',
97
+ created,
98
+ },
99
+ ];
100
+ });
101
+ matches.sort((a, b) => b.created - a.created);
102
+ return matches[0] ? { revision: matches[0].revision, imageDigest: matches[0].imageDigest } : null;
103
+ }
104
+ /** Extract serving state from a `gcloud run services describe --format=json`. */
105
+ export function servingStateOf(service, described) {
106
+ const d = described;
107
+ const active = (d.status?.traffic ?? []).filter((t) => (t.percent ?? 0) === 100);
108
+ if (active.length !== 1 || !active[0].revisionName) {
109
+ throw new Error(`${service}: expected exactly one 100% traffic target`);
110
+ }
111
+ const env = d.spec?.template?.spec?.containers?.[0]?.env ?? [];
112
+ const buildSha = env.find((e) => e.name === 'BUILD_ID')?.value ?? null;
113
+ return {
114
+ service,
115
+ revision: active[0].revisionName,
116
+ buildSha: buildSha !== null && SHA40.test(buildSha) ? buildSha : null,
117
+ imageDigest: d.spec?.template?.spec?.containers?.[0]?.image ?? null,
118
+ };
119
+ }
120
+ const CONTENT_TYPES = {
121
+ '.js': 'text/javascript',
122
+ '.mjs': 'text/javascript',
123
+ '.css': 'text/css',
124
+ '.map': 'application/json',
125
+ '.json': 'application/json',
126
+ '.svg': 'image/svg+xml',
127
+ '.png': 'image/png',
128
+ '.webp': 'image/webp',
129
+ '.woff2': 'font/woff2',
130
+ '.html': 'text/html',
131
+ '.txt': 'text/plain',
132
+ };
133
+ export function contentTypeFor(path) {
134
+ const dot = path.lastIndexOf('.');
135
+ const ext = dot === -1 ? '' : path.slice(dot).toLowerCase();
136
+ return CONTENT_TYPES[ext] ?? 'application/octet-stream';
137
+ }
138
+ /** Parse release-record listing rows into {sha, at} entries. */
139
+ export function releaseEntriesFrom(rows, prefix) {
140
+ if (!Array.isArray(rows))
141
+ return [];
142
+ return rows.flatMap((row) => {
143
+ const r = row;
144
+ const url = r.url ?? '';
145
+ const name = url.slice(url.lastIndexOf('/') + 1);
146
+ const sha = name.endsWith('.json') ? name.slice(0, -5) : '';
147
+ if (!SHA40.test(sha) || !url.includes(prefix))
148
+ return [];
149
+ return [{ sha, at: r.metadata?.timeCreated ?? '' }];
150
+ });
151
+ }
@@ -0,0 +1,10 @@
1
+ import type { Runner } from './types.js';
2
+ export interface RollbackOptions {
3
+ repoRoot: string;
4
+ runner: Runner;
5
+ log?: (message: string) => void;
6
+ argv?: string[];
7
+ sleep?: (ms: number) => Promise<void>;
8
+ fetchFn?: typeof fetch;
9
+ }
10
+ export declare function runRollback(options: RollbackOptions): Promise<number>;