pgserve 2.5.0 → 2.6.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/README.md +5 -8
- package/bin/pgserve-wrapper.cjs +48 -0
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +184 -0
- package/scripts/assemble-tarball.sh +191 -0
- package/scripts/build-binary.sh +213 -0
- package/scripts/fetch-postgres-bins.sh +234 -0
- package/scripts/postinstall.cjs +102 -18
- package/scripts/verify-published-artifacts.sh +211 -0
- package/src/cli-install.cjs +229 -3
- package/src/commands/doctor.js +465 -0
- package/src/commands/gc.js +276 -0
- package/src/commands/provision.js +396 -0
- package/src/commands/trust.js +187 -0
- package/src/cosign/trust-list.js +3 -3
- package/src/cosign/trust-store.js +250 -0
- package/src/gc/audit-log.js +150 -0
- package/src/gc/orphan-detection.js +190 -0
- package/src/gc/queries.js +193 -0
- package/src/lib/pg-query.js +145 -0
- package/src/provision/advisory-lock.js +91 -0
- package/src/provision/db-naming.js +130 -0
- package/src/provision/fingerprint.js +144 -0
- package/src/schema/pgserve-meta.js +120 -0
- package/src/security/blocked-versions.js +103 -0
- package/src/upgrade/steps/binary-cache-flush.js +2 -2
package/src/cli-install.cjs
CHANGED
|
@@ -24,9 +24,131 @@
|
|
|
24
24
|
const { spawnSync, execFileSync } = require('node:child_process');
|
|
25
25
|
const crypto = require('node:crypto');
|
|
26
26
|
const fs = require('node:fs');
|
|
27
|
+
const net = require('node:net');
|
|
27
28
|
const os = require('node:os');
|
|
28
29
|
const path = require('node:path');
|
|
29
30
|
|
|
31
|
+
// pgserve v2.6.1 — `pgserve install --help` should print usage + exit 0,
|
|
32
|
+
// not run the install (B2 HIGH from QA-RECIPE-B2.md). Single source of
|
|
33
|
+
// truth for the help text so the autopg + pgserve bin invocations show
|
|
34
|
+
// the same surface (Decision #7 of finalize wish).
|
|
35
|
+
const INSTALL_USAGE = `Usage:
|
|
36
|
+
pgserve install [options]
|
|
37
|
+
autopg install [options]
|
|
38
|
+
|
|
39
|
+
Register pgserve under pm2 (Tier A supervisor) with hardened defaults.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--port <N> TCP port for postgres (default: 5432)
|
|
43
|
+
--data <path> Data directory (default: ~/.autopg/data)
|
|
44
|
+
--socket-dir <path> Unix socket directory (default: $XDG_RUNTIME_DIR/pgserve)
|
|
45
|
+
--ui-port <N> Port for the autopg UI process (default: 8433)
|
|
46
|
+
--ui-host <host> Bind host for the UI (default: 127.0.0.1)
|
|
47
|
+
--no-ui Skip the autopg-ui pm2 process (headless / CI)
|
|
48
|
+
--no-pm2 Skip pm2 registration entirely (Tier B / external supervisor)
|
|
49
|
+
--help, -h Show this help and exit
|
|
50
|
+
|
|
51
|
+
Idempotent: re-running with the same args is a no-op when the existing
|
|
52
|
+
admin.json + pm2 state already matches.
|
|
53
|
+
|
|
54
|
+
See \`pgserve --help\` for the full verb list.
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function printInstallUsage(stream = process.stdout) {
|
|
58
|
+
stream.write(INSTALL_USAGE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// pgserve v2.6.1 — `pgserve install` on a host where the chosen port is
|
|
62
|
+
// already in use must fail BEFORE pm2 / admin.json / data-dir side
|
|
63
|
+
// effects (B3 HIGH from QA-RECIPE-B3.md). Pre-flight bind-test on the
|
|
64
|
+
// canonical loopback the postmaster will use; on EADDRINUSE we fail
|
|
65
|
+
// fast with an operator-readable hint pointing at `--port`.
|
|
66
|
+
//
|
|
67
|
+
// Synchronous wrapper around net.createServer().listen() — uses
|
|
68
|
+
// child_process.spawnSync via a self-bind-then-close so the install
|
|
69
|
+
// flow stays synchronous. Returns null on success; throws an Error
|
|
70
|
+
// with `code='EADDRINUSE'` on collision.
|
|
71
|
+
async function assertPortAvailable(port, host = '127.0.0.1') {
|
|
72
|
+
// Test-only escape hatch. Production never sets this env var. Used by
|
|
73
|
+
// tests that assert on `port: 5432` literal output where the host
|
|
74
|
+
// running the test happens to have 5432 bound (dev workstations
|
|
75
|
+
// running a real pgserve). The port-pre-flight contract itself is
|
|
76
|
+
// covered end-to-end by the B3-collision test which intentionally
|
|
77
|
+
// does NOT set this env var so the pre-flight fires.
|
|
78
|
+
if (process.env.PGSERVE_TEST_SKIP_PORT_PREFLIGHT === '1') return null;
|
|
79
|
+
|
|
80
|
+
// Layer 1: connect-probe BOTH IPv4 (127.0.0.1) and IPv6 (::1).
|
|
81
|
+
// Postgres binds both loopback families on startup; any listener on
|
|
82
|
+
// either is an EADDRINUSE for the postmaster. A pure listen()-bind
|
|
83
|
+
// probe can miss this when the conflicting service was started with
|
|
84
|
+
// SO_REUSEADDR or only-one-family, so connect() is the primary check.
|
|
85
|
+
// If connect() succeeds → something is listening → port is busy.
|
|
86
|
+
for (const probeHost of [host, '::1']) {
|
|
87
|
+
const busy = await probePortListening(port, probeHost);
|
|
88
|
+
if (busy) {
|
|
89
|
+
const e = new Error(
|
|
90
|
+
`pgserve install: port ${port} is already in use on ${probeHost} (something is listening).\n` +
|
|
91
|
+
`Specify a different port with \`pgserve install --port <free>\`,\n` +
|
|
92
|
+
`or stop the process bound to ${port} first and retry.`,
|
|
93
|
+
);
|
|
94
|
+
e.code = 'EADDRINUSE';
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Layer 2: bind-probe to confirm we ourselves can bind without
|
|
100
|
+
// SO_REUSEADDR conflicts. Catches the case where another process
|
|
101
|
+
// bound the same port with SO_REUSEADDR but isn't currently listening
|
|
102
|
+
// (rare but possible for transitional services).
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const server = net.createServer();
|
|
105
|
+
server.once('error', (err) => {
|
|
106
|
+
if (err.code === 'EADDRINUSE') {
|
|
107
|
+
const e = new Error(
|
|
108
|
+
`pgserve install: port ${port} is already in use on ${host} (EADDRINUSE).\n` +
|
|
109
|
+
`Specify a different port with \`pgserve install --port <free>\`,\n` +
|
|
110
|
+
`or stop the process bound to ${port} first and retry.`,
|
|
111
|
+
);
|
|
112
|
+
e.code = 'EADDRINUSE';
|
|
113
|
+
reject(e);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Non-EADDRINUSE errors (e.g. EACCES on privileged ports) — fail
|
|
117
|
+
// closed with the underlying message; no install side effects yet.
|
|
118
|
+
reject(err);
|
|
119
|
+
});
|
|
120
|
+
server.once('listening', () => {
|
|
121
|
+
server.close(() => resolve(null));
|
|
122
|
+
});
|
|
123
|
+
server.listen(port, host);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Promise that resolves true when something is accepting connections at
|
|
128
|
+
// host:port (port is busy), false on ECONNREFUSED (port is free), and
|
|
129
|
+
// rejects on any other error so callers can fail closed.
|
|
130
|
+
function probePortListening(port, host, timeoutMs = 500) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const socket = new net.Socket();
|
|
133
|
+
let settled = false;
|
|
134
|
+
const finish = (value, err) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
try { socket.destroy(); } catch { /* best-effort */ }
|
|
138
|
+
if (err) reject(err); else resolve(value);
|
|
139
|
+
};
|
|
140
|
+
socket.setTimeout(timeoutMs);
|
|
141
|
+
socket.once('connect', () => finish(true));
|
|
142
|
+
socket.once('timeout', () => finish(false)); // treat slow/no-response as free
|
|
143
|
+
socket.once('error', (err) => {
|
|
144
|
+
if (err.code === 'ECONNREFUSED') return finish(false);
|
|
145
|
+
if (err.code === 'EHOSTUNREACH' || err.code === 'EADDRNOTAVAIL') return finish(false); // IPv6 not configured, etc.
|
|
146
|
+
finish(false, err);
|
|
147
|
+
});
|
|
148
|
+
socket.connect(port, host);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
30
152
|
// pgserve singleton (v2.4): the cohort-shared admin-json + socket-dir
|
|
31
153
|
// helpers live in `src/lib/*.js` as ESM modules (project convention: new
|
|
32
154
|
// modules ship as .js / ESM). cli-install.cjs runs under node — which
|
|
@@ -35,14 +157,32 @@ const path = require('node:path');
|
|
|
35
157
|
const _adminJsonModuleP = import('./lib/admin-json.js');
|
|
36
158
|
const _socketDirModuleP = import('./lib/socket-dir.js');
|
|
37
159
|
const _runtimeJsonModuleP = import('./lib/runtime-json.js');
|
|
160
|
+
const _blockedVersionsModuleP = import('./security/blocked-versions.js');
|
|
38
161
|
|
|
39
162
|
async function loadCohortModules() {
|
|
40
|
-
const [adminJson, socketDirMod, runtimeJson] = await Promise.all([
|
|
163
|
+
const [adminJson, socketDirMod, runtimeJson, blockedVersions] = await Promise.all([
|
|
41
164
|
_adminJsonModuleP,
|
|
42
165
|
_socketDirModuleP,
|
|
43
166
|
_runtimeJsonModuleP,
|
|
167
|
+
_blockedVersionsModuleP,
|
|
44
168
|
]);
|
|
45
|
-
return { adminJson, socketDirMod, runtimeJson };
|
|
169
|
+
return { adminJson, socketDirMod, runtimeJson, blockedVersions };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
|
|
173
|
+
//
|
|
174
|
+
// Resolves the running pgserve version from package.json so `assertNotBlocked`
|
|
175
|
+
// can compare against the compile-time BLOCKED_VERSIONS list before any
|
|
176
|
+
// install/update mutation. We intentionally use the package.json shipped
|
|
177
|
+
// with this binary (`require.resolve` from inside cli-install.cjs) rather
|
|
178
|
+
// than the version on the host filesystem — we want to refuse THIS binary
|
|
179
|
+
// running, not a different binary that might happen to live next door.
|
|
180
|
+
function getCurrentVersion() {
|
|
181
|
+
try {
|
|
182
|
+
return require('../package.json').version;
|
|
183
|
+
} catch (_e) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
46
186
|
}
|
|
47
187
|
|
|
48
188
|
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
|
|
@@ -675,7 +815,32 @@ function cmdAuthDispatch(args) {
|
|
|
675
815
|
* wrapper before this module is required (avoids re-resolving here).
|
|
676
816
|
*/
|
|
677
817
|
async function cmdInstall(args, ctx) {
|
|
678
|
-
|
|
818
|
+
// B2 (v2.6.1): `--help` / `-h` MUST short-circuit before any side
|
|
819
|
+
// effects (no pm2 spawn, no admin.json write, no data-dir create).
|
|
820
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
821
|
+
printInstallUsage();
|
|
822
|
+
process.exit(0);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const { adminJson, socketDirMod, blockedVersions } = await loadCohortModules();
|
|
826
|
+
|
|
827
|
+
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
|
|
828
|
+
// Refuse to install if THIS binary's version appears in the compile-time
|
|
829
|
+
// blocklist. Runs first (before any host-touching work) so the operator
|
|
830
|
+
// sees a clear `EBLOCKEDVERSION:` diagnostic with the locked reason +
|
|
831
|
+
// remediation hint, exit code 4 (distinct from generic install failures).
|
|
832
|
+
const currentVersion = getCurrentVersion();
|
|
833
|
+
if (currentVersion) {
|
|
834
|
+
try {
|
|
835
|
+
blockedVersions.assertNotBlocked(currentVersion);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
if (err.code === 'EBLOCKEDVERSION') {
|
|
838
|
+
process.stderr.write(`${err.message}\n`);
|
|
839
|
+
process.exit(4);
|
|
840
|
+
}
|
|
841
|
+
throw err;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
679
844
|
|
|
680
845
|
// pgserve singleton (v2.4): refuse to install if a different supervisor
|
|
681
846
|
// (Tier B systemd-user / launchd) already owns the host. The cohort
|
|
@@ -696,6 +861,36 @@ async function cmdInstall(args, ctx) {
|
|
|
696
861
|
}
|
|
697
862
|
|
|
698
863
|
const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
|
|
864
|
+
|
|
865
|
+
// B3 (v2.6.1): pre-flight bind-test the chosen port BEFORE creating
|
|
866
|
+
// pm2 entries / admin.json / data dir. Without this, an operator on
|
|
867
|
+
// a host where 5432 is already occupied gets pm2 reporting `online`
|
|
868
|
+
// while the postmaster crashes silently — divergence between
|
|
869
|
+
// supervisor state and data-plane state. Fail fast with a clear hint.
|
|
870
|
+
try {
|
|
871
|
+
await assertPortAvailable(port);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
if (err.code === 'EADDRINUSE') {
|
|
874
|
+
process.stderr.write(`${err.message}\n`);
|
|
875
|
+
// Belt-and-suspenders for the QA loop-2/2 finding: in QA's test
|
|
876
|
+
// environment, the synchronous `process.exit(1)` path was
|
|
877
|
+
// observed to NOT terminate the process before the install
|
|
878
|
+
// function continued + resolved the wrapper's promise with
|
|
879
|
+
// undefined, which the wrapper then mapped to `process.exit(0)`.
|
|
880
|
+
// Three guarantees here:
|
|
881
|
+
// 1. process.exitCode = 1 → default exit code becomes 1 even
|
|
882
|
+
// if explicit exit is somehow trapped/delayed
|
|
883
|
+
// 2. process.exit(1) → force termination (preferred path)
|
|
884
|
+
// 3. throw err → if exit is delayed, the async
|
|
885
|
+
// function rejects, wrapper's rejection handler does its
|
|
886
|
+
// own process.exit(1), guaranteeing non-zero exit
|
|
887
|
+
process.exitCode = 1;
|
|
888
|
+
process.exit(1);
|
|
889
|
+
throw err;
|
|
890
|
+
}
|
|
891
|
+
throw err;
|
|
892
|
+
}
|
|
893
|
+
|
|
699
894
|
const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
|
|
700
895
|
|
|
701
896
|
// Set up the canonical socket directory before pm2 launches the
|
|
@@ -1041,6 +1236,12 @@ function dispatch(subcommand, args, ctx) {
|
|
|
1041
1236
|
// dispatcher. dispatch() returns a Promise here; the wrapper
|
|
1042
1237
|
// already handles both numeric and Promise returns.
|
|
1043
1238
|
return import('./commands/uninstall.js').then((mod) => mod.runUninstall());
|
|
1239
|
+
case 'doctor':
|
|
1240
|
+
// pgserve-singleton-no-proxy Group 3: read-only V1. Reports the
|
|
1241
|
+
// active supervisor + postmaster reachability + admin.json /
|
|
1242
|
+
// runtime.json health. --fix tiered modes deferred to a follow-up
|
|
1243
|
+
// (SHARED-DESIGN §3.2).
|
|
1244
|
+
return import('./commands/doctor.js').then((mod) => mod.runDoctor(args).then((code) => process.exit(code)));
|
|
1044
1245
|
case 'status':
|
|
1045
1246
|
return cmdStatus(args);
|
|
1046
1247
|
case 'url':
|
|
@@ -1083,6 +1284,26 @@ function dispatch(subcommand, args, ctx) {
|
|
|
1083
1284
|
// as `uninstall` so the ESM module isn't eagerly loaded.
|
|
1084
1285
|
return import('./commands/verify.js').then((mod) => mod.runVerify(args));
|
|
1085
1286
|
}
|
|
1287
|
+
case 'trust':
|
|
1288
|
+
// pgserve singleton (v2.4) — wish Group 3, second read-only verb.
|
|
1289
|
+
// `pgserve trust add/list/remove` manages the user-extensible cosign
|
|
1290
|
+
// trust store at ~/.pgserve/trust/identities.json. Pure node.
|
|
1291
|
+
// The wrapper handles the numeric-exit-code case; matches the
|
|
1292
|
+
// verify dispatch style so the wrapper, not the verb, owns
|
|
1293
|
+
// process.exit.
|
|
1294
|
+
return import('./commands/trust.js').then((mod) => mod.runTrust(args));
|
|
1295
|
+
case 'gc':
|
|
1296
|
+
// pgserve singleton (v2.4) — wish Group 3, verb 3. `pgserve gc`
|
|
1297
|
+
// sweeps orphaned databases. Default mode is dry-run; --apply
|
|
1298
|
+
// performs the actual DROP. Composes the orphan classifier +
|
|
1299
|
+
// audit-log writer + psql shellout primitives.
|
|
1300
|
+
return import('./commands/gc.js').then((mod) => mod.runGc(args));
|
|
1301
|
+
case 'provision':
|
|
1302
|
+
// pgserve singleton (v2.4) — wish Group 3, verb 4. Idempotent
|
|
1303
|
+
// CREATE ROLE / DATABASE / GRANT + UPSERT pgserve_meta. Honest
|
|
1304
|
+
// idempotency-driven serialization (see provision.js header for
|
|
1305
|
+
// why no advisory lock). Pure node + psql shellout.
|
|
1306
|
+
return import('./commands/provision.js').then((mod) => mod.runProvision(args));
|
|
1086
1307
|
default:
|
|
1087
1308
|
throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
|
|
1088
1309
|
}
|
|
@@ -1091,6 +1312,11 @@ function dispatch(subcommand, args, ctx) {
|
|
|
1091
1312
|
module.exports = {
|
|
1092
1313
|
// Public API for the wrapper.
|
|
1093
1314
|
dispatch,
|
|
1315
|
+
// B2 + B3: surfaced so unit tests can drive helpers without the
|
|
1316
|
+
// full install side-effect chain.
|
|
1317
|
+
printInstallUsage,
|
|
1318
|
+
assertPortAvailable,
|
|
1319
|
+
INSTALL_USAGE,
|
|
1094
1320
|
// Auth surface used by cli-ui.cjs.
|
|
1095
1321
|
verifyAdminPassword,
|
|
1096
1322
|
getAdminFilePath,
|