pgserve 2.4.0 → 2.6.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.
Files changed (36) hide show
  1. package/README.md +5 -8
  2. package/bin/pgserve-wrapper.cjs +23 -0
  3. package/bin/postgres-server.js +28 -0
  4. package/package.json +2 -1
  5. package/scripts/aggregate-manifest.sh +184 -0
  6. package/scripts/assemble-tarball.sh +191 -0
  7. package/scripts/audit-redaction-lint.js +349 -0
  8. package/scripts/build-binary.sh +213 -0
  9. package/scripts/fetch-postgres-bins.sh +234 -0
  10. package/scripts/postinstall.cjs +102 -18
  11. package/scripts/verify-published-artifacts.sh +211 -0
  12. package/src/audit/audit.js +134 -0
  13. package/src/cli-install.cjs +258 -26
  14. package/src/commands/doctor.js +465 -0
  15. package/src/commands/gc.js +276 -0
  16. package/src/commands/provision.js +396 -0
  17. package/src/commands/trust.js +187 -0
  18. package/src/commands/verify.js +360 -0
  19. package/src/cosign/cache-token.js +328 -0
  20. package/src/cosign/schema.js +97 -0
  21. package/src/cosign/trust-list.js +81 -0
  22. package/src/cosign/trust-store.js +250 -0
  23. package/src/cosign/verify-binary.js +277 -0
  24. package/src/gc/audit-log.js +150 -0
  25. package/src/gc/orphan-detection.js +190 -0
  26. package/src/gc/queries.js +193 -0
  27. package/src/lib/pg-query.js +145 -0
  28. package/src/lib/runtime-json.js +181 -0
  29. package/src/provision/advisory-lock.js +91 -0
  30. package/src/provision/db-naming.js +130 -0
  31. package/src/provision/fingerprint.js +144 -0
  32. package/src/schema/pgserve-meta.js +120 -0
  33. package/src/security/blocked-versions.js +103 -0
  34. package/src/upgrade/index.js +5 -0
  35. package/src/upgrade/steps/binary-cache-flush.js +2 -2
  36. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
@@ -0,0 +1,465 @@
1
+ /**
2
+ * `pgserve doctor` — health-check the local pgserve install.
3
+ *
4
+ * pgserve-singleton-no-proxy wish, Group 3 (read-only V1; --fix tiered
5
+ * modes deferred to a follow-up — see SHARED-DESIGN.md §3.2).
6
+ *
7
+ * Goals:
8
+ * - Read-only by default. Surfaces every detectable misconfiguration as a
9
+ * structured finding so an operator (or `pgserve doctor --json` output
10
+ * piped to a tool) can act on it without `pgserve doctor` itself
11
+ * mutating the host.
12
+ * - Honors the cohort supervisor model: reads `~/.autopg/admin.json`
13
+ * (singleton G1 + canonical G1 + cutover G11 co-owned schema) and
14
+ * dispatches the matching liveness probe (pm2 / systemd-user / launchd).
15
+ * - Reports `<socketDir>/runtime.json` (cutover G19 live-discovery file)
16
+ * and the postmaster reachability on both the canonical Unix socket and
17
+ * TCP loopback.
18
+ * - Refuses to suggest tier swaps. Felipe directive 2026-05-08: doctor
19
+ * reports passively.
20
+ *
21
+ * Out of scope (V1):
22
+ * - `--fix` / `--fix --aggressive` mutations. Tiered mode design (Cat 1/2/3)
23
+ * locked in SHARED-DESIGN §3.2 but implementation lives in a follow-up.
24
+ * - GC sweep (G3 sibling verb).
25
+ * - Cosign verify state inspection beyond what's already in `pgserve_meta`.
26
+ *
27
+ * Output modes:
28
+ * - default: human-readable summary, exit 0 if every check passes, exit 1
29
+ * if any FAIL, exit 2 if any WARN (no FAILs).
30
+ * - `--json`: emits `{ ok, findings: [...], summary }` to stdout. Exit
31
+ * code identical to the human path.
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import net from 'node:net';
37
+ import { createRequire } from 'node:module';
38
+ import { spawnSync } from 'node:child_process';
39
+
40
+ const require = createRequire(import.meta.url);
41
+ import { getAdminFilePath, readAdminJson, SUPERVISOR_VALUES } from '../lib/admin-json.js';
42
+ import { resolveSocketDir } from '../lib/socket-dir.js';
43
+ import { readRuntimeJson, isLiveRuntime } from '../lib/runtime-json.js';
44
+ import { findBlocked } from '../security/blocked-versions.js';
45
+
46
+ const SEVERITY = Object.freeze({ PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL' });
47
+
48
+ /**
49
+ * @typedef {Object} Finding
50
+ * @property {string} id Stable check id (snake_case, grep-friendly).
51
+ * @property {string} title Human-readable summary.
52
+ * @property {'PASS'|'WARN'|'FAIL'} severity
53
+ * @property {string} [detail] One-line operator-facing explanation.
54
+ * @property {string} [hint] Optional remediation hint.
55
+ */
56
+
57
+ function check(id, title, severity, detail, hint) {
58
+ /** @type {Finding} */
59
+ const f = { id, title, severity };
60
+ if (detail) f.detail = detail;
61
+ if (hint) f.hint = hint;
62
+ return f;
63
+ }
64
+
65
+ function getCurrentVersion() {
66
+ try {
67
+ return require('../../package.json').version;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ // ─── individual checks ────────────────────────────────────────────────
74
+
75
+ function checkVersionNotBlocked() {
76
+ const version = getCurrentVersion();
77
+ if (!version) {
78
+ return check(
79
+ 'version_blocklist',
80
+ 'pgserve binary version not blocked',
81
+ SEVERITY.WARN,
82
+ 'cannot read pgserve version from package.json',
83
+ 'reinstall pgserve via npm/bun and re-run',
84
+ );
85
+ }
86
+ const hit = findBlocked(version);
87
+ if (hit) {
88
+ return check(
89
+ 'version_blocklist',
90
+ `pgserve@${version} is on the hardcoded blocklist`,
91
+ SEVERITY.FAIL,
92
+ hit.reason,
93
+ 'install a different version (run `pgserve upgrade` for the latest)',
94
+ );
95
+ }
96
+ return check('version_blocklist', `pgserve@${version} is not blocked`, SEVERITY.PASS);
97
+ }
98
+
99
+ function checkAdminJsonExists() {
100
+ const file = getAdminFilePath();
101
+ if (!fs.existsSync(file)) {
102
+ return check(
103
+ 'admin_json_exists',
104
+ '~/.autopg/admin.json missing',
105
+ SEVERITY.FAIL,
106
+ `${file} does not exist — pgserve install has not run`,
107
+ 'run `pgserve install` (Tier A) or `autopg service install` (Tier B)',
108
+ );
109
+ }
110
+ try {
111
+ const stat = fs.statSync(file);
112
+ const mode = stat.mode & 0o777;
113
+ if (mode !== 0o600) {
114
+ return check(
115
+ 'admin_json_exists',
116
+ '~/.autopg/admin.json file mode is not 0600',
117
+ SEVERITY.WARN,
118
+ `mode is 0${mode.toString(8)} — should be 0600 (cohort schema)`,
119
+ 'chmod 600 ~/.autopg/admin.json',
120
+ );
121
+ }
122
+ } catch (e) {
123
+ return check('admin_json_exists', '~/.autopg/admin.json stat failed', SEVERITY.FAIL, e.message);
124
+ }
125
+ return check('admin_json_exists', '~/.autopg/admin.json exists with mode 0600', SEVERITY.PASS);
126
+ }
127
+
128
+ function checkAdminJsonShape() {
129
+ let admin;
130
+ try {
131
+ admin = readAdminJson();
132
+ } catch {
133
+ return check(
134
+ 'admin_json_shape',
135
+ '~/.autopg/admin.json could not be parsed',
136
+ SEVERITY.FAIL,
137
+ 'JSON parse failed',
138
+ 'restore from backup or run `pgserve install` to regenerate',
139
+ );
140
+ }
141
+ if (!admin) {
142
+ return check(
143
+ 'admin_json_shape',
144
+ '~/.autopg/admin.json missing or empty',
145
+ SEVERITY.WARN,
146
+ 'readAdminJson returned null — file may not exist yet',
147
+ );
148
+ }
149
+ const supervisor = admin.supervisor;
150
+ if (!SUPERVISOR_VALUES.includes(supervisor)) {
151
+ return check(
152
+ 'admin_json_shape',
153
+ `admin.json.supervisor is invalid: ${JSON.stringify(supervisor)}`,
154
+ SEVERITY.FAIL,
155
+ `expected one of {${SUPERVISOR_VALUES.join(', ')}}`,
156
+ 'reinstall via `pgserve install` (Tier A) or `autopg service install` (Tier B)',
157
+ );
158
+ }
159
+ if (!Number.isInteger(admin.port) || admin.port <= 0 || admin.port > 65535) {
160
+ return check(
161
+ 'admin_json_shape',
162
+ `admin.json.port is invalid: ${JSON.stringify(admin.port)}`,
163
+ SEVERITY.FAIL,
164
+ 'expected a positive integer in [1, 65535]',
165
+ 'reinstall via `pgserve install` to regenerate admin.json',
166
+ );
167
+ }
168
+ if (typeof admin.socketDir !== 'string' || admin.socketDir.length === 0) {
169
+ return check(
170
+ 'admin_json_shape',
171
+ `admin.json.socketDir is invalid: ${JSON.stringify(admin.socketDir)}`,
172
+ SEVERITY.FAIL,
173
+ 'expected a non-empty string',
174
+ 'reinstall via `pgserve install` to regenerate admin.json',
175
+ );
176
+ }
177
+ return check(
178
+ 'admin_json_shape',
179
+ `admin.json.supervisor = "${supervisor}"`,
180
+ SEVERITY.PASS,
181
+ `port=${admin.port}, socketDir=${admin.socketDir}`,
182
+ );
183
+ }
184
+
185
+ // Cap every supervisor probe at 5s. Without this, a hung supervisor
186
+ // (stuck pm2 daemon, dbus deadlock on systemctl, launchctl waiting on a
187
+ // lock) would make `pgserve doctor` itself hang forever — defeating the
188
+ // "diagnostic tool the operator runs when something is wrong" use case.
189
+ // 5s is far above any healthy probe (single-digit ms in practice).
190
+ const SUPERVISOR_PROBE_TIMEOUT_MS = 5000;
191
+
192
+ function spawnHitTimeout(result) {
193
+ // node's child_process: when spawnSync hits the timeout, it terminates
194
+ // the child via SIGTERM (configurable), reports `signal: 'SIGTERM'`,
195
+ // status null, and on some platforms sets error.code === 'ETIMEDOUT'.
196
+ if (result?.error?.code === 'ETIMEDOUT') return true;
197
+ if (result?.signal === 'SIGTERM' && result?.status === null) return true;
198
+ return false;
199
+ }
200
+
201
+ function pm2EntryOnline(name) {
202
+ try {
203
+ const result = spawnSync('pm2', ['jlist'], {
204
+ encoding: 'utf8',
205
+ stdio: ['ignore', 'pipe', 'pipe'],
206
+ timeout: SUPERVISOR_PROBE_TIMEOUT_MS,
207
+ });
208
+ if (spawnHitTimeout(result)) return { ok: false, reason: `pm2 jlist timed out after ${SUPERVISOR_PROBE_TIMEOUT_MS}ms` };
209
+ if (result.status !== 0) return { ok: false, reason: 'pm2 jlist failed' };
210
+ const list = JSON.parse(result.stdout || '[]');
211
+ const entry = list.find((p) => p.name === name);
212
+ if (!entry) return { ok: false, reason: 'no pm2 entry found' };
213
+ if (entry.pm2_env?.status !== 'online') {
214
+ return { ok: false, reason: `pm2 entry status: ${entry.pm2_env?.status}` };
215
+ }
216
+ return { ok: true };
217
+ } catch (e) {
218
+ return { ok: false, reason: e.message };
219
+ }
220
+ }
221
+
222
+ function systemdUnitActive(unit, scope) {
223
+ const args = scope === 'user' ? ['--user', 'is-active', unit] : ['is-active', unit];
224
+ try {
225
+ const result = spawnSync('systemctl', args, {
226
+ encoding: 'utf8',
227
+ stdio: ['ignore', 'pipe', 'pipe'],
228
+ timeout: SUPERVISOR_PROBE_TIMEOUT_MS,
229
+ });
230
+ if (spawnHitTimeout(result)) return { ok: false, reason: `systemctl is-active timed out after ${SUPERVISOR_PROBE_TIMEOUT_MS}ms` };
231
+ return { ok: result.status === 0, reason: (result.stdout || '').trim() || 'unknown' };
232
+ } catch (e) {
233
+ return { ok: false, reason: e.message };
234
+ }
235
+ }
236
+
237
+ function launchdJobLoaded(label) {
238
+ try {
239
+ const result = spawnSync('launchctl', ['list', label], {
240
+ encoding: 'utf8',
241
+ stdio: ['ignore', 'pipe', 'pipe'],
242
+ timeout: SUPERVISOR_PROBE_TIMEOUT_MS,
243
+ });
244
+ if (spawnHitTimeout(result)) return { ok: false, reason: `launchctl list timed out after ${SUPERVISOR_PROBE_TIMEOUT_MS}ms` };
245
+ return { ok: result.status === 0, reason: result.status === 0 ? 'loaded' : 'not loaded' };
246
+ } catch (e) {
247
+ return { ok: false, reason: e.message };
248
+ }
249
+ }
250
+
251
+ function checkSupervisorLiveness(admin) {
252
+ if (!admin || !admin.supervisor) {
253
+ return check(
254
+ 'supervisor_liveness',
255
+ 'cannot probe supervisor — admin.json absent or invalid',
256
+ SEVERITY.WARN,
257
+ );
258
+ }
259
+ switch (admin.supervisor) {
260
+ case 'pm2': {
261
+ const r = pm2EntryOnline('autopg-server');
262
+ return r.ok
263
+ ? check('supervisor_liveness', 'pm2 autopg-server entry online', SEVERITY.PASS)
264
+ : check('supervisor_liveness', 'pm2 autopg-server entry not online', SEVERITY.FAIL, r.reason, 'run `pgserve install` to (re-)register pm2 entry');
265
+ }
266
+ case 'systemd-user': {
267
+ const r = systemdUnitActive('autopg.service', 'user');
268
+ return r.ok
269
+ ? check('supervisor_liveness', 'systemd-user autopg.service active', SEVERITY.PASS, r.reason)
270
+ : check('supervisor_liveness', `systemd-user autopg.service not active`, SEVERITY.FAIL, r.reason, 'run `systemctl --user status autopg.service` for details');
271
+ }
272
+ case 'launchd': {
273
+ const r = launchdJobLoaded('dev.automagik.autopg');
274
+ return r.ok
275
+ ? check('supervisor_liveness', 'launchd dev.automagik.autopg loaded', SEVERITY.PASS)
276
+ : check('supervisor_liveness', 'launchd dev.automagik.autopg not loaded', SEVERITY.FAIL, r.reason, 'run `autopg service install` to (re-)register');
277
+ }
278
+ case 'external':
279
+ return check(
280
+ 'supervisor_liveness',
281
+ 'supervisor=external — operator owns supervision; no automated liveness check',
282
+ SEVERITY.PASS,
283
+ );
284
+ default:
285
+ return check('supervisor_liveness', `unknown supervisor: ${admin.supervisor}`, SEVERITY.FAIL);
286
+ }
287
+ }
288
+
289
+ function checkRuntimeJson(admin) {
290
+ const socketDir = admin?.socketDir || resolveSocketDir();
291
+ const runtime = readRuntimeJson(socketDir);
292
+ if (!runtime) {
293
+ return check(
294
+ 'runtime_json',
295
+ `<socketDir>/runtime.json missing or unreadable`,
296
+ SEVERITY.WARN,
297
+ `looked at ${socketDir}/runtime.json — postmaster may not be running, or runtime file was cleaned on graceful shutdown`,
298
+ );
299
+ }
300
+ if ('supervisor' in runtime) {
301
+ return check(
302
+ 'runtime_json',
303
+ 'runtime.json has a `supervisor` field — cohort contract violated',
304
+ SEVERITY.FAIL,
305
+ 'runtime.json (live discovery) MUST NOT carry supervisor — that lives in admin.json only',
306
+ 'remove the supervisor field; cutover G19 spec',
307
+ );
308
+ }
309
+ const live = isLiveRuntime(runtime);
310
+ if (!live) {
311
+ return check(
312
+ 'runtime_json',
313
+ `runtime.json points at non-live autopgPid=${runtime.autopgPid}`,
314
+ SEVERITY.WARN,
315
+ 'process is no longer running; the file should have been cleaned on graceful shutdown',
316
+ 'restart the postmaster to refresh runtime.json',
317
+ );
318
+ }
319
+ return check(
320
+ 'runtime_json',
321
+ `runtime.json present + autopgPid=${runtime.autopgPid} alive`,
322
+ SEVERITY.PASS,
323
+ `port=${runtime.port}, schemaVersion=${runtime.schemaVersion}`,
324
+ );
325
+ }
326
+
327
+ function checkUdsReachable(admin) {
328
+ const socketDir = admin?.socketDir || resolveSocketDir();
329
+ const port = admin?.port || 5432;
330
+ const sockPath = path.join(socketDir, `.s.PGSQL.${port}`);
331
+ if (!fs.existsSync(sockPath)) {
332
+ return Promise.resolve(check(
333
+ 'uds_reachable',
334
+ `Unix socket missing at ${sockPath}`,
335
+ SEVERITY.FAIL,
336
+ 'postmaster has not bound the canonical socket',
337
+ 'check postmaster logs / supervisor liveness',
338
+ ));
339
+ }
340
+ // Probe by connecting; postgres responds even before SSL handshake on a healthy socket.
341
+ return new Promise((resolve) => {
342
+ const sock = new net.Socket();
343
+ let resolved = false;
344
+ const done = (severity, title, detail) => {
345
+ if (resolved) return;
346
+ resolved = true;
347
+ sock.destroy();
348
+ resolve(check('uds_reachable', title, severity, detail));
349
+ };
350
+ sock.setTimeout(500);
351
+ sock.on('connect', () => done(SEVERITY.PASS, `Unix socket accepting at ${sockPath}`));
352
+ sock.on('timeout', () => done(SEVERITY.FAIL, `Unix socket not accepting at ${sockPath}`, 'connect timed out'));
353
+ sock.on('error', (e) => done(SEVERITY.FAIL, `Unix socket probe failed`, e.code || e.message));
354
+ sock.connect(sockPath);
355
+ });
356
+ }
357
+
358
+ function checkTcpReachable(admin) {
359
+ const port = admin?.port || 5432;
360
+ return new Promise((resolve) => {
361
+ const sock = new net.Socket();
362
+ let resolved = false;
363
+ const done = (severity, detail) => {
364
+ if (resolved) return;
365
+ resolved = true;
366
+ sock.destroy();
367
+ resolve(check('tcp_reachable', `TCP localhost:${port}`, severity, detail));
368
+ };
369
+ sock.setTimeout(500);
370
+ sock.on('connect', () => done(SEVERITY.PASS, 'connected'));
371
+ sock.on('timeout', () => done(SEVERITY.WARN, 'timed out (postmaster may not be on this port)'));
372
+ sock.on('error', (e) => done(SEVERITY.WARN, e.code || e.message));
373
+ sock.connect(port, '127.0.0.1');
374
+ });
375
+ }
376
+
377
+ // ─── orchestration ────────────────────────────────────────────────────
378
+
379
+ /**
380
+ * Run all checks and return findings.
381
+ * @returns {Promise<Finding[]>}
382
+ */
383
+ export async function runChecks() {
384
+ const findings = [];
385
+ findings.push(checkVersionNotBlocked());
386
+ findings.push(checkAdminJsonExists());
387
+ findings.push(checkAdminJsonShape());
388
+
389
+ let admin = null;
390
+ try { admin = readAdminJson(); } catch { /* admin_json_shape already reported */ }
391
+
392
+ findings.push(checkSupervisorLiveness(admin));
393
+ findings.push(checkRuntimeJson(admin));
394
+ findings.push(await checkUdsReachable(admin));
395
+ findings.push(await checkTcpReachable(admin));
396
+
397
+ return findings;
398
+ }
399
+
400
+ /**
401
+ * Compute the exit code from a list of findings.
402
+ * @param {Finding[]} findings
403
+ */
404
+ export function exitCodeFor(findings) {
405
+ if (findings.some((f) => f.severity === SEVERITY.FAIL)) return 1;
406
+ if (findings.some((f) => f.severity === SEVERITY.WARN)) return 2;
407
+ return 0;
408
+ }
409
+
410
+ function summary(findings) {
411
+ const pass = findings.filter((f) => f.severity === SEVERITY.PASS).length;
412
+ const warn = findings.filter((f) => f.severity === SEVERITY.WARN).length;
413
+ const fail = findings.filter((f) => f.severity === SEVERITY.FAIL).length;
414
+ return { total: findings.length, pass, warn, fail };
415
+ }
416
+
417
+ function emitHuman(findings, options = {}) {
418
+ const stream = options.stream || process.stdout;
419
+ for (const f of findings) {
420
+ const tag = f.severity === SEVERITY.PASS ? '✓' : f.severity === SEVERITY.WARN ? '⚠' : '✗';
421
+ stream.write(`${tag} [${f.severity}] ${f.id}: ${f.title}\n`);
422
+ if (f.detail) stream.write(` ${f.detail}\n`);
423
+ if (f.hint && f.severity !== SEVERITY.PASS) stream.write(` hint: ${f.hint}\n`);
424
+ }
425
+ const s = summary(findings);
426
+ stream.write(`\n${s.pass} pass, ${s.warn} warn, ${s.fail} fail (${s.total} checks)\n`);
427
+ }
428
+
429
+ function emitJson(findings, options = {}) {
430
+ const stream = options.stream || process.stdout;
431
+ const ok = findings.every((f) => f.severity === SEVERITY.PASS);
432
+ stream.write(`${JSON.stringify({ ok, summary: summary(findings), findings }, null, 2)}\n`);
433
+ }
434
+
435
+ /**
436
+ * Entry point for `pgserve doctor`.
437
+ * @param {string[]} argv
438
+ */
439
+ export async function runDoctor(argv = []) {
440
+ if (argv.includes('--fix')) {
441
+ process.stderr.write(
442
+ 'pgserve doctor: --fix tiered modes are not implemented in v2.4 — read-only V1.\n' +
443
+ ' See SHARED-DESIGN.md §3.2 for the planned Cat 1/2/3 contract; tracked as a follow-up.\n',
444
+ );
445
+ return 64;
446
+ }
447
+
448
+ const findings = await runChecks();
449
+ const json = argv.includes('--json');
450
+ if (json) emitJson(findings);
451
+ else emitHuman(findings);
452
+ return exitCodeFor(findings);
453
+ }
454
+
455
+ // Test-only re-exports — referenced by tests/cli/doctor.test.js so the
456
+ // individual check functions can be exercised in isolation.
457
+ export const __testInternals = Object.freeze({
458
+ checkVersionNotBlocked,
459
+ checkAdminJsonExists,
460
+ checkAdminJsonShape,
461
+ checkRuntimeJson,
462
+ checkSupervisorLiveness,
463
+ exitCodeFor,
464
+ SEVERITY,
465
+ });