pgserve 2.2.4 → 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/bin/pgserve-wrapper.cjs +5 -4
- package/bin/postgres-server.js +142 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +2 -2
- package/scripts/test-npx.sh +32 -10
- package/src/cli-install.cjs +147 -77
- package/src/commands/uninstall.js +241 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `autopg uninstall` — Tier A teardown of the rootless pm2 supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Group 1 of the canonical-pgserve-pm2-supervision wish. Idempotent.
|
|
5
|
+
*
|
|
6
|
+
* Removes:
|
|
7
|
+
* - pm2 entry `autopg-server` (the postmaster, registered by `autopg install`)
|
|
8
|
+
* - pm2 entry `autopg-ui` (the console SPA, registered by `autopg install`)
|
|
9
|
+
* - the supervisor record in `~/.autopg/admin.json` (the four supervisor
|
|
10
|
+
* fields managed by `src/lib/admin-json.js` — preserves the scrypt
|
|
11
|
+
* Basic-Auth scheme so a re-install can keep the same admin password).
|
|
12
|
+
*
|
|
13
|
+
* Preserves:
|
|
14
|
+
* - the data directory under `~/.autopg/data/`
|
|
15
|
+
* - `~/.autopg/config.json`
|
|
16
|
+
* - `admin.json` auth fields (scheme/salt/hash/createdAt/rotatedAt/...)
|
|
17
|
+
*
|
|
18
|
+
* Writes one JSONL audit-log entry to `<configDir>/audit.log`.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent contract: running uninstall twice in a row is a no-op on the
|
|
21
|
+
* second call. After uninstall, a subsequent `autopg install` succeeds
|
|
22
|
+
* without a Tier-B-refusal false positive — `assertSupervisor` treats a
|
|
23
|
+
* missing supervisor field as "host is free to install".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ADMIN_FILE_MODE,
|
|
33
|
+
getAdminFilePath,
|
|
34
|
+
readAdminJson,
|
|
35
|
+
} from '../lib/admin-json.js';
|
|
36
|
+
|
|
37
|
+
export const TIER_A_PM2_PROCESSES = Object.freeze(['autopg-server', 'autopg-ui']);
|
|
38
|
+
export const SUPERVISOR_FIELDS = Object.freeze([
|
|
39
|
+
'supervisor',
|
|
40
|
+
'socketDir',
|
|
41
|
+
'port',
|
|
42
|
+
'installedAt',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function getConfigDir() {
|
|
46
|
+
return (
|
|
47
|
+
process.env.AUTOPG_CONFIG_DIR
|
|
48
|
+
|| process.env.PGSERVE_CONFIG_DIR
|
|
49
|
+
|| path.join(os.homedir(), '.autopg')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pm2IsAvailable() {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync('pm2', ['--version'], {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 3000,
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
});
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pm2GetProcess(name) {
|
|
67
|
+
try {
|
|
68
|
+
const out = execFileSync('pm2', ['jlist'], {
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
});
|
|
73
|
+
const list = JSON.parse(out);
|
|
74
|
+
return list.find((p) => p && p.name === name) || null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Always-attempt pm2 delete. `pm2 delete <missing>` exits non-zero but the
|
|
82
|
+
* call is idempotent server-side, so any non-zero exit is treated as
|
|
83
|
+
* "already absent" rather than a hard failure. We snapshot pm2 jlist
|
|
84
|
+
* BEFORE the delete to report whether the entry actually existed.
|
|
85
|
+
*/
|
|
86
|
+
function tearDownPm2(name) {
|
|
87
|
+
if (!pm2IsAvailable()) {
|
|
88
|
+
return { name, removed: false, status: 'pm2-missing' };
|
|
89
|
+
}
|
|
90
|
+
const before = pm2GetProcess(name);
|
|
91
|
+
const res = spawnSync('pm2', ['delete', name], {
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
timeout: 10_000,
|
|
94
|
+
});
|
|
95
|
+
if (res.status === 0) {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
removed: !!before,
|
|
99
|
+
status: before ? 'removed' : 'already-absent',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
removed: false,
|
|
105
|
+
status: 'already-absent',
|
|
106
|
+
exitCode: res.status,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Atomically clear the supervisor fields from admin.json. Preserves all
|
|
112
|
+
* other fields (notably the scrypt Basic-Auth scheme written by
|
|
113
|
+
* `cli-install.cjs`'s `writeAdminFile`). Removes the file entirely if
|
|
114
|
+
* clearing leaves an empty object.
|
|
115
|
+
*
|
|
116
|
+
* Returns { changed, file, hadSupervisor }.
|
|
117
|
+
*/
|
|
118
|
+
function clearSupervisorRecord(configDir) {
|
|
119
|
+
const file = getAdminFilePath(configDir);
|
|
120
|
+
const existing = readAdminJson({ configDir });
|
|
121
|
+
if (!existing) {
|
|
122
|
+
return { changed: false, file, hadSupervisor: false };
|
|
123
|
+
}
|
|
124
|
+
const hadSupervisor = SUPERVISOR_FIELDS.some((k) => k in existing);
|
|
125
|
+
if (!hadSupervisor) {
|
|
126
|
+
return { changed: false, file, hadSupervisor: false };
|
|
127
|
+
}
|
|
128
|
+
const cleared = { ...existing };
|
|
129
|
+
for (const field of SUPERVISOR_FIELDS) {
|
|
130
|
+
delete cleared[field];
|
|
131
|
+
}
|
|
132
|
+
if (Object.keys(cleared).length === 0) {
|
|
133
|
+
try {
|
|
134
|
+
fs.unlinkSync(file);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err && err.code !== 'ENOENT') throw err;
|
|
137
|
+
}
|
|
138
|
+
return { changed: true, file, hadSupervisor: true };
|
|
139
|
+
}
|
|
140
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
141
|
+
const json = `${JSON.stringify(cleared, null, 2)}\n`;
|
|
142
|
+
fs.writeFileSync(tmp, json, { mode: ADMIN_FILE_MODE });
|
|
143
|
+
fs.renameSync(tmp, file);
|
|
144
|
+
fs.chmodSync(file, ADMIN_FILE_MODE);
|
|
145
|
+
return { changed: true, file, hadSupervisor: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendAuditLog(configDir, payload) {
|
|
149
|
+
if (!fs.existsSync(configDir)) {
|
|
150
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
}
|
|
152
|
+
const file = path.join(configDir, 'audit.log');
|
|
153
|
+
const record = {
|
|
154
|
+
ts: new Date().toISOString(),
|
|
155
|
+
event: 'autopg_uninstall',
|
|
156
|
+
...payload,
|
|
157
|
+
};
|
|
158
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function emit(level, msg, silent) {
|
|
162
|
+
if (silent) return;
|
|
163
|
+
const stream = level === 'err' ? process.stderr : process.stdout;
|
|
164
|
+
stream.write(`autopg: ${msg}\n`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Run the uninstall flow. Returns a numeric exit code.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} [opts]
|
|
171
|
+
* @param {string} [opts.configDir] — override the autopg config dir (used
|
|
172
|
+
* by tests; production code should leave this undefined and let env vars
|
|
173
|
+
* resolve).
|
|
174
|
+
* @param {boolean} [opts.silent] — suppress stdout/stderr writes.
|
|
175
|
+
*/
|
|
176
|
+
export function runUninstall(opts = {}) {
|
|
177
|
+
const configDir = opts.configDir || getConfigDir();
|
|
178
|
+
const silent = opts.silent === true;
|
|
179
|
+
|
|
180
|
+
const pm2Available = pm2IsAvailable();
|
|
181
|
+
if (!pm2Available) {
|
|
182
|
+
emit(
|
|
183
|
+
'err',
|
|
184
|
+
'pm2 not found in PATH; skipping pm2 teardown (admin.json supervisor record will still be cleared).',
|
|
185
|
+
silent,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pm2Results = TIER_A_PM2_PROCESSES.map((name) => tearDownPm2(name));
|
|
190
|
+
|
|
191
|
+
let supervisorClear;
|
|
192
|
+
try {
|
|
193
|
+
supervisorClear = clearSupervisorRecord(configDir);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
emit('err', `failed to clear supervisor record in admin.json: ${err.message}`, silent);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
appendAuditLog(configDir, {
|
|
201
|
+
pm2Available,
|
|
202
|
+
pm2: pm2Results,
|
|
203
|
+
supervisorRecord: {
|
|
204
|
+
changed: supervisorClear.changed,
|
|
205
|
+
hadSupervisor: supervisorClear.hadSupervisor,
|
|
206
|
+
file: supervisorClear.file,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
// Audit must never break uninstall.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const removed = pm2Results.filter((r) => r.removed).map((r) => r.name);
|
|
214
|
+
const absent = pm2Results.filter((r) => r.status === 'already-absent').map((r) => r.name);
|
|
215
|
+
|
|
216
|
+
if (removed.length === 0 && !supervisorClear.changed) {
|
|
217
|
+
emit(
|
|
218
|
+
'out',
|
|
219
|
+
`not registered under pm2 (${TIER_A_PM2_PROCESSES.join(', ')}); nothing to uninstall`,
|
|
220
|
+
silent,
|
|
221
|
+
);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (removed.length > 0) {
|
|
226
|
+
emit(
|
|
227
|
+
'out',
|
|
228
|
+
`uninstalled pm2 entries: ${removed.join(', ')} (data dir preserved at ${path.join(configDir, 'data')})`,
|
|
229
|
+
silent,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
if (absent.length > 0 && removed.length > 0) {
|
|
233
|
+
emit('out', `(already absent: ${absent.join(', ')})`, silent);
|
|
234
|
+
}
|
|
235
|
+
if (supervisorClear.changed) {
|
|
236
|
+
emit('out', `cleared supervisor record from ${supervisorClear.file}`, silent);
|
|
237
|
+
} else if (removed.length > 0) {
|
|
238
|
+
emit('out', `no supervisor record to clear in ${supervisorClear.file}`, silent);
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,49 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pgserve
|
|
2
|
+
* pgserve — Embedded PostgreSQL Server (singleton, v2.4+)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Public surface after the `pgserve-singleton-no-proxy` Group 2 deletion:
|
|
5
|
+
* the bun proxy data plane, daemon control socket, libpq protocol
|
|
6
|
+
* rewriting, and SO_PEERCRED handshake are gone. Operators interact with
|
|
7
|
+
* pgserve through the CLI (`bin/pgserve-wrapper.cjs`), the postmaster
|
|
8
|
+
* subcommand (`bin/postgres-server.js postmaster`), and the cohort-shared
|
|
9
|
+
* helpers under `src/lib/`.
|
|
10
|
+
*
|
|
11
|
+
* `PostgresManager` is exported for tests and integrators that want to
|
|
12
|
+
* embed a postgres instance programmatically — it is the same class the
|
|
13
|
+
* postmaster subcommand instantiates.
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
|
-
// Main exports
|
|
9
|
-
export { MultiTenantRouter, startMultiTenantServer } from './router.js';
|
|
10
16
|
export { PostgresManager } from './postgres.js';
|
|
11
|
-
export { SyncManager } from './sync.js';
|
|
12
|
-
export { RestoreManager } from './restore.js';
|
|
13
|
-
export { Dashboard } from './dashboard.js';
|
|
14
|
-
export { StatsCollector } from './stats-collector.js';
|
|
15
|
-
export { StatsDashboard } from './stats-dashboard.js';
|
|
16
|
-
export {
|
|
17
|
-
PgserveDaemon,
|
|
18
|
-
startDaemon,
|
|
19
|
-
stopDaemon,
|
|
20
|
-
resolveControlSocketDir,
|
|
21
|
-
resolveControlSocketPath,
|
|
22
|
-
resolvePidLockPath,
|
|
23
|
-
resolveLibpqCompatPath,
|
|
24
|
-
acquirePidLock,
|
|
25
|
-
isProcessAlive,
|
|
26
|
-
} from './daemon.js';
|
|
27
|
-
export {
|
|
28
|
-
buildDaemonArgs,
|
|
29
|
-
daemonClientOptions,
|
|
30
|
-
ensureDaemon,
|
|
31
|
-
probeDaemon,
|
|
32
|
-
resolveBundledCliBin,
|
|
33
|
-
} from './sdk.js';
|
|
34
|
-
export {
|
|
35
|
-
derivePackageFingerprint,
|
|
36
|
-
deriveScriptFingerprint,
|
|
37
|
-
fingerprintFromCred,
|
|
38
|
-
findNearestPackageJson,
|
|
39
|
-
readPackageName,
|
|
40
|
-
readPersistFlag,
|
|
41
|
-
} from './fingerprint.js';
|
|
42
|
-
export {
|
|
43
|
-
hashToken,
|
|
44
|
-
mintToken,
|
|
45
|
-
parseTcpAuth,
|
|
46
|
-
} from './tokens.js';
|
|
47
|
-
|
|
48
|
-
// Default export
|
|
49
|
-
export { startMultiTenantServer as default } from './router.js';
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.autopg/admin.json` reader, atomic writer, and supervisor-assertion.
|
|
3
|
+
*
|
|
4
|
+
* Cohort-shared module — co-owned with `canonical-pgserve-pm2-supervision`
|
|
5
|
+
* Group 1. Schema for the supervisor record:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* supervisor: "pm2" | "systemd-user" | "launchd" | "external",
|
|
9
|
+
* socketDir: "<absolute path>",
|
|
10
|
+
* port: <integer>,
|
|
11
|
+
* installedAt: "<ISO 8601 timestamp>"
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* The file is shared with the Basic-Auth scrypt password record used by the
|
|
15
|
+
* autopg console UI (`scheme`/`salt`/`hash`/...). This module merges with
|
|
16
|
+
* any pre-existing fields it does not own — `writeAdminJson` is additive,
|
|
17
|
+
* never destructive — so both tenants coexist on the same file.
|
|
18
|
+
*
|
|
19
|
+
* Hard contract — refuses to downgrade supervision authority:
|
|
20
|
+
* - If the existing file records `systemd-user` or `launchd`, refuses to
|
|
21
|
+
* write `pm2` or `external`. Operators must `autopg service uninstall`
|
|
22
|
+
* first to migrate Tier B → Tier A.
|
|
23
|
+
* - `assertSupervisor(expected)` throws when the actual supervisor differs
|
|
24
|
+
* so callers fail fast with a structured remediation hint.
|
|
25
|
+
*
|
|
26
|
+
* Atomic semantics: write to `<file>.tmp.<pid>`, fsync, then
|
|
27
|
+
* `fs.renameSync` to the target. mode 0600 enforced via `fs.chmodSync`
|
|
28
|
+
* after write.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'fs';
|
|
32
|
+
import os from 'os';
|
|
33
|
+
import path from 'path';
|
|
34
|
+
|
|
35
|
+
export const ADMIN_FILE_NAME = 'admin.json';
|
|
36
|
+
export const ADMIN_FILE_MODE = 0o600;
|
|
37
|
+
|
|
38
|
+
export const SUPERVISOR_VALUES = Object.freeze([
|
|
39
|
+
'pm2',
|
|
40
|
+
'systemd-user',
|
|
41
|
+
'launchd',
|
|
42
|
+
'external',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** Supervisors that own the postmaster lifecycle via an OS service unit. */
|
|
46
|
+
const TIER_B_SUPERVISORS = new Set(['systemd-user', 'launchd']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the autopg config directory.
|
|
50
|
+
*
|
|
51
|
+
* Honors `AUTOPG_CONFIG_DIR` (current var) first, then `PGSERVE_CONFIG_DIR`
|
|
52
|
+
* (legacy soft-rename), then `$HOME/.autopg`. Mirrors the precedence in
|
|
53
|
+
* `src/cli-install.cjs` and `src/settings-loader.cjs`.
|
|
54
|
+
*/
|
|
55
|
+
export function getDefaultConfigDir() {
|
|
56
|
+
return (
|
|
57
|
+
process.env.AUTOPG_CONFIG_DIR
|
|
58
|
+
|| process.env.PGSERVE_CONFIG_DIR
|
|
59
|
+
|| path.join(os.homedir(), '.autopg')
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getAdminFilePath(configDir = getDefaultConfigDir()) {
|
|
64
|
+
return path.join(configDir, ADMIN_FILE_NAME);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureConfigDir(configDir) {
|
|
68
|
+
if (!fs.existsSync(configDir)) {
|
|
69
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the admin.json record. Returns the parsed object on success, `null`
|
|
75
|
+
* when the file is missing or unreadable. Never throws — callers treat
|
|
76
|
+
* "missing" and "broken" identically.
|
|
77
|
+
*/
|
|
78
|
+
export function readAdminJson({ configDir = getDefaultConfigDir() } = {}) {
|
|
79
|
+
const file = getAdminFilePath(configDir);
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
return (parsed && typeof parsed === 'object') ? parsed : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isPlainObject(v) {
|
|
95
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateSupervisorRecord(record) {
|
|
99
|
+
if (!isPlainObject(record)) {
|
|
100
|
+
throw new TypeError('admin-json: record must be an object');
|
|
101
|
+
}
|
|
102
|
+
if (!SUPERVISOR_VALUES.includes(record.supervisor)) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
`admin-json: invalid supervisor "${record.supervisor}". `
|
|
105
|
+
+ `Expected one of: ${SUPERVISOR_VALUES.join(', ')}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (typeof record.socketDir !== 'string' || record.socketDir.length === 0) {
|
|
109
|
+
throw new TypeError('admin-json: socketDir must be a non-empty string');
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isInteger(record.port) || record.port < 1 || record.port > 65535) {
|
|
112
|
+
throw new TypeError(`admin-json: port must be an integer in [1, 65535]; got ${record.port}`);
|
|
113
|
+
}
|
|
114
|
+
if (typeof record.installedAt !== 'string' || record.installedAt.length === 0) {
|
|
115
|
+
throw new TypeError('admin-json: installedAt must be a non-empty ISO 8601 string');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Atomic merge-write of the supervisor record.
|
|
121
|
+
*
|
|
122
|
+
* Reads any existing admin.json, layers the supplied supervisor fields on
|
|
123
|
+
* top (preserving unrelated fields like the scrypt Basic-Auth scheme), and
|
|
124
|
+
* writes the result via tmp+rename with mode 0600.
|
|
125
|
+
*
|
|
126
|
+
* Refuses with a structured error when the existing record names a Tier B
|
|
127
|
+
* supervisor (`systemd-user` / `launchd`) and the incoming record would
|
|
128
|
+
* downgrade authority. Use `autopg service uninstall` to migrate Tier B →
|
|
129
|
+
* Tier A explicitly.
|
|
130
|
+
*/
|
|
131
|
+
export function writeAdminJson(record, { configDir = getDefaultConfigDir() } = {}) {
|
|
132
|
+
validateSupervisorRecord(record);
|
|
133
|
+
|
|
134
|
+
ensureConfigDir(configDir);
|
|
135
|
+
const file = getAdminFilePath(configDir);
|
|
136
|
+
const existing = readAdminJson({ configDir }) ?? {};
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
TIER_B_SUPERVISORS.has(existing.supervisor)
|
|
140
|
+
&& existing.supervisor !== record.supervisor
|
|
141
|
+
) {
|
|
142
|
+
const err = new Error(
|
|
143
|
+
`pgserve: refusing to overwrite admin.json — existing supervisor is `
|
|
144
|
+
+ `"${existing.supervisor}" (Tier B); cannot register "${record.supervisor}". `
|
|
145
|
+
+ `Run \`autopg service uninstall\` first to migrate to Tier A.`,
|
|
146
|
+
);
|
|
147
|
+
err.code = 'EADMINSUPERVISORLOCK';
|
|
148
|
+
err.existingSupervisor = existing.supervisor;
|
|
149
|
+
err.requestedSupervisor = record.supervisor;
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const merged = {
|
|
154
|
+
...existing,
|
|
155
|
+
supervisor: record.supervisor,
|
|
156
|
+
socketDir: record.socketDir,
|
|
157
|
+
port: record.port,
|
|
158
|
+
installedAt: record.installedAt,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
162
|
+
const json = `${JSON.stringify(merged, null, 2)}\n`;
|
|
163
|
+
fs.writeFileSync(tmp, json, { mode: ADMIN_FILE_MODE });
|
|
164
|
+
fs.renameSync(tmp, file);
|
|
165
|
+
fs.chmodSync(file, ADMIN_FILE_MODE);
|
|
166
|
+
|
|
167
|
+
return merged;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Throw when the on-disk supervisor differs from `expected`. Returns the
|
|
172
|
+
* record on match. Used by callers that must refuse to operate when the
|
|
173
|
+
* host has already been claimed by a different supervisor — e.g.
|
|
174
|
+
* `pgserve install` (Tier A) refusing to run on a Tier B host.
|
|
175
|
+
*
|
|
176
|
+
* Missing file is NOT an error here — there's nothing to assert against.
|
|
177
|
+
* The caller should treat "no record" as "free to install".
|
|
178
|
+
*/
|
|
179
|
+
export function assertSupervisor(expected, { configDir = getDefaultConfigDir() } = {}) {
|
|
180
|
+
if (!SUPERVISOR_VALUES.includes(expected)) {
|
|
181
|
+
throw new TypeError(
|
|
182
|
+
`admin-json: invalid expected supervisor "${expected}". `
|
|
183
|
+
+ `Expected one of: ${SUPERVISOR_VALUES.join(', ')}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const existing = readAdminJson({ configDir });
|
|
187
|
+
if (!existing || !existing.supervisor) return null;
|
|
188
|
+
if (existing.supervisor !== expected) {
|
|
189
|
+
const err = new Error(
|
|
190
|
+
`pgserve: admin.json supervisor mismatch — expected "${expected}", `
|
|
191
|
+
+ `found "${existing.supervisor}". `
|
|
192
|
+
+ `${TIER_B_SUPERVISORS.has(existing.supervisor)
|
|
193
|
+
? 'Run `autopg service uninstall` to migrate to Tier A.'
|
|
194
|
+
: 'Run `pgserve uninstall` to clear the existing record.'}`,
|
|
195
|
+
);
|
|
196
|
+
err.code = 'EADMINSUPERVISORMISMATCH';
|
|
197
|
+
err.expected = expected;
|
|
198
|
+
err.actual = existing.supervisor;
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
return existing;
|
|
202
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cohort-shared pm2 launch builder for the canonical-pgserve-pm2-supervision
|
|
3
|
+
* wish (Group 1).
|
|
4
|
+
*
|
|
5
|
+
* Exports:
|
|
6
|
+
* PM2_HARDENED_DEFAULTS — baseline hardening values pinned in the wish
|
|
7
|
+
* SERVICE_MEMORY_LIMITS — per-service maxMemoryRestart map
|
|
8
|
+
* buildPm2StartArgs(serviceName, opts) — factory returning the argv passed
|
|
9
|
+
* to `pm2 ...`
|
|
10
|
+
*
|
|
11
|
+
* Per Decision 3 of the wish, the constants stay duplicated across
|
|
12
|
+
* `autopg`, `genie`, and `omni` rather than introducing a shared package —
|
|
13
|
+
* the values are pinned here and copied verbatim into the genie + omni
|
|
14
|
+
* installers.
|
|
15
|
+
*
|
|
16
|
+
* Note on the autopg daemon (`autopg-server`): its own pm2 args are still
|
|
17
|
+
* built inside `src/cli-install.cjs` with a higher restart budget and a
|
|
18
|
+
* larger memory ceiling, because postgres specifics demand more headroom
|
|
19
|
+
* (see PR #57 review notes). The values exported here are the cohort
|
|
20
|
+
* baseline used by the companion `autopg-ui` process and by the cross-repo
|
|
21
|
+
* services (`genie-serve`, `omni-api`, `omni-nats`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
export const PM2_HARDENED_DEFAULTS = Object.freeze({
|
|
27
|
+
maxRestarts: 10,
|
|
28
|
+
restartDelayMs: 5000,
|
|
29
|
+
killTimeoutMs: 20000,
|
|
30
|
+
logDateFormat: 'YYYY-MM-DD HH:mm:ss.SSS',
|
|
31
|
+
// pm2 launches both genie and omni binaries via `#!/usr/bin/env bun`
|
|
32
|
+
// shebangs. `--interpreter bun` triggers pm2's ESM/require crash on
|
|
33
|
+
// top-level await; shebang resolution side-steps the issue.
|
|
34
|
+
// Empirically validated 2026-04-30 (Decision 4 of the wish).
|
|
35
|
+
interpreter: 'none',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const SERVICE_MEMORY_LIMITS = Object.freeze({
|
|
39
|
+
'autopg-server': '2G',
|
|
40
|
+
'autopg-ui': '256M',
|
|
41
|
+
'genie-serve': '2G',
|
|
42
|
+
'omni-api': '2G',
|
|
43
|
+
'omni-nats': '1G',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_MAX_MEMORY = '2G';
|
|
47
|
+
|
|
48
|
+
const VALID_SERVICE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the maxMemoryRestart string for a service. Honors caller override
|
|
52
|
+
* first, then SERVICE_MEMORY_LIMITS, then DEFAULT_MAX_MEMORY.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveMaxMemory(serviceName, override) {
|
|
55
|
+
if (override) return override;
|
|
56
|
+
return SERVICE_MEMORY_LIMITS[serviceName] || DEFAULT_MAX_MEMORY;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the argv to register a long-lived service under pm2.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} serviceName — pm2 process name (also used in default log
|
|
63
|
+
* filenames). Must match `^[A-Za-z][A-Za-z0-9_-]{0,63}$`.
|
|
64
|
+
* @param {object} opts
|
|
65
|
+
* @param {string} opts.scriptPath — script pm2 invokes
|
|
66
|
+
* @param {string} opts.logsDir — directory for `<name>-out.log` /
|
|
67
|
+
* `<name>-error.log`
|
|
68
|
+
* @param {string[]} [opts.scriptArgs] — args passed after `--` to the script
|
|
69
|
+
* @param {string} [opts.maxMemoryRestart] — override the per-service default
|
|
70
|
+
* (e.g. `4G` on big-iron hosts)
|
|
71
|
+
* @param {object} [opts.overrides] — override individual hardening values
|
|
72
|
+
* (`maxRestarts`, `restartDelayMs`, `killTimeoutMs`, `logDateFormat`,
|
|
73
|
+
* `interpreter`)
|
|
74
|
+
* @returns {string[]} args to pass to `pm2`
|
|
75
|
+
*/
|
|
76
|
+
export function buildPm2StartArgs(serviceName, opts) {
|
|
77
|
+
if (typeof serviceName !== 'string' || !VALID_SERVICE_NAME.test(serviceName)) {
|
|
78
|
+
throw new TypeError(
|
|
79
|
+
`pm2-args: serviceName must match /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; got ${JSON.stringify(serviceName)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (!opts || typeof opts !== 'object') {
|
|
83
|
+
throw new TypeError('pm2-args: opts is required');
|
|
84
|
+
}
|
|
85
|
+
if (typeof opts.scriptPath !== 'string' || opts.scriptPath.length === 0) {
|
|
86
|
+
throw new TypeError('pm2-args: opts.scriptPath must be a non-empty string');
|
|
87
|
+
}
|
|
88
|
+
if (typeof opts.logsDir !== 'string' || opts.logsDir.length === 0) {
|
|
89
|
+
throw new TypeError('pm2-args: opts.logsDir must be a non-empty string');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const overrides = opts.overrides || {};
|
|
93
|
+
const maxRestarts = overrides.maxRestarts ?? PM2_HARDENED_DEFAULTS.maxRestarts;
|
|
94
|
+
const restartDelayMs = overrides.restartDelayMs ?? PM2_HARDENED_DEFAULTS.restartDelayMs;
|
|
95
|
+
const killTimeoutMs = overrides.killTimeoutMs ?? PM2_HARDENED_DEFAULTS.killTimeoutMs;
|
|
96
|
+
const logDateFormat = overrides.logDateFormat ?? PM2_HARDENED_DEFAULTS.logDateFormat;
|
|
97
|
+
const interpreter = overrides.interpreter ?? PM2_HARDENED_DEFAULTS.interpreter;
|
|
98
|
+
const maxMemoryRestart = resolveMaxMemory(serviceName, opts.maxMemoryRestart);
|
|
99
|
+
|
|
100
|
+
const argv = [
|
|
101
|
+
'start',
|
|
102
|
+
opts.scriptPath,
|
|
103
|
+
'--name', serviceName,
|
|
104
|
+
'--interpreter', interpreter,
|
|
105
|
+
'--max-restarts', String(maxRestarts),
|
|
106
|
+
'--restart-delay', String(restartDelayMs),
|
|
107
|
+
'--max-memory-restart', maxMemoryRestart,
|
|
108
|
+
'--kill-timeout', String(killTimeoutMs),
|
|
109
|
+
'--log-date-format', logDateFormat,
|
|
110
|
+
'--output', path.join(opts.logsDir, `${serviceName}-out.log`),
|
|
111
|
+
'--error', path.join(opts.logsDir, `${serviceName}-error.log`),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const scriptArgs = Array.isArray(opts.scriptArgs) ? opts.scriptArgs : [];
|
|
115
|
+
if (scriptArgs.length > 0) {
|
|
116
|
+
argv.push('--', ...scriptArgs);
|
|
117
|
+
}
|
|
118
|
+
return argv;
|
|
119
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical pgserve socket-dir resolver.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
|
|
5
|
+
*
|
|
6
|
+
* Postgres backend listens on a Unix socket inside this directory plus TCP
|
|
7
|
+
* 5432. The directory is also where `pgserve` records its `.s.PGSQL.<port>`
|
|
8
|
+
* socket file so off-the-shelf libpq clients connecting via
|
|
9
|
+
* `psql -h <socketDir>` (no `-p`) succeed against the systemd / freedesktop
|
|
10
|
+
* convention path. CI runners and minimal containers without
|
|
11
|
+
* `$XDG_RUNTIME_DIR` get `/tmp/pgserve` as the documented fallback.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
|
|
17
|
+
export const SOCKET_DIR_NAME = 'pgserve';
|
|
18
|
+
export const SOCKET_DIR_MODE = 0o700;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the canonical socket directory.
|
|
22
|
+
*
|
|
23
|
+
* Preferred: `$XDG_RUNTIME_DIR/pgserve` (systemd / freedesktop convention).
|
|
24
|
+
* Fallback: `/tmp/pgserve` (CI runners and minimal containers without XDG).
|
|
25
|
+
*
|
|
26
|
+
* Pure function — does not touch the filesystem. Use `ensureSocketDir()`
|
|
27
|
+
* to create the directory with the correct permissions.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveSocketDir() {
|
|
30
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
31
|
+
const base = xdg && xdg.length > 0 ? xdg : '/tmp';
|
|
32
|
+
return path.join(base, SOCKET_DIR_NAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ensure the socket directory exists with mode 0700 and is writable.
|
|
37
|
+
*
|
|
38
|
+
* Returns the resolved path. Throws if the directory exists but is not a
|
|
39
|
+
* directory, or if creation fails for any reason other than EEXIST.
|
|
40
|
+
*
|
|
41
|
+
* The mode is enforced via fs.chmodSync after creation — `mkdirSync(mode)`
|
|
42
|
+
* is honored only when the directory does not already exist.
|
|
43
|
+
*/
|
|
44
|
+
export function ensureSocketDir(dir = resolveSocketDir()) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true, mode: SOCKET_DIR_MODE });
|
|
46
|
+
fs.chmodSync(dir, SOCKET_DIR_MODE);
|
|
47
|
+
|
|
48
|
+
const stat = fs.statSync(dir);
|
|
49
|
+
if (!stat.isDirectory()) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`pgserve: socket dir path exists but is not a directory: ${dir}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate writability by touching a sentinel file. Avoids surfacing the
|
|
56
|
+
// real-world failure ("postgres can't bind socket") at the postmaster
|
|
57
|
+
// boot step where the diagnostic is much harder to trace.
|
|
58
|
+
const probe = path.join(dir, `.writable-${process.pid}-${Date.now()}`);
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(probe, '');
|
|
61
|
+
fs.unlinkSync(probe);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`pgserve: socket dir not writable (${dir}): ${err.message}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return dir;
|
|
69
|
+
}
|