pgserve 2.4.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 +4 -0
- package/bin/postgres-server.js +28 -0
- package/package.json +2 -1
- package/scripts/audit-redaction-lint.js +349 -0
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +195 -25
- 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/lib/runtime-json.js +181 -0
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<socketDir>/runtime.json` — runtime discovery file owned by the
|
|
3
|
+
* `autopg serve` postmaster wrapper (cutover wish G19).
|
|
4
|
+
*
|
|
5
|
+
* Schema:
|
|
6
|
+
* {
|
|
7
|
+
* socketDir: "<absolute path>",
|
|
8
|
+
* port: <integer>, // postgres TCP port
|
|
9
|
+
* pid: <integer>, // postmaster pid
|
|
10
|
+
* autopgPid: <integer>, // `autopg serve` wrapper pid
|
|
11
|
+
* schemaVersion: 1
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Cohort contract — there is **no `supervisor` field**. The supervisor
|
|
15
|
+
* (pm2 / systemd-user / launchd / external) is recorded once at install
|
|
16
|
+
* time in `~/.autopg/admin.json`. Mixing the two creates a synchronization
|
|
17
|
+
* problem (which file is authoritative when the postmaster restarts under
|
|
18
|
+
* a new pid?). `writeRuntimeJson()` rejects records carrying a `supervisor`
|
|
19
|
+
* key so the contract can't drift via a future copy-paste.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle:
|
|
22
|
+
* - `writeRuntimeJson()` after the postmaster greets healthy.
|
|
23
|
+
* - `clearRuntimeJson()` on graceful shutdown (SIGTERM / SIGINT).
|
|
24
|
+
* - On crash the file is left in place. Consumers detect a stale record
|
|
25
|
+
* via `process.kill(record.autopgPid, 0)` (no-signal probe).
|
|
26
|
+
*
|
|
27
|
+
* Atomic semantics: write to `<file>.tmp.<pid>`, then `fs.renameSync()`.
|
|
28
|
+
* Mode 0644 so unprivileged peers can `cat <socketDir>/runtime.json`
|
|
29
|
+
* without sudo — the file carries no secrets, only public discovery info.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'fs';
|
|
33
|
+
import path from 'path';
|
|
34
|
+
|
|
35
|
+
export const RUNTIME_FILE_NAME = 'runtime.json';
|
|
36
|
+
export const RUNTIME_FILE_MODE = 0o644;
|
|
37
|
+
export const RUNTIME_SCHEMA_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
export function getRuntimeFilePath(socketDir) {
|
|
40
|
+
if (typeof socketDir !== 'string' || socketDir.length === 0) {
|
|
41
|
+
throw new TypeError('runtime-json: socketDir must be a non-empty string');
|
|
42
|
+
}
|
|
43
|
+
return path.join(socketDir, RUNTIME_FILE_NAME);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPlainObject(v) {
|
|
47
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validateRecord(record) {
|
|
51
|
+
if (!isPlainObject(record)) {
|
|
52
|
+
throw new TypeError('runtime-json: record must be an object');
|
|
53
|
+
}
|
|
54
|
+
if (typeof record.socketDir !== 'string' || record.socketDir.length === 0) {
|
|
55
|
+
throw new TypeError('runtime-json: socketDir must be a non-empty string');
|
|
56
|
+
}
|
|
57
|
+
if (!Number.isInteger(record.port) || record.port < 1 || record.port > 65535) {
|
|
58
|
+
throw new TypeError(`runtime-json: port must be an integer in [1, 65535]; got ${record.port}`);
|
|
59
|
+
}
|
|
60
|
+
if (!Number.isInteger(record.pid) || record.pid < 1) {
|
|
61
|
+
throw new TypeError(`runtime-json: pid must be a positive integer; got ${record.pid}`);
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(record.autopgPid) || record.autopgPid < 1) {
|
|
64
|
+
throw new TypeError(`runtime-json: autopgPid must be a positive integer; got ${record.autopgPid}`);
|
|
65
|
+
}
|
|
66
|
+
if (Object.prototype.hasOwnProperty.call(record, 'supervisor')) {
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
'runtime-json: refusing to write `supervisor` into runtime.json — that field '
|
|
69
|
+
+ 'lives only in `~/.autopg/admin.json` (cohort contract).',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read `<socketDir>/runtime.json`. Returns the parsed object on success,
|
|
76
|
+
* `null` when the file is missing or unreadable. Never throws — callers
|
|
77
|
+
* treat "missing" and "broken" identically and fall back to admin.json.
|
|
78
|
+
*/
|
|
79
|
+
export function readRuntimeJson(socketDir) {
|
|
80
|
+
let file;
|
|
81
|
+
try {
|
|
82
|
+
file = getRuntimeFilePath(socketDir);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Atomic write of the runtime discovery record. Validates shape, refuses
|
|
102
|
+
* a `supervisor` key (the cohort contract — that field belongs in
|
|
103
|
+
* admin.json), ensures the parent directory exists, and stamps
|
|
104
|
+
* `schemaVersion: 1` if the caller didn't.
|
|
105
|
+
*/
|
|
106
|
+
export function writeRuntimeJson(input = {}) {
|
|
107
|
+
if (!isPlainObject(input)) {
|
|
108
|
+
throw new TypeError('runtime-json: writeRuntimeJson expects an object argument');
|
|
109
|
+
}
|
|
110
|
+
// Reject `supervisor` from the input directly — destructuring would
|
|
111
|
+
// silently drop it and that's a contract failure we want to surface.
|
|
112
|
+
if (Object.prototype.hasOwnProperty.call(input, 'supervisor')) {
|
|
113
|
+
throw new TypeError(
|
|
114
|
+
'runtime-json: refusing to write `supervisor` into runtime.json — that field '
|
|
115
|
+
+ 'lives only in `~/.autopg/admin.json` (cohort contract).',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const { socketDir, port, pid, autopgPid, schemaVersion = RUNTIME_SCHEMA_VERSION } = input;
|
|
119
|
+
const record = { socketDir, port, pid, autopgPid, schemaVersion };
|
|
120
|
+
validateRecord(record);
|
|
121
|
+
|
|
122
|
+
if (!fs.existsSync(socketDir)) {
|
|
123
|
+
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const file = getRuntimeFilePath(socketDir);
|
|
127
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
128
|
+
const json = `${JSON.stringify(record, null, 2)}\n`;
|
|
129
|
+
fs.writeFileSync(tmp, json, { mode: RUNTIME_FILE_MODE });
|
|
130
|
+
fs.renameSync(tmp, file);
|
|
131
|
+
fs.chmodSync(file, RUNTIME_FILE_MODE);
|
|
132
|
+
return record;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort delete of `<socketDir>/runtime.json`. Used during graceful
|
|
137
|
+
* shutdown so consumers immediately observe "no live postmaster" instead
|
|
138
|
+
* of seeing a stale-pid record they have to probe with `process.kill()`.
|
|
139
|
+
*
|
|
140
|
+
* Returns `true` when the file was removed, `false` when it was already
|
|
141
|
+
* gone or removal failed. Never throws — graceful shutdown must not
|
|
142
|
+
* regress because of a permission glitch on the runtime file.
|
|
143
|
+
*/
|
|
144
|
+
export function clearRuntimeJson(socketDir) {
|
|
145
|
+
let file;
|
|
146
|
+
try {
|
|
147
|
+
file = getRuntimeFilePath(socketDir);
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
fs.unlinkSync(file);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Returns true when the runtime record points at a process that's alive
|
|
161
|
+
* on this host. `process.kill(pid, 0)` is a no-signal probe — it raises
|
|
162
|
+
* ESRCH when the pid is gone and EPERM when we can't signal a foreign
|
|
163
|
+
* uid (still alive, just not ours). Treat EPERM as "alive" so cross-uid
|
|
164
|
+
* supervisors (e.g. an operator probing a system-installed pgserve)
|
|
165
|
+
* don't false-negative.
|
|
166
|
+
*/
|
|
167
|
+
export function isLiveRuntime(record) {
|
|
168
|
+
if (!isPlainObject(record)) return false;
|
|
169
|
+
// process.kill(pid, 0) accepts a process group sentinel for pid <= 0
|
|
170
|
+
// (pid 0 = caller's group, pid -1 = every signalable process). Neither
|
|
171
|
+
// is a meaningful "live postmaster" answer, so reject anything below 1
|
|
172
|
+
// before we touch the syscall.
|
|
173
|
+
const pid = record.autopgPid;
|
|
174
|
+
if (!Number.isInteger(pid) || pid < 1) return false;
|
|
175
|
+
try {
|
|
176
|
+
process.kill(pid, 0);
|
|
177
|
+
return true;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return err && err.code === 'EPERM';
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/upgrade/index.js
CHANGED
|
@@ -20,11 +20,16 @@ import * as plpgsqlResolve from './steps/plpgsql-resolve.js';
|
|
|
20
20
|
import * as envRefresh from './steps/env-refresh.js';
|
|
21
21
|
import * as consumerSignal from './steps/consumer-signal.js';
|
|
22
22
|
import * as healthValidate from './steps/health-validate.js';
|
|
23
|
+
import * as cosignMetaMigration from './steps/cosign-meta-migration.js';
|
|
23
24
|
|
|
24
25
|
export const STEPS = [
|
|
25
26
|
{ name: 'port-reconcile', impl: portReconcile },
|
|
26
27
|
{ name: 'binary-cache-flush', impl: binaryCacheFlush },
|
|
27
28
|
{ name: 'plpgsql-resolve', impl: plpgsqlResolve },
|
|
29
|
+
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
30
|
+
// Adds the additive `verified_*` columns to `pgserve_meta`. Runs after
|
|
31
|
+
// plpgsql-resolve so the extension is available; idempotent per-DB.
|
|
32
|
+
{ name: 'cosign-meta-migration', impl: cosignMetaMigration },
|
|
28
33
|
{ name: 'env-refresh', impl: envRefresh },
|
|
29
34
|
{ name: 'consumer-signal', impl: consumerSignal },
|
|
30
35
|
{ name: 'health-validate', impl: healthValidate },
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step — pgserve_meta cosign columns (additive).
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
|
|
5
|
+
*
|
|
6
|
+
* Adds `verified_at`, `verified_identity`, `verified_tier` to every
|
|
7
|
+
* `pgserve_meta` table the upgrade step finds. The schema delta is
|
|
8
|
+
* additive (Decision P4) — pre-cosign rows continue to work, columns are
|
|
9
|
+
* NULL until Group 3's `pgserve provision` writes them.
|
|
10
|
+
*
|
|
11
|
+
* Runs idempotently: `ADD COLUMN IF NOT EXISTS` plus a guarded DO-block
|
|
12
|
+
* for the CHECK constraint. Re-running on an already-migrated host is a
|
|
13
|
+
* no-op. If `pgserve_meta` does not exist (fresh install before G3 has
|
|
14
|
+
* provisioned anything) the step is a SKIP.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
|
|
19
|
+
import { getMigrationStatements } from '../../cosign/schema.js';
|
|
20
|
+
|
|
21
|
+
export const name = 'cosign-meta-migration';
|
|
22
|
+
const CANONICAL_PORT = 5432;
|
|
23
|
+
const SYSTEM_DBS = new Set(['template0', 'template1']);
|
|
24
|
+
|
|
25
|
+
// PR #79 fix: previous implementation used execSync with a template string +
|
|
26
|
+
// JSON.stringify(sql). The migration SQL contains `DO $$ ... $$` blocks; bash
|
|
27
|
+
// expands `$$` to its PID, corrupting the SQL before psql sees it. Switch to
|
|
28
|
+
// spawnSync (shell:false) with the SQL fed through stdin — no shell parsing,
|
|
29
|
+
// no expansion, no injection surface.
|
|
30
|
+
function pgQuery({ db, sql, captureStdout = false, port = CANONICAL_PORT }) {
|
|
31
|
+
const env = { ...process.env, PGPASSWORD: process.env.PGPASSWORD || 'postgres' };
|
|
32
|
+
const result = spawnSync(
|
|
33
|
+
'psql',
|
|
34
|
+
['-h', '127.0.0.1', '-p', String(port), '-U', 'postgres', '-d', db, '-At', '-f', '-'],
|
|
35
|
+
{ env, input: sql, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
36
|
+
);
|
|
37
|
+
if (result.status !== 0) {
|
|
38
|
+
const stderr = (result.stderr || Buffer.from('')).toString();
|
|
39
|
+
const err = new Error(`psql exited ${result.status}: ${stderr.trim()}`);
|
|
40
|
+
err.status = result.status;
|
|
41
|
+
err.stderr = stderr;
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
const stdout = (result.stdout || Buffer.from('')).toString();
|
|
45
|
+
return captureStdout ? stdout.trim() : stdout;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listUserDbs() {
|
|
49
|
+
const out = pgQuery({
|
|
50
|
+
db: 'postgres',
|
|
51
|
+
sql: "SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname",
|
|
52
|
+
captureStdout: true,
|
|
53
|
+
});
|
|
54
|
+
return out ? out.split('\n').filter(Boolean).filter((d) => !SYSTEM_DBS.has(d)) : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pgserveMetaExists(db) {
|
|
58
|
+
const out = pgQuery({
|
|
59
|
+
db,
|
|
60
|
+
sql: "SELECT to_regclass('public.pgserve_meta') IS NOT NULL",
|
|
61
|
+
captureStdout: true,
|
|
62
|
+
});
|
|
63
|
+
return out === 't' || out === 'true';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function plan() {
|
|
67
|
+
let dbs;
|
|
68
|
+
try {
|
|
69
|
+
dbs = listUserDbs();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return `cannot enumerate DBs: ${err.message}`;
|
|
72
|
+
}
|
|
73
|
+
if (dbs.length === 0) return 'no user DBs — skip';
|
|
74
|
+
const targets = [];
|
|
75
|
+
for (const db of dbs) {
|
|
76
|
+
try {
|
|
77
|
+
if (pgserveMetaExists(db)) targets.push(db);
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip silently — DB might be unreachable, listed but not connectable.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (targets.length === 0) return 'no DB hosts pgserve_meta yet — skip';
|
|
83
|
+
return `would apply additive cosign columns to pgserve_meta in: ${targets.join(', ')}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function execute({ log, warn }) {
|
|
87
|
+
let dbs;
|
|
88
|
+
try {
|
|
89
|
+
dbs = listUserDbs();
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return { status: 'FAIL', detail: `cannot enumerate DBs: ${err.message}` };
|
|
92
|
+
}
|
|
93
|
+
if (dbs.length === 0) return { status: 'SKIP', detail: 'no user DBs to migrate' };
|
|
94
|
+
|
|
95
|
+
const statements = getMigrationStatements();
|
|
96
|
+
let migrated = 0;
|
|
97
|
+
let skipped = 0;
|
|
98
|
+
for (const db of dbs) {
|
|
99
|
+
let exists;
|
|
100
|
+
try {
|
|
101
|
+
exists = pgserveMetaExists(db);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
warn(`[cosign-meta-migration] ${db}: cannot probe pgserve_meta — ${err.message}`);
|
|
104
|
+
skipped++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!exists) {
|
|
108
|
+
skipped++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
for (const sql of statements) {
|
|
113
|
+
pgQuery({ db, sql });
|
|
114
|
+
}
|
|
115
|
+
log(`[cosign-meta-migration] ${db}: applied ${statements.length} idempotent statement(s)`);
|
|
116
|
+
migrated++;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
warn(`[cosign-meta-migration] ${db}: failed — ${err.message}`);
|
|
119
|
+
skipped++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { status: 'OK', detail: `migrated ${migrated} DB(s), skipped ${skipped}` };
|
|
123
|
+
}
|