@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.
- package/dist/bin/pw-deploy.d.ts +2 -0
- package/dist/bin/pw-deploy.js +15 -0
- package/dist/bin/pw-rollback.d.ts +2 -0
- package/dist/bin/pw-rollback.js +10 -0
- package/dist/db.d.ts +12 -1
- package/dist/db.js +21 -0
- package/dist/deploy/config.d.ts +5 -0
- package/dist/deploy/config.js +67 -0
- package/dist/deploy/deploy.d.ts +13 -0
- package/dist/deploy/deploy.js +405 -0
- package/dist/deploy/evidence.d.ts +19 -0
- package/dist/deploy/evidence.js +60 -0
- package/dist/deploy/exec.d.ts +10 -0
- package/dist/deploy/exec.js +41 -0
- package/dist/deploy/gcs.d.ts +43 -0
- package/dist/deploy/gcs.js +189 -0
- package/dist/deploy/plan.d.ts +56 -0
- package/dist/deploy/plan.js +151 -0
- package/dist/deploy/rollback.d.ts +10 -0
- package/dist/deploy/rollback.js +176 -0
- package/dist/deploy/types.d.ts +119 -0
- package/dist/deploy/types.js +3 -0
- package/dist/log-core.d.ts +2 -1
- package/dist/log-core.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The pw-rollback orchestrator: one-command traffic move to a previously
|
|
2
|
+
// released build. Never rebuilds. Breaks-and-takes the deploy lock (a
|
|
3
|
+
// rollback must not queue behind the stuck deploy that motivated it), places
|
|
4
|
+
// a hold so unattended deploys cannot silently re-ship the rolled-back SHA,
|
|
5
|
+
// and only ever moves backward (release records + ancestry checks).
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
import { must, mustJson } from './exec.js';
|
|
8
|
+
import { openEvidence, PhaseTimer } from './evidence.js';
|
|
9
|
+
import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
|
|
10
|
+
import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
|
|
11
|
+
import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, servingStateOf, SHA40, } from './plan.js';
|
|
12
|
+
export async function runRollback(options) {
|
|
13
|
+
const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
|
|
14
|
+
const runner = options.runner;
|
|
15
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
16
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
17
|
+
const argv = options.argv ?? [];
|
|
18
|
+
const allowMigrationDiff = argv.includes('--allow-migration-diff');
|
|
19
|
+
const requested = argv.find((a) => SHA40.test(a));
|
|
20
|
+
const timer = new PhaseTimer(log);
|
|
21
|
+
let evidenceDir = '';
|
|
22
|
+
let lock = null;
|
|
23
|
+
try {
|
|
24
|
+
const config = await loadConfig(options.repoRoot);
|
|
25
|
+
const gcloudBase = ['--project', config.project];
|
|
26
|
+
// A rollback is an incident tool: it needs origin's commits for ancestry
|
|
27
|
+
// checks but does not demand a clean tree or the main branch.
|
|
28
|
+
await must(runner, 'git', ['fetch', 'origin', 'main'], { cwd: options.repoRoot });
|
|
29
|
+
const account = (await must(runner, 'gcloud', ['config', 'get', 'account'])).stdout.trim();
|
|
30
|
+
if (account !== (config.account ?? DEFAULT_ACCOUNT)) {
|
|
31
|
+
throw new Error(`gcloud account must be ${config.account ?? DEFAULT_ACCOUNT} (is ${account})`);
|
|
32
|
+
}
|
|
33
|
+
const project = (await must(runner, 'gcloud', ['config', 'get', 'project'])).stdout.trim();
|
|
34
|
+
if (project !== config.project) {
|
|
35
|
+
throw new Error(`gcloud project must be ${config.project} (is ${project})`);
|
|
36
|
+
}
|
|
37
|
+
timer.enter('lock');
|
|
38
|
+
lock = await acquireLock(runner, `gs://${config.bucket}/${config.app}/deploy.lock`, `host=${hostname()}\nrollback=true\nstarted=${new Date().toISOString()}\n`, { log, sleep, breakExisting: true });
|
|
39
|
+
timer.enter('target');
|
|
40
|
+
const serving = [];
|
|
41
|
+
for (const service of config.services) {
|
|
42
|
+
const described = await mustJson(runner, 'gcloud', [
|
|
43
|
+
'run', 'services', 'describe', service.name,
|
|
44
|
+
...gcloudBase, '--region', config.region, '--format=json',
|
|
45
|
+
]);
|
|
46
|
+
serving.push(servingStateOf(service.name, described));
|
|
47
|
+
}
|
|
48
|
+
const evidence = await openEvidence(config.app, requested ?? serving[0].buildSha ?? 'rollback');
|
|
49
|
+
evidenceDir = evidence.dir;
|
|
50
|
+
await evidence.save('serving-before.json', JSON.stringify(serving, null, 2));
|
|
51
|
+
const rows = await listReleaseRecords(runner, config.bucket, config.releasePrefix);
|
|
52
|
+
const records = releaseEntriesFrom(rows, config.releasePrefix);
|
|
53
|
+
const isAncestor = async (maybeAncestor, descendant) => {
|
|
54
|
+
const exists = await runner('git', ['cat-file', '-e', `${maybeAncestor}^{commit}`], {
|
|
55
|
+
cwd: options.repoRoot,
|
|
56
|
+
});
|
|
57
|
+
if (exists.code !== 0)
|
|
58
|
+
return false;
|
|
59
|
+
const result = await runner('git', ['merge-base', '--is-ancestor', maybeAncestor, descendant], { cwd: options.repoRoot });
|
|
60
|
+
if (result.code > 1)
|
|
61
|
+
throw new Error(`git merge-base failed: ${result.stderr.trim()}`);
|
|
62
|
+
return result.code === 0;
|
|
63
|
+
};
|
|
64
|
+
const selection = await selectRollbackTarget({ requested, records, serving, isAncestor });
|
|
65
|
+
if (!selection.ok || !selection.sha) {
|
|
66
|
+
throw new Error(`no rollback target: ${selection.refusal}`);
|
|
67
|
+
}
|
|
68
|
+
const target = selection.sha;
|
|
69
|
+
log(`rolling back to ${target.slice(0, 12)}`);
|
|
70
|
+
const servingShas = [...new Set(serving.map((s) => s.buildSha))];
|
|
71
|
+
if (!allowMigrationDiff) {
|
|
72
|
+
for (const fromSha of servingShas) {
|
|
73
|
+
const diff = (await must(runner, 'git', ['diff', '--name-only', '--diff-filter=ACMRD', `${target}..${fromSha}`, '--', 'migrations'], { cwd: options.repoRoot })).stdout.trim();
|
|
74
|
+
if (diff) {
|
|
75
|
+
throw new Error(`migrations changed between ${target.slice(0, 12)} and serving ` +
|
|
76
|
+
`${fromSha.slice(0, 12)} — old code would run against a migrated schema. ` +
|
|
77
|
+
'Decide deliberately; pass --allow-migration-diff to proceed.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const targets = [];
|
|
82
|
+
for (const service of config.services) {
|
|
83
|
+
const revisions = await mustJson(runner, 'gcloud', [
|
|
84
|
+
'run', 'revisions', 'list', '--service', service.name,
|
|
85
|
+
...gcloudBase, '--region', config.region, '--format=json',
|
|
86
|
+
]);
|
|
87
|
+
const picked = pickRevisionForSha(revisions, target);
|
|
88
|
+
if (!picked) {
|
|
89
|
+
throw new Error(`${service.name}: no ready revision carries BUILD_ID ${target.slice(0, 12)}; ` +
|
|
90
|
+
'use the manual traffic-move recipe in OPERATIONS.md');
|
|
91
|
+
}
|
|
92
|
+
targets.push({ name: service.name, ...picked });
|
|
93
|
+
}
|
|
94
|
+
if (new Set(targets.map((t) => t.imageDigest)).size > 1) {
|
|
95
|
+
throw new Error(`services would land on different images for ${target.slice(0, 12)}: ` +
|
|
96
|
+
targets.map((t) => `${t.name}=${t.imageDigest.slice(-12)}`).join(' '));
|
|
97
|
+
}
|
|
98
|
+
timer.enter('traffic');
|
|
99
|
+
for (const t of targets) {
|
|
100
|
+
await must(runner, 'gcloud', [
|
|
101
|
+
'run', 'services', 'update-traffic', t.name,
|
|
102
|
+
...gcloudBase, '--region', config.region, '--quiet', '--format=json',
|
|
103
|
+
'--to-revisions', `${t.revision}=100`,
|
|
104
|
+
]);
|
|
105
|
+
log(`${t.name}: pinned to ${t.revision}`);
|
|
106
|
+
}
|
|
107
|
+
timer.enter('hold');
|
|
108
|
+
await placeHold(runner, `gs://${config.bucket}/${config.app}/deploy.hold`, {
|
|
109
|
+
heldShas: servingShas.filter((sha) => sha !== target),
|
|
110
|
+
reason: 'rollback',
|
|
111
|
+
at: new Date().toISOString(),
|
|
112
|
+
});
|
|
113
|
+
timer.enter('smoke');
|
|
114
|
+
for (const [index, service] of config.services.entries()) {
|
|
115
|
+
if (service.health.kind === 'none')
|
|
116
|
+
continue;
|
|
117
|
+
const path = service.health.path ?? '/health';
|
|
118
|
+
let url;
|
|
119
|
+
let headers = {};
|
|
120
|
+
if (service.health.kind === 'public-canonical') {
|
|
121
|
+
url = `${service.canonicalUrl}${path}`;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const described = await mustJson(runner, 'gcloud', [
|
|
125
|
+
'run', 'services', 'describe', service.name,
|
|
126
|
+
...gcloudBase, '--region', config.region, '--format=json',
|
|
127
|
+
]);
|
|
128
|
+
const runUrl = described.status?.url ?? '';
|
|
129
|
+
const audience = service.health.kind === 'oidc-audience' ? service.health.audience : runUrl;
|
|
130
|
+
const token = (await must(runner, 'gcloud', ['auth', 'print-identity-token', `--audiences=${audience}`])).stdout.trim();
|
|
131
|
+
headers = { authorization: `Bearer ${token}` };
|
|
132
|
+
url = `${runUrl}${path}`;
|
|
133
|
+
}
|
|
134
|
+
let passed = false;
|
|
135
|
+
let lastDetail = '';
|
|
136
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetchFn(url, { headers });
|
|
139
|
+
const body = (await response.json());
|
|
140
|
+
if (response.ok &&
|
|
141
|
+
body.ok === true &&
|
|
142
|
+
(body.revision === undefined || body.revision === targets[index].revision)) {
|
|
143
|
+
passed = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
lastDetail = `status ${response.status} revision ${body.revision ?? '?'}`;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
lastDetail = String(error);
|
|
150
|
+
}
|
|
151
|
+
await sleep(3000);
|
|
152
|
+
}
|
|
153
|
+
if (!passed)
|
|
154
|
+
throw new Error(`${service.name}: post-rollback smoke failed (${lastDetail})`);
|
|
155
|
+
log(`${service.name}: smoke passed`);
|
|
156
|
+
}
|
|
157
|
+
timer.close();
|
|
158
|
+
log(`rolled back to ${target.slice(0, 12)}`);
|
|
159
|
+
log(`hold placed on ${servingShas.map((sha) => sha.slice(0, 12)).join(', ')} — ` +
|
|
160
|
+
'deploys of held SHAs refuse without --override-hold; the hold clears when a different SHA deploys');
|
|
161
|
+
log(`timings:\n${timer.table()}`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
console.error(message);
|
|
167
|
+
console.error(`rollback failed in phase: ${timer.current}`);
|
|
168
|
+
if (evidenceDir)
|
|
169
|
+
console.error(`evidence: ${evidenceDir}`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (lock)
|
|
174
|
+
await releaseLock(runner, lock);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export interface ServiceHealth {
|
|
2
|
+
/** How the shared smoke reaches /health:
|
|
3
|
+
* - public-canonical: unauthenticated GET on the canonical URL
|
|
4
|
+
* - oidc-run-url: OIDC identity token with the service's run.app URL audience
|
|
5
|
+
* - oidc-audience: OIDC identity token with a fixed custom audience
|
|
6
|
+
* - none: no shared health check (app hook covers it) */
|
|
7
|
+
kind: 'public-canonical' | 'oidc-run-url' | 'oidc-audience' | 'none';
|
|
8
|
+
audience?: string;
|
|
9
|
+
path?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ServiceConfig {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Canonical public URL (used by public-canonical health and printed). */
|
|
14
|
+
canonicalUrl?: string;
|
|
15
|
+
health: ServiceHealth;
|
|
16
|
+
}
|
|
17
|
+
export interface DeployConfig {
|
|
18
|
+
/** Short app name; prefixes the lock/hold/release objects. */
|
|
19
|
+
app: string;
|
|
20
|
+
project: string;
|
|
21
|
+
region: string;
|
|
22
|
+
/** GCS bucket holding lock, hold, release records, and retained assets. */
|
|
23
|
+
bucket: string;
|
|
24
|
+
/** Expected gcloud account (defaults to the fleet coding agent). */
|
|
25
|
+
account?: string;
|
|
26
|
+
image: {
|
|
27
|
+
arRepo: string;
|
|
28
|
+
name: string;
|
|
29
|
+
buildServiceAccount: string;
|
|
30
|
+
};
|
|
31
|
+
build: {
|
|
32
|
+
/** Command producing the deployable outputs; run with BUILD_ID=<sha>. */
|
|
33
|
+
command: string;
|
|
34
|
+
/** Repo-relative files/dirs copied into the sealed build context
|
|
35
|
+
* alongside Dockerfile, cloudbuild.yaml, package.json, package-lock.json. */
|
|
36
|
+
contextFiles: string[];
|
|
37
|
+
};
|
|
38
|
+
/** Gate commands run while the image builds; all must pass before traffic. */
|
|
39
|
+
gates: string[];
|
|
40
|
+
/** Default retained-asset publish (omit when a publishAssets hook or no
|
|
41
|
+
* assets exist): dir is repo-relative, prefix is bucket-relative. */
|
|
42
|
+
assets?: {
|
|
43
|
+
dir: string;
|
|
44
|
+
prefix: string;
|
|
45
|
+
};
|
|
46
|
+
/** Bucket-relative prefix for shared release records, e.g. "todo/pw-releases/". */
|
|
47
|
+
releasePrefix: string;
|
|
48
|
+
/** Secrets re-pinned on every routine deploy (name -> secret:version). */
|
|
49
|
+
updateSecrets?: Record<string, string>;
|
|
50
|
+
/** Refuse to deploy across a migrations/ diff (notes2 semantics). */
|
|
51
|
+
refuseMigrationDiffOnDeploy?: boolean;
|
|
52
|
+
/** Repo-relative path of an optional ES module exporting hooks. */
|
|
53
|
+
hooks?: string;
|
|
54
|
+
services: ServiceConfig[];
|
|
55
|
+
}
|
|
56
|
+
export interface ExecResult {
|
|
57
|
+
stdout: string;
|
|
58
|
+
stderr: string;
|
|
59
|
+
code: number;
|
|
60
|
+
}
|
|
61
|
+
export interface ExecOptions {
|
|
62
|
+
timeoutMs?: number;
|
|
63
|
+
cwd?: string;
|
|
64
|
+
env?: Record<string, string | undefined>;
|
|
65
|
+
input?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Injected process runner; never throws on nonzero exit (code reports it). */
|
|
68
|
+
export type Runner = (file: string, args: string[], opts?: ExecOptions) => Promise<ExecResult>;
|
|
69
|
+
export interface ReleaseRecord {
|
|
70
|
+
sha: string;
|
|
71
|
+
at: string;
|
|
72
|
+
app: string;
|
|
73
|
+
services: {
|
|
74
|
+
name: string;
|
|
75
|
+
revision: string;
|
|
76
|
+
imageDigest: string;
|
|
77
|
+
}[];
|
|
78
|
+
timingsMs?: Record<string, number>;
|
|
79
|
+
}
|
|
80
|
+
export interface HoldRecord {
|
|
81
|
+
/** SHAs that must not deploy without an explicit override. */
|
|
82
|
+
heldShas: string[];
|
|
83
|
+
reason: string;
|
|
84
|
+
at: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ServingState {
|
|
87
|
+
service: string;
|
|
88
|
+
revision: string;
|
|
89
|
+
/** BUILD_ID env of the serving revision; null before unification. */
|
|
90
|
+
buildSha: string | null;
|
|
91
|
+
imageDigest: string | null;
|
|
92
|
+
}
|
|
93
|
+
export interface HookContext {
|
|
94
|
+
sha: string;
|
|
95
|
+
/** Consistent serving base SHA across services, when determinable. */
|
|
96
|
+
baseSha: string | null;
|
|
97
|
+
config: DeployConfig;
|
|
98
|
+
repoRoot: string;
|
|
99
|
+
contextDir: string;
|
|
100
|
+
evidenceDir: string;
|
|
101
|
+
/** Resolved image digest (release phase onward). */
|
|
102
|
+
imageDigest?: string;
|
|
103
|
+
serving: ServingState[];
|
|
104
|
+
exec: Runner;
|
|
105
|
+
log: (message: string) => void;
|
|
106
|
+
}
|
|
107
|
+
export interface DeployHooks {
|
|
108
|
+
/** Runs after the local build, before the context is sealed. Overrides the
|
|
109
|
+
* default retained-asset publish when present. */
|
|
110
|
+
publishAssets?: (ctx: HookContext) => Promise<void>;
|
|
111
|
+
/** Runs after all gates pass, immediately before the first traffic switch. */
|
|
112
|
+
preRelease?: (ctx: HookContext) => Promise<void>;
|
|
113
|
+
/** Runs right after traffic moves (e.g. todo2's announce-build). */
|
|
114
|
+
postTraffic?: (ctx: HookContext) => Promise<void>;
|
|
115
|
+
/** Extra smoke checks after the shared health checks. */
|
|
116
|
+
smoke?: (ctx: HookContext) => Promise<void>;
|
|
117
|
+
/** Runs after everything else succeeded (e.g. notes2's release state). */
|
|
118
|
+
postSuccess?: (ctx: HookContext) => Promise<void>;
|
|
119
|
+
}
|
package/dist/log-core.d.ts
CHANGED
|
@@ -52,7 +52,8 @@ export interface ShipperOptions {
|
|
|
52
52
|
* fire at the next CPU window (next request or shutdown), not on the
|
|
53
53
|
* wall clock. */
|
|
54
54
|
retryDelaysMs?: number[];
|
|
55
|
-
/** Per-attempt fetch timeout (default 3s
|
|
55
|
+
/** Per-attempt fetch timeout (default 10s; 3s aborted most of the fleet's
|
|
56
|
+
* attempts in the two weeks to 2026-08-28). */
|
|
56
57
|
attemptTimeoutMs?: number;
|
|
57
58
|
/** Caps across ALL undelivered batches; overflow drops oldest with an
|
|
58
59
|
* onGiveUp('buffer-overflow'). Defaults 5000 events / 5 MB. */
|
package/dist/log-core.js
CHANGED
|
@@ -70,7 +70,7 @@ export class LogShipper {
|
|
|
70
70
|
this.intervalMs = opts.intervalMs ?? 2000;
|
|
71
71
|
this.maxAttempts = opts.maxAttempts ?? 4;
|
|
72
72
|
this.retryDelaysMs = opts.retryDelaysMs ?? [5_000, 15_000, 40_000];
|
|
73
|
-
this.attemptTimeoutMs = opts.attemptTimeoutMs ??
|
|
73
|
+
this.attemptTimeoutMs = opts.attemptTimeoutMs ?? 10_000;
|
|
74
74
|
this.maxBufferedEvents = opts.maxBufferedEvents ?? 5_000;
|
|
75
75
|
this.maxBufferedBytes = opts.maxBufferedBytes ?? 5 * 1024 * 1024;
|
|
76
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,7 +63,9 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"bin": {
|
|
66
|
-
"check-test-owners": "./dist/bin/check-test-owners.js"
|
|
66
|
+
"check-test-owners": "./dist/bin/check-test-owners.js",
|
|
67
|
+
"pw-deploy": "./dist/bin/pw-deploy.js",
|
|
68
|
+
"pw-rollback": "./dist/bin/pw-rollback.js"
|
|
67
69
|
},
|
|
68
70
|
"engines": {
|
|
69
71
|
"node": "22.x"
|