pgserve 2.3.0 → 2.5.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 +9 -4
- package/bin/postgres-server.js +170 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +3 -2
- package/scripts/audit-redaction-lint.js +349 -0
- package/scripts/test-npx.sh +32 -10
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +340 -100
- package/src/commands/uninstall.js +241 -0
- package/src/commands/verify.js +360 -0
- package/src/cosign/cache-token.js +328 -0
- package/src/cosign/schema.js +97 -0
- package/src/cosign/trust-list.js +81 -0
- package/src/cosign/verify-binary.js +277 -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/runtime-json.js +181 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
- 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,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cosign keyless OIDC binary verifier.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* Per Decision P5 (locked), we shell out to the `cosign` CLI rather than
|
|
7
|
+
* vendoring sigstore-rs. Verification model:
|
|
8
|
+
*
|
|
9
|
+
* - Each binary distributed by an automagik release ships with a
|
|
10
|
+
* paired Sigstore bundle file at `<binary>.bundle` (modern keyless
|
|
11
|
+
* OIDC attestation, JSON-encoded).
|
|
12
|
+
* - We invoke `cosign verify-blob --bundle <bundle>
|
|
13
|
+
* --certificate-identity-regexp <re> --certificate-oidc-issuer <iss>
|
|
14
|
+
* <binary>` — once per entry in the trust list.
|
|
15
|
+
* - First entry that exits 0 wins. We return its identity + tier.
|
|
16
|
+
* - If every entry fails, we surface the most-specific cosign diagnostic
|
|
17
|
+
* (the last exit) so the operator knows which trust root we tried.
|
|
18
|
+
*
|
|
19
|
+
* Resolution of the cosign executable:
|
|
20
|
+
* 1. Caller-supplied `cosignBin` (used by tests + `--cosign-bin` flag)
|
|
21
|
+
* 2. `cosign` on `$PATH`
|
|
22
|
+
* 3. Cached static binary at `~/.pgserve/bin/cosign`
|
|
23
|
+
* 4. Download official static binary from sigstore release (offline by
|
|
24
|
+
* default — only triggered when `allowFetch: true` is passed)
|
|
25
|
+
*
|
|
26
|
+
* Returns a tagged union:
|
|
27
|
+
* { ok: true, identity, tier, sha256, cosignBin, bundle }
|
|
28
|
+
* { ok: false, reason, detail?, identityChain? }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import os from 'node:os';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
import { TRUSTED_IDENTITIES } from './trust-list.js';
|
|
38
|
+
|
|
39
|
+
export const COSIGN_TIER = 'cosign_signed';
|
|
40
|
+
export const COSIGN_BIN_DIR = path.join(os.homedir(), '.pgserve', 'bin');
|
|
41
|
+
export const COSIGN_BIN_FILE = path.join(COSIGN_BIN_DIR, process.platform === 'win32' ? 'cosign.exe' : 'cosign');
|
|
42
|
+
|
|
43
|
+
const COSIGN_RELEASE_VERSION = 'v2.2.4';
|
|
44
|
+
const COSIGN_RELEASE_BASE = `https://github.com/sigstore/cosign/releases/download/${COSIGN_RELEASE_VERSION}`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute sha256 of a file. Returns lowercase hex.
|
|
48
|
+
*/
|
|
49
|
+
export function sha256File(filePath) {
|
|
50
|
+
const hash = crypto.createHash('sha256');
|
|
51
|
+
const buf = fs.readFileSync(filePath);
|
|
52
|
+
hash.update(buf);
|
|
53
|
+
return hash.digest('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the sidecar bundle path for a given binary path. Convention:
|
|
58
|
+
* `<binary>.bundle`. Operators that publish detached `.sig` + `.cert` can
|
|
59
|
+
* regenerate a bundle with `cosign sign-blob --bundle <path>.bundle`; we
|
|
60
|
+
* intentionally only support the bundle form to keep the surface narrow.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveBundlePath(binaryPath) {
|
|
63
|
+
return `${binaryPath}.bundle`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve which `cosign` executable to use. Returns a string path or null
|
|
68
|
+
* if no cosign is available and `allowFetch: false`.
|
|
69
|
+
*
|
|
70
|
+
* PATH probing is implemented in-process (rather than shelling out to
|
|
71
|
+
* `which` / `where`) so the resolver works inside test harnesses that
|
|
72
|
+
* scrub PATH down to a single stub directory.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveCosignBin({ cosignBin, allowFetch = false } = {}) {
|
|
75
|
+
if (cosignBin && fs.existsSync(cosignBin)) return cosignBin;
|
|
76
|
+
|
|
77
|
+
const fromPath = lookupOnPath(process.platform === 'win32' ? 'cosign.exe' : 'cosign');
|
|
78
|
+
if (fromPath) return fromPath;
|
|
79
|
+
|
|
80
|
+
// Cached static binary.
|
|
81
|
+
if (fs.existsSync(COSIGN_BIN_FILE)) return COSIGN_BIN_FILE;
|
|
82
|
+
|
|
83
|
+
if (!allowFetch) return null;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return fetchCosignBin();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function lookupOnPath(name) {
|
|
93
|
+
const PATH = process.env.PATH || '';
|
|
94
|
+
if (!PATH) return null;
|
|
95
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
96
|
+
const dirs = PATH.split(sep).filter(Boolean);
|
|
97
|
+
for (const dir of dirs) {
|
|
98
|
+
const candidate = path.join(dir, name);
|
|
99
|
+
try {
|
|
100
|
+
const stat = fs.statSync(candidate);
|
|
101
|
+
if (stat.isFile()) {
|
|
102
|
+
// On POSIX, ensure it's executable; on Windows, file existence is enough.
|
|
103
|
+
if (process.platform === 'win32') return candidate;
|
|
104
|
+
if ((stat.mode & 0o111) !== 0) return candidate;
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// missing or stat error — try next dir
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Download the official cosign static binary into `~/.pgserve/bin/cosign`.
|
|
115
|
+
* Synchronous — used by `pgserve verify` when no cosign is on PATH and the
|
|
116
|
+
* operator opts into the fetch (see Decision P5: "if cosign not on PATH,
|
|
117
|
+
* pgserve install shells out to a downloader to fetch the official static
|
|
118
|
+
* binary into ~/.pgserve/bin/cosign").
|
|
119
|
+
*
|
|
120
|
+
* Network-dependent. Throws on failure. Tests stub via `cosignBin` instead
|
|
121
|
+
* of this code path.
|
|
122
|
+
*/
|
|
123
|
+
export function fetchCosignBin({
|
|
124
|
+
releaseBase = COSIGN_RELEASE_BASE,
|
|
125
|
+
targetFile = COSIGN_BIN_FILE,
|
|
126
|
+
targetDir = COSIGN_BIN_DIR,
|
|
127
|
+
} = {}) {
|
|
128
|
+
fs.mkdirSync(targetDir, { recursive: true, mode: 0o755 });
|
|
129
|
+
const assetName = pickCosignAssetName();
|
|
130
|
+
const url = `${releaseBase}/${assetName}`;
|
|
131
|
+
const tmp = `${targetFile}.tmp.${process.pid}`;
|
|
132
|
+
downloadToFileSync(url, tmp);
|
|
133
|
+
fs.chmodSync(tmp, 0o755);
|
|
134
|
+
fs.renameSync(tmp, targetFile);
|
|
135
|
+
return targetFile;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pickCosignAssetName() {
|
|
139
|
+
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
|
140
|
+
if (process.platform === 'darwin') return `cosign-darwin-${arch}`;
|
|
141
|
+
if (process.platform === 'win32') return `cosign-windows-${arch}.exe`;
|
|
142
|
+
return `cosign-linux-${arch}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function downloadToFileSync(url, destPath) {
|
|
146
|
+
// Node has no built-in synchronous HTTP. We fall back to spawning curl
|
|
147
|
+
// since this only runs once per host (cached afterward) and curl is
|
|
148
|
+
// ubiquitous on the supported platforms.
|
|
149
|
+
const curl = spawnSync('curl', ['-fsSL', '-o', destPath, url], {
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
});
|
|
152
|
+
if (curl.status === 0) return;
|
|
153
|
+
// Fallback: try wget.
|
|
154
|
+
const wget = spawnSync('wget', ['-qO', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
155
|
+
if (wget.status === 0) return;
|
|
156
|
+
throw new Error(
|
|
157
|
+
`cosign-verify: failed to download cosign from ${url} (curl exit ${curl.status}, wget exit ${wget?.status ?? 'n/a'})`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Verify a binary with cosign.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} binaryPath
|
|
165
|
+
* @param {object} [options]
|
|
166
|
+
* @param {string} [options.cosignBin] override cosign executable
|
|
167
|
+
* @param {string} [options.bundlePath] override bundle sidecar path
|
|
168
|
+
* @param {Array} [options.trustList] override hardcoded TRUSTED_IDENTITIES
|
|
169
|
+
* @param {boolean}[options.allowFetch] allow fetching the cosign binary if missing
|
|
170
|
+
* @returns {{ ok: true, identity, tier, sha256, cosignBin, bundle, identityChain }
|
|
171
|
+
* | { ok: false, reason, detail?, identityChain? }}
|
|
172
|
+
*/
|
|
173
|
+
export function verifyBinary(binaryPath, options = {}) {
|
|
174
|
+
if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
|
|
175
|
+
return { ok: false, reason: 'invalid-args', detail: 'binaryPath required' };
|
|
176
|
+
}
|
|
177
|
+
if (!fs.existsSync(binaryPath)) {
|
|
178
|
+
return { ok: false, reason: 'binary-missing', detail: binaryPath };
|
|
179
|
+
}
|
|
180
|
+
let stat;
|
|
181
|
+
try {
|
|
182
|
+
stat = fs.statSync(binaryPath);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return { ok: false, reason: 'binary-unreadable', detail: err.message };
|
|
185
|
+
}
|
|
186
|
+
if (!stat.isFile()) {
|
|
187
|
+
return { ok: false, reason: 'binary-not-a-file', detail: binaryPath };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bundlePath = options.bundlePath || resolveBundlePath(binaryPath);
|
|
191
|
+
if (!fs.existsSync(bundlePath)) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: 'bundle-missing',
|
|
195
|
+
detail: `expected sigstore bundle at ${bundlePath} (run \`cosign sign-blob --bundle ${bundlePath} ${binaryPath}\` to attest)`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const trustList = options.trustList || TRUSTED_IDENTITIES;
|
|
200
|
+
if (!Array.isArray(trustList) || trustList.length === 0) {
|
|
201
|
+
return { ok: false, reason: 'empty-trust-list' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const cosignBin = resolveCosignBin({
|
|
205
|
+
cosignBin: options.cosignBin,
|
|
206
|
+
allowFetch: options.allowFetch === true,
|
|
207
|
+
});
|
|
208
|
+
if (!cosignBin) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: 'cosign-missing',
|
|
212
|
+
detail:
|
|
213
|
+
'no `cosign` binary on $PATH or in ~/.pgserve/bin/cosign — install cosign or rerun with --allow-fetch',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const sha256 = sha256File(binaryPath);
|
|
218
|
+
const identityChain = [];
|
|
219
|
+
let lastFailure = null;
|
|
220
|
+
|
|
221
|
+
for (const identity of trustList) {
|
|
222
|
+
if (!identity || !identity.id || !identity.issuer || !identity.identityRegexp) {
|
|
223
|
+
identityChain.push({ id: identity?.id || '<malformed>', status: 'skipped' });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const result = invokeCosign({
|
|
227
|
+
cosignBin,
|
|
228
|
+
bundlePath,
|
|
229
|
+
binaryPath,
|
|
230
|
+
identity,
|
|
231
|
+
});
|
|
232
|
+
if (result.ok) {
|
|
233
|
+
identityChain.push({ id: identity.id, status: 'matched' });
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
identity: identity.id,
|
|
237
|
+
publisher: identity.publisher,
|
|
238
|
+
tier: COSIGN_TIER,
|
|
239
|
+
sha256,
|
|
240
|
+
cosignBin,
|
|
241
|
+
bundle: bundlePath,
|
|
242
|
+
identityChain,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
identityChain.push({ id: identity.id, status: 'rejected', exitCode: result.exitCode });
|
|
246
|
+
lastFailure = result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: 'no-trust-match',
|
|
252
|
+
detail: lastFailure?.stderr || 'cosign rejected the binary against every trust root',
|
|
253
|
+
identityChain,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function invokeCosign({ cosignBin, bundlePath, binaryPath, identity }) {
|
|
258
|
+
const args = [
|
|
259
|
+
'verify-blob',
|
|
260
|
+
'--bundle', bundlePath,
|
|
261
|
+
'--certificate-identity-regexp', identity.identityRegexp,
|
|
262
|
+
'--certificate-oidc-issuer', identity.issuer,
|
|
263
|
+
binaryPath,
|
|
264
|
+
];
|
|
265
|
+
const proc = spawnSync(cosignBin, args, {
|
|
266
|
+
encoding: 'utf8',
|
|
267
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
if (proc.status === 0) {
|
|
270
|
+
return { ok: true, stdout: proc.stdout || '' };
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
exitCode: typeof proc.status === 'number' ? proc.status : -1,
|
|
275
|
+
stderr: (proc.stderr || proc.stdout || '').trim().slice(0, 4096),
|
|
276
|
+
};
|
|
277
|
+
}
|
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
|
+
}
|