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,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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/postgres.js
CHANGED
|
@@ -548,6 +548,15 @@ export class PostgresManager extends EventEmitter {
|
|
|
548
548
|
this.binaries = null;
|
|
549
549
|
this.creatingDatabases = new Map(); // Track in-progress creations
|
|
550
550
|
this.socketDir = null; // Unix socket directory for faster local connections
|
|
551
|
+
// pgserve singleton (v2.4): callers that own the socket directory (e.g.
|
|
552
|
+
// `pgserve postmaster` invoked under pm2 or a systemd-user unit) pass an
|
|
553
|
+
// explicit, externally-managed path so libpq peers connecting via
|
|
554
|
+
// `psql -h $XDG_RUNTIME_DIR/pgserve` reach a stable, well-known socket.
|
|
555
|
+
// When unset we fall back to the legacy per-pid `os.tmpdir()` path so
|
|
556
|
+
// foreground / daemon / cluster modes keep working unchanged.
|
|
557
|
+
this.explicitSocketDir = typeof options.socketDir === 'string' && options.socketDir.length > 0
|
|
558
|
+
? options.socketDir
|
|
559
|
+
: null;
|
|
551
560
|
this.adminPool = null; // Connection pool for database admin operations
|
|
552
561
|
this.useRam = options.useRam || false; // Use /dev/shm for true RAM storage (Linux only)
|
|
553
562
|
this.isTrueRam = false; // Tracks if we're actually using RAM storage
|
|
@@ -634,9 +643,24 @@ export class PostgresManager extends EventEmitter {
|
|
|
634
643
|
|
|
635
644
|
// Create Unix socket directory (Linux/macOS only, Windows uses TCP)
|
|
636
645
|
if (os.platform() !== 'win32') {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
646
|
+
if (this.explicitSocketDir) {
|
|
647
|
+
// pgserve singleton (v2.4): operator-supplied socket dir is created
|
|
648
|
+
// and chmoded by the install path (`pgserve install`); we only
|
|
649
|
+
// refuse to start when it doesn't exist so the operator's intent
|
|
650
|
+
// surfaces cleanly instead of postgres bailing on bind() with a
|
|
651
|
+
// cryptic libpq error.
|
|
652
|
+
if (!fs.existsSync(this.explicitSocketDir)) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`pgserve: socketDir does not exist: ${this.explicitSocketDir}. `
|
|
655
|
+
+ `Run \`pgserve install\` to create it (or pass --socket-dir <existing-dir>).`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
this.socketDir = this.explicitSocketDir;
|
|
659
|
+
} else {
|
|
660
|
+
this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
|
|
661
|
+
if (!fs.existsSync(this.socketDir)) {
|
|
662
|
+
fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
|
|
663
|
+
}
|
|
640
664
|
}
|
|
641
665
|
}
|
|
642
666
|
|
|
@@ -894,6 +918,33 @@ export class PostgresManager extends EventEmitter {
|
|
|
894
918
|
...gucArgs,
|
|
895
919
|
];
|
|
896
920
|
|
|
921
|
+
// pgserve singleton (v2.4): the bun-side audit-log writer is gone.
|
|
922
|
+
// Audit moves to postgres-native logging — `pgaudit` if the .so is
|
|
923
|
+
// bundled with the embedded postgres distribution, `log_statement`
|
|
924
|
+
// as a portable fallback otherwise. The wish ships the contract;
|
|
925
|
+
// shipping the pgaudit binary is a separate cohort task. Either way
|
|
926
|
+
// pm2 captures stderr to `<configDir>/logs/autopg-server-error.log`
|
|
927
|
+
// and `logrotate.d/pgserve` rotates it.
|
|
928
|
+
const pgauditPath = path.join(this.binaries.libDir, '..', 'lib', 'postgresql', 'pgaudit.so');
|
|
929
|
+
if (fs.existsSync(pgauditPath)) {
|
|
930
|
+
pgArgs.push('-c', 'shared_preload_libraries=pgaudit');
|
|
931
|
+
pgArgs.push('-c', 'pgaudit.log=all');
|
|
932
|
+
pgArgs.push('-c', 'pgaudit.log_catalog=off');
|
|
933
|
+
appliedGucs.shared_preload_libraries = 'pgaudit';
|
|
934
|
+
appliedGucs['pgaudit.log'] = 'all';
|
|
935
|
+
} else {
|
|
936
|
+
// Fallback: postgres-native log_statement='all' captures every
|
|
937
|
+
// query without the pgaudit-specific class taxonomy. Operators
|
|
938
|
+
// get an audit trail today; the cohort can swap to pgaudit later
|
|
939
|
+
// by dropping the .so into the embedded-postgres bundle.
|
|
940
|
+
pgArgs.push('-c', 'log_statement=all');
|
|
941
|
+
appliedGucs.log_statement = 'all';
|
|
942
|
+
this.logger?.warn(
|
|
943
|
+
{ pgauditPath },
|
|
944
|
+
'pgaudit.so not found in embedded postgres lib dir; falling back to log_statement=all for audit',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
897
948
|
// Enable Unix socket for faster local connections (Linux/macOS)
|
|
898
949
|
// Windows falls back to TCP only
|
|
899
950
|
if (this.socketDir) {
|
|
@@ -1574,8 +1625,16 @@ export class PostgresManager extends EventEmitter {
|
|
|
1574
1625
|
}
|
|
1575
1626
|
}
|
|
1576
1627
|
|
|
1577
|
-
// Clean up socket directory
|
|
1578
|
-
|
|
1628
|
+
// Clean up socket directory.
|
|
1629
|
+
// pgserve singleton (v2.4): when the caller supplied an explicit
|
|
1630
|
+
// socket dir (operator-owned canonical path under
|
|
1631
|
+
// `$XDG_RUNTIME_DIR/pgserve` or `/tmp/pgserve`), the install path
|
|
1632
|
+
// owns the directory's lifecycle — postgres unlinks its own
|
|
1633
|
+
// `.s.PGSQL.<port>` + `.lock` files on graceful shutdown, and
|
|
1634
|
+
// tearing the directory down here would race with operator tooling
|
|
1635
|
+
// (pm2 restarts, doctor --fix, etc.). Only sweep the legacy
|
|
1636
|
+
// per-pid `os.tmpdir()/pgserve-sock-*` form we generated ourselves.
|
|
1637
|
+
if (this.socketDir && !this.explicitSocketDir) {
|
|
1579
1638
|
try {
|
|
1580
1639
|
fs.rmSync(this.socketDir, { recursive: true, force: true });
|
|
1581
1640
|
this.logger.debug({ socketDir: this.socketDir }, 'Cleaned up socket directory');
|
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
|
+
}
|