pgserve 2.5.0 → 2.6.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/README.md +5 -8
- package/bin/pgserve-wrapper.cjs +19 -0
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +184 -0
- package/scripts/assemble-tarball.sh +191 -0
- package/scripts/build-binary.sh +213 -0
- package/scripts/fetch-postgres-bins.sh +234 -0
- package/scripts/postinstall.cjs +102 -18
- package/scripts/verify-published-artifacts.sh +211 -0
- package/src/cli-install.cjs +65 -3
- package/src/commands/doctor.js +465 -0
- package/src/commands/gc.js +276 -0
- package/src/commands/provision.js +396 -0
- package/src/commands/trust.js +187 -0
- package/src/cosign/trust-list.js +3 -3
- package/src/cosign/trust-store.js +250 -0
- package/src/gc/audit-log.js +150 -0
- package/src/gc/orphan-detection.js +190 -0
- package/src/gc/queries.js +193 -0
- package/src/lib/pg-query.js +145 -0
- package/src/provision/advisory-lock.js +91 -0
- package/src/provision/db-naming.js +130 -0
- package/src/provision/fingerprint.js +144 -0
- package/src/schema/pgserve-meta.js +120 -0
- package/src/security/blocked-versions.js +103 -0
- package/src/upgrade/steps/binary-cache-flush.js +2 -2
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve gc` — sweep orphaned databases. Singleton G3 verb 3.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* The 240-orphan-disease fix from the v1.0 mission. Composes:
|
|
7
|
+
* - src/gc/orphan-detection.js#classifyOrphans — pure classifier
|
|
8
|
+
* - src/gc/audit-log.js#writeGcAudit — JSON-lines audit
|
|
9
|
+
* - src/gc/queries.js — gc-specific psql queries
|
|
10
|
+
* - src/lib/admin-json.js — port discovery
|
|
11
|
+
*
|
|
12
|
+
* Defaults:
|
|
13
|
+
* - `--dry-run` is the DEFAULT. gc does NOT drop anything unless
|
|
14
|
+
* `--apply` is passed. Logs the intent to the audit file with
|
|
15
|
+
* `dryRun: true` so an operator can audit what *would* have been
|
|
16
|
+
* swept before flipping the switch.
|
|
17
|
+
* - `--apply` actually drops. Each drop is audited as `action: drop`.
|
|
18
|
+
* - `--stale-after-days <N>` overrides the 30d default for the
|
|
19
|
+
* `idle_stale` orphan signal.
|
|
20
|
+
* - `--json` emits a single JSON summary at the end (machine-
|
|
21
|
+
* readable; the per-event audit log is always written regardless).
|
|
22
|
+
*
|
|
23
|
+
* Exit codes:
|
|
24
|
+
* 0 success — partition computed, drops (or dry-run intent)
|
|
25
|
+
* audited.
|
|
26
|
+
* 1 user error (bad flags, fingerprint missing on a row, etc.)
|
|
27
|
+
* 2 `pgserve_meta` does not exist on the host (no provisions yet);
|
|
28
|
+
* a clean signal for monitoring rather than a crash.
|
|
29
|
+
* 3 one or more drops failed; the partial sweep is still audited.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readAdminJson } from '../lib/admin-json.js';
|
|
33
|
+
import { classifyOrphans } from '../gc/orphan-detection.js';
|
|
34
|
+
import { writeGcAudit } from '../gc/audit-log.js';
|
|
35
|
+
import {
|
|
36
|
+
selectMetaRows,
|
|
37
|
+
selectExistingDbs,
|
|
38
|
+
selectActiveDbs,
|
|
39
|
+
dropDatabase,
|
|
40
|
+
deleteMetaRow,
|
|
41
|
+
} from '../gc/queries.js';
|
|
42
|
+
import fs from 'node:fs';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
45
|
+
const USAGE = `Usage: pgserve gc [options]
|
|
46
|
+
|
|
47
|
+
--dry-run show what would be dropped without dropping (default)
|
|
48
|
+
--apply actually drop orphan databases + roles + meta rows
|
|
49
|
+
--stale-after-days <N> override the 30-day idle staleness window
|
|
50
|
+
--json emit a JSON summary on stdout
|
|
51
|
+
--port <N> override the postgres port (default: read admin.json or 5432)
|
|
52
|
+
|
|
53
|
+
Default mode is dry-run; you must pass --apply to actually drop anything.`;
|
|
54
|
+
|
|
55
|
+
function parseFlags(argv) {
|
|
56
|
+
const out = {
|
|
57
|
+
apply: false,
|
|
58
|
+
json: false,
|
|
59
|
+
staleAfterDays: DEFAULT_STALE_AFTER_DAYS,
|
|
60
|
+
port: undefined,
|
|
61
|
+
};
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
switch (a) {
|
|
65
|
+
case '--dry-run':
|
|
66
|
+
out.apply = false;
|
|
67
|
+
break;
|
|
68
|
+
case '--apply':
|
|
69
|
+
out.apply = true;
|
|
70
|
+
break;
|
|
71
|
+
case '--json':
|
|
72
|
+
out.json = true;
|
|
73
|
+
break;
|
|
74
|
+
case '--help':
|
|
75
|
+
case '-h':
|
|
76
|
+
out.help = true;
|
|
77
|
+
break;
|
|
78
|
+
case '--stale-after-days': {
|
|
79
|
+
const v = Number(argv[++i]);
|
|
80
|
+
if (!Number.isInteger(v) || v <= 0) {
|
|
81
|
+
throw new Error('--stale-after-days requires a positive integer');
|
|
82
|
+
}
|
|
83
|
+
out.staleAfterDays = v;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case '--port':
|
|
87
|
+
case '-p': {
|
|
88
|
+
const v = Number(argv[++i]);
|
|
89
|
+
if (!Number.isInteger(v) || v <= 0 || v > 65535) {
|
|
90
|
+
throw new Error('--port requires an integer in [1, 65535]');
|
|
91
|
+
}
|
|
92
|
+
out.port = v;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
throw new Error(`unknown flag: ${a}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolvePort(opts) {
|
|
103
|
+
if (typeof opts.port === 'number') return opts.port;
|
|
104
|
+
try {
|
|
105
|
+
const admin = readAdminJson();
|
|
106
|
+
if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
|
|
107
|
+
} catch {
|
|
108
|
+
/* admin.json absent or unreadable — fall through */
|
|
109
|
+
}
|
|
110
|
+
return 5432;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emitJson(summary) {
|
|
114
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function emitHumanSummary(summary) {
|
|
118
|
+
const tag = summary.applied ? 'APPLIED' : 'DRY-RUN';
|
|
119
|
+
process.stdout.write(`pgserve gc [${tag}] — port=${summary.port}, staleAfterDays=${summary.staleAfterDays}\n`);
|
|
120
|
+
process.stdout.write(` meta rows scanned: ${summary.scanned}\n`);
|
|
121
|
+
process.stdout.write(` retained: ${summary.retained}\n`);
|
|
122
|
+
process.stdout.write(` orphans found: ${summary.orphans}\n`);
|
|
123
|
+
if (summary.dropped > 0) {
|
|
124
|
+
process.stdout.write(` databases dropped: ${summary.dropped}\n`);
|
|
125
|
+
}
|
|
126
|
+
if (summary.errors.length > 0) {
|
|
127
|
+
process.stdout.write(` errors: ${summary.errors.length}\n`);
|
|
128
|
+
for (const e of summary.errors) {
|
|
129
|
+
process.stdout.write(` - ${e.database}: ${e.message}\n`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (summary.orphans > 0 && !summary.applied) {
|
|
133
|
+
process.stdout.write(`\n re-run with --apply to actually drop the ${summary.orphans} orphan(s).\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runGc(argv = []) {
|
|
138
|
+
let opts;
|
|
139
|
+
try {
|
|
140
|
+
opts = parseFlags(argv);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
process.stderr.write(`pgserve gc: ${err.message}\n\n${USAGE}\n`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
if (opts.help) {
|
|
146
|
+
process.stdout.write(USAGE + '\n');
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const port = resolvePort(opts);
|
|
150
|
+
const summary = {
|
|
151
|
+
applied: opts.apply,
|
|
152
|
+
port,
|
|
153
|
+
staleAfterDays: opts.staleAfterDays,
|
|
154
|
+
scanned: 0,
|
|
155
|
+
retained: 0,
|
|
156
|
+
orphans: 0,
|
|
157
|
+
dropped: 0,
|
|
158
|
+
errors: [],
|
|
159
|
+
findings: [],
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
writeGcAudit({
|
|
163
|
+
action: 'start',
|
|
164
|
+
detail: `mode=${opts.apply ? 'apply' : 'dry-run'} port=${port} staleAfterDays=${opts.staleAfterDays}`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
let metaRows;
|
|
168
|
+
try {
|
|
169
|
+
metaRows = selectMetaRows({ port });
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err.code === 'ENOPGSERVE_META') {
|
|
172
|
+
writeGcAudit({ action: 'finish', reason: 'no_pgserve_meta', detail: err.message });
|
|
173
|
+
if (opts.json) emitJson({ ...summary, error: 'pgserve_meta does not exist' });
|
|
174
|
+
else process.stdout.write(`pgserve gc: pgserve_meta does not exist on this host (no provisions yet)\n`);
|
|
175
|
+
return 2;
|
|
176
|
+
}
|
|
177
|
+
writeGcAudit({ action: 'error', reason: 'select_meta_rows_failed', detail: err.message });
|
|
178
|
+
if (opts.json) emitJson({ ...summary, error: err.message });
|
|
179
|
+
else process.stderr.write(`pgserve gc: ${err.message}\n`);
|
|
180
|
+
return 3;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let existingDbs;
|
|
184
|
+
let activeDbs;
|
|
185
|
+
try {
|
|
186
|
+
existingDbs = selectExistingDbs({ port });
|
|
187
|
+
activeDbs = selectActiveDbs({ port });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
writeGcAudit({ action: 'error', reason: 'select_pg_state_failed', detail: err.message });
|
|
190
|
+
if (opts.json) emitJson({ ...summary, error: err.message });
|
|
191
|
+
else process.stderr.write(`pgserve gc: ${err.message}\n`);
|
|
192
|
+
return 3;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const now = new Date();
|
|
196
|
+
const partition = classifyOrphans({
|
|
197
|
+
metaRows,
|
|
198
|
+
existingDbs,
|
|
199
|
+
activeDbs,
|
|
200
|
+
pathExists: (p) => {
|
|
201
|
+
try { return fs.existsSync(p); } catch { return false; }
|
|
202
|
+
},
|
|
203
|
+
now,
|
|
204
|
+
staleAfterMs: opts.staleAfterDays * 24 * 60 * 60 * 1000,
|
|
205
|
+
});
|
|
206
|
+
summary.scanned = metaRows.length;
|
|
207
|
+
summary.retained = partition.retained.length;
|
|
208
|
+
summary.orphans = partition.orphans.length;
|
|
209
|
+
summary.findings = [
|
|
210
|
+
...partition.orphans.map((f) => ({ ...f, partition: 'orphan' })),
|
|
211
|
+
...partition.retained.map((f) => ({ ...f, partition: 'retained' })),
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
// Audit every retention decision so an operator can answer "why was
|
|
215
|
+
// X kept?" days later just by reading the JSON-lines log.
|
|
216
|
+
for (const r of partition.retained) {
|
|
217
|
+
writeGcAudit({
|
|
218
|
+
action: 'skip',
|
|
219
|
+
fingerprint: r.row.fingerprint,
|
|
220
|
+
database: r.row.database_name,
|
|
221
|
+
role: r.row.role_name,
|
|
222
|
+
reason: r.reason,
|
|
223
|
+
detail: r.detail,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const o of partition.orphans) {
|
|
228
|
+
if (!opts.apply) {
|
|
229
|
+
writeGcAudit({
|
|
230
|
+
action: 'skip',
|
|
231
|
+
dryRun: true,
|
|
232
|
+
fingerprint: o.row.fingerprint,
|
|
233
|
+
database: o.row.database_name,
|
|
234
|
+
role: o.row.role_name,
|
|
235
|
+
reason: o.reason,
|
|
236
|
+
detail: `would drop (${o.detail})`,
|
|
237
|
+
});
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
dropDatabase({ database: o.row.database_name, role: o.row.role_name, port });
|
|
242
|
+
deleteMetaRow({ fingerprint: o.row.fingerprint, port });
|
|
243
|
+
summary.dropped++;
|
|
244
|
+
writeGcAudit({
|
|
245
|
+
action: 'drop',
|
|
246
|
+
fingerprint: o.row.fingerprint,
|
|
247
|
+
database: o.row.database_name,
|
|
248
|
+
role: o.row.role_name,
|
|
249
|
+
reason: o.reason,
|
|
250
|
+
detail: o.detail,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
summary.errors.push({ database: o.row.database_name, message: err.message });
|
|
254
|
+
writeGcAudit({
|
|
255
|
+
action: 'error',
|
|
256
|
+
fingerprint: o.row.fingerprint,
|
|
257
|
+
database: o.row.database_name,
|
|
258
|
+
role: o.row.role_name,
|
|
259
|
+
reason: 'drop_failed',
|
|
260
|
+
detail: err.message,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
writeGcAudit({
|
|
266
|
+
action: 'finish',
|
|
267
|
+
detail: `scanned=${summary.scanned} orphans=${summary.orphans} dropped=${summary.dropped} errors=${summary.errors.length}`,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (opts.json) emitJson(summary);
|
|
271
|
+
else emitHumanSummary(summary);
|
|
272
|
+
|
|
273
|
+
return summary.errors.length > 0 ? 3 : 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const __testInternals = Object.freeze({ parseFlags, resolvePort, emitHumanSummary });
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve provision [<fingerprint>]` — singleton G3 verb 4.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* Idempotent provisioner. Resolves a fingerprint from the operator's
|
|
7
|
+
* cwd (or an explicit positional), takes a per-fingerprint advisory
|
|
8
|
+
* lock to make 10 concurrent calls produce exactly 1 database, then
|
|
9
|
+
* runs the cohort-canonical CREATE ROLE / CREATE DATABASE / GRANT /
|
|
10
|
+
* INSERT INTO pgserve_meta sequence.
|
|
11
|
+
*
|
|
12
|
+
* Composes the merged G3 foundations:
|
|
13
|
+
* - src/provision/fingerprint.js → resolveFingerprint
|
|
14
|
+
* - src/provision/db-naming.js → deriveProvisionedNames
|
|
15
|
+
* - src/schema/pgserve-meta.js → bootstrapPgserveMeta
|
|
16
|
+
* - src/cosign/schema.js → applyVerifiedColumns
|
|
17
|
+
* - src/lib/pg-query.js → pgQuery / quoteIdent / quoteLiteral
|
|
18
|
+
* - src/lib/admin-json.js → readAdminJson (port discovery)
|
|
19
|
+
*
|
|
20
|
+
* The advisory-lock helpers in src/provision/advisory-lock.js are NOT
|
|
21
|
+
* called by this CLI verb (see "Concurrency note" below) — they're
|
|
22
|
+
* kept on main for future single-session callers (e.g. a daemon mode).
|
|
23
|
+
*
|
|
24
|
+
* Idempotency strategy:
|
|
25
|
+
* 1. SELECT existing pgserve_meta row by fingerprint inside the
|
|
26
|
+
* advisory-lock window. If present + database still exists, just
|
|
27
|
+
* `touch` last_used_at and exit success.
|
|
28
|
+
* 2. Otherwise run the full create sequence with `IF NOT EXISTS` /
|
|
29
|
+
* `CREATE OR REPLACE` semantics so a partial earlier run can be
|
|
30
|
+
* resumed without operator surgery.
|
|
31
|
+
* 3. INSERT ... ON CONFLICT (fingerprint) DO UPDATE so the row
|
|
32
|
+
* converges to current values whether this is a first run or a
|
|
33
|
+
* replay.
|
|
34
|
+
*
|
|
35
|
+
* Concurrency note (PR #92 bot-review correction):
|
|
36
|
+
* The wish references `pg_advisory_lock`-deduped provisioning, but
|
|
37
|
+
* we shell out to psql via `spawnSync` — every pgQuery call starts
|
|
38
|
+
* a NEW psql session, so even a session-scoped lock can't survive
|
|
39
|
+
* across our calls (xact-scoped is even shorter — releases on the
|
|
40
|
+
* transaction's COMMIT). To get a real lock we'd need to bundle the
|
|
41
|
+
* whole sequence into a single piped psql script, which collides
|
|
42
|
+
* with `CREATE DATABASE`'s "no transaction block" restriction.
|
|
43
|
+
*
|
|
44
|
+
* The honest mechanism here is **idempotency under racing replays**:
|
|
45
|
+
* - CREATE ROLE wrapped in a `DO` / `IF NOT EXISTS` block.
|
|
46
|
+
* - CREATE DATABASE catches `database "<name>" already exists`
|
|
47
|
+
* (psql does NOT emit SQLSTATE codes by default, so we match
|
|
48
|
+
* on the human-readable error text in stderr — bot review
|
|
49
|
+
* MEDIUM).
|
|
50
|
+
* - GRANT is set-style (re-running adds nothing).
|
|
51
|
+
* - INSERT INTO pgserve_meta uses `ON CONFLICT (fingerprint) DO
|
|
52
|
+
* UPDATE` so the row converges to current values.
|
|
53
|
+
*
|
|
54
|
+
* 10 concurrent `provision <fingerprint>` invocations with the same
|
|
55
|
+
* fingerprint converge to one (database, role, meta row) trio — the
|
|
56
|
+
* serialization is at the postgres level (CREATE DATABASE acquires
|
|
57
|
+
* the appropriate locks itself), not at our shellout level. The
|
|
58
|
+
* advisory-lock primitives in `src/provision/advisory-lock.js` are
|
|
59
|
+
* kept for future single-session callers (e.g. a daemon mode), but
|
|
60
|
+
* are NOT acquired by this CLI verb.
|
|
61
|
+
*
|
|
62
|
+
* Exit codes:
|
|
63
|
+
* 0 provisioned (or no-op idempotent replay)
|
|
64
|
+
* 1 user error (bad flags, fingerprint validation)
|
|
65
|
+
* 2 pgserve postmaster unreachable / not provisionable
|
|
66
|
+
* 3 postgres error during create sequence (partial state may
|
|
67
|
+
* remain; rerun is safe)
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { readAdminJson } from '../lib/admin-json.js';
|
|
71
|
+
import { resolveFingerprint } from '../provision/fingerprint.js';
|
|
72
|
+
import { deriveProvisionedNames } from '../provision/db-naming.js';
|
|
73
|
+
import { bootstrapPgserveMeta } from '../schema/pgserve-meta.js';
|
|
74
|
+
import { applyVerifiedColumns } from '../cosign/schema.js';
|
|
75
|
+
import { pgQuery, quoteIdent, quoteLiteral } from '../lib/pg-query.js';
|
|
76
|
+
|
|
77
|
+
const USAGE = `Usage: pgserve provision [<fingerprint>] [options]
|
|
78
|
+
|
|
79
|
+
<fingerprint> optional explicit fingerprint string (skips
|
|
80
|
+
package.json detection); pinned by the
|
|
81
|
+
operator. If omitted, pgserve resolves the
|
|
82
|
+
fingerprint from the cwd's package.json.
|
|
83
|
+
|
|
84
|
+
--port <N> override the postgres port (default: read
|
|
85
|
+
admin.json or 5432).
|
|
86
|
+
--json emit a JSON summary on stdout.
|
|
87
|
+
-h, --help show this help.
|
|
88
|
+
|
|
89
|
+
Idempotent: re-running with the same fingerprint touches last_used_at
|
|
90
|
+
and exits success — no error if the database already exists.`;
|
|
91
|
+
|
|
92
|
+
function parseFlags(argv) {
|
|
93
|
+
const out = { json: false, port: undefined, positional: [] };
|
|
94
|
+
for (let i = 0; i < argv.length; i++) {
|
|
95
|
+
const a = argv[i];
|
|
96
|
+
switch (a) {
|
|
97
|
+
case '--json':
|
|
98
|
+
out.json = true;
|
|
99
|
+
break;
|
|
100
|
+
case '--help':
|
|
101
|
+
case '-h':
|
|
102
|
+
out.help = true;
|
|
103
|
+
break;
|
|
104
|
+
case '--port':
|
|
105
|
+
case '-p': {
|
|
106
|
+
const v = Number(argv[++i]);
|
|
107
|
+
if (!Number.isInteger(v) || v <= 0 || v > 65535) {
|
|
108
|
+
throw new Error('--port requires an integer in [1, 65535]');
|
|
109
|
+
}
|
|
110
|
+
out.port = v;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
if (a.startsWith('--')) {
|
|
115
|
+
throw new Error(`unknown flag: ${a}`);
|
|
116
|
+
}
|
|
117
|
+
out.positional.push(a);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolvePort(opts) {
|
|
124
|
+
if (typeof opts.port === 'number') return opts.port;
|
|
125
|
+
try {
|
|
126
|
+
const admin = readAdminJson();
|
|
127
|
+
if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
|
|
128
|
+
} catch {
|
|
129
|
+
/* admin.json absent — fall through */
|
|
130
|
+
}
|
|
131
|
+
return 5432;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run the bootstrap (pgserve_meta CREATE TABLE + indexes) and then
|
|
136
|
+
* apply the cosign verify ALTER columns on top. Idempotent — both
|
|
137
|
+
* modules use IF NOT EXISTS / IF NOT EXISTS guards.
|
|
138
|
+
*/
|
|
139
|
+
async function ensurePgserveMetaSchema({ port }) {
|
|
140
|
+
const client = makePsqlClient({ port, db: 'postgres' });
|
|
141
|
+
await bootstrapPgserveMeta(client);
|
|
142
|
+
await applyVerifiedColumns(client);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Adapter: shape `pgQuery` to the node-postgres-compatible
|
|
147
|
+
* `client.query(sql)` contract that bootstrapPgserveMeta + applyVerifiedColumns
|
|
148
|
+
* expect. Awaitable so the modules can `await client.query(sql)`.
|
|
149
|
+
*/
|
|
150
|
+
function makePsqlClient({ port, db }) {
|
|
151
|
+
return {
|
|
152
|
+
query: async (sql) => pgQuery({ sql, port, db }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Probe: does pgserve_meta have a row for this fingerprint?
|
|
158
|
+
* Returns the row or null.
|
|
159
|
+
*/
|
|
160
|
+
function selectMetaRow({ port, fingerprint }) {
|
|
161
|
+
const out = pgQuery({
|
|
162
|
+
sql: [
|
|
163
|
+
'SELECT',
|
|
164
|
+
" COALESCE(database_name, ''),",
|
|
165
|
+
" COALESCE(role_name, '')",
|
|
166
|
+
'FROM public.pgserve_meta',
|
|
167
|
+
`WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
|
|
168
|
+
'LIMIT 1',
|
|
169
|
+
].join('\n'),
|
|
170
|
+
port,
|
|
171
|
+
captureStdout: true,
|
|
172
|
+
});
|
|
173
|
+
if (!out) return null;
|
|
174
|
+
const [database_name, role_name] = out.split('\t');
|
|
175
|
+
if (!database_name) return null;
|
|
176
|
+
return { database_name, role_name };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function databaseExists({ port, database }) {
|
|
180
|
+
const out = pgQuery({
|
|
181
|
+
sql: `SELECT 1 FROM pg_database WHERE datname = ${quoteLiteral(database)}`,
|
|
182
|
+
port,
|
|
183
|
+
captureStdout: true,
|
|
184
|
+
});
|
|
185
|
+
return out === '1';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function touchMetaRow({ port, fingerprint }) {
|
|
189
|
+
pgQuery({
|
|
190
|
+
sql: `UPDATE public.pgserve_meta SET last_used_at = now() WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
|
|
191
|
+
port,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run the create sequence. Idempotency-driven (see file header for
|
|
197
|
+
* why we don't try to claim a per-process advisory lock).
|
|
198
|
+
*
|
|
199
|
+
* Returns the names that were provisioned.
|
|
200
|
+
*/
|
|
201
|
+
function runCreateSequence({ port, fingerprint, publisher, sourcePath, names }) {
|
|
202
|
+
const { databaseName, roleName } = names;
|
|
203
|
+
|
|
204
|
+
// Step 1 — CREATE ROLE if missing. We CREATE WITH LOGIN by default;
|
|
205
|
+
// pgserve consumers connect as their fingerprint role.
|
|
206
|
+
pgQuery({
|
|
207
|
+
sql: [
|
|
208
|
+
'DO $do$',
|
|
209
|
+
'BEGIN',
|
|
210
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${quoteLiteral(roleName)}) THEN`,
|
|
211
|
+
` EXECUTE 'CREATE ROLE ${quoteIdent(roleName)} WITH LOGIN';`,
|
|
212
|
+
' END IF;',
|
|
213
|
+
'END$do$;',
|
|
214
|
+
].join('\n'),
|
|
215
|
+
port,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Step 2 — CREATE DATABASE if missing. Cannot run inside a
|
|
219
|
+
// transaction block. We swallow the "already exists" race because
|
|
220
|
+
// the wish marks it a non-error.
|
|
221
|
+
//
|
|
222
|
+
// psql does not emit SQLSTATE codes (42P04) by default — only the
|
|
223
|
+
// human-readable message "ERROR: database "<name>" already exists".
|
|
224
|
+
// Match against that text rather than the SQLSTATE we don't have.
|
|
225
|
+
// (Bot review MEDIUM on PR #92.)
|
|
226
|
+
if (!databaseExists({ port, database: databaseName })) {
|
|
227
|
+
try {
|
|
228
|
+
pgQuery({
|
|
229
|
+
sql: `CREATE DATABASE ${quoteIdent(databaseName)} OWNER ${quoteIdent(roleName)}`,
|
|
230
|
+
port,
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = (err && err.stderr) ? err.stderr : '';
|
|
234
|
+
if (msg.includes('already exists')) {
|
|
235
|
+
/* race: another provisioner created it; benign */
|
|
236
|
+
} else {
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Step 3 — GRANT CONNECT + CREATE on the DB to the role. Idempotent
|
|
243
|
+
// (postgres GRANT is set-style, not stack-style).
|
|
244
|
+
pgQuery({
|
|
245
|
+
sql: `GRANT CONNECT, CREATE ON DATABASE ${quoteIdent(databaseName)} TO ${quoteIdent(roleName)}`,
|
|
246
|
+
port,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Step 4 — UPSERT the pgserve_meta row. ON CONFLICT keeps replays
|
|
250
|
+
// safe (a partial earlier run can be resumed without operator
|
|
251
|
+
// intervention).
|
|
252
|
+
pgQuery({
|
|
253
|
+
sql: [
|
|
254
|
+
'INSERT INTO public.pgserve_meta',
|
|
255
|
+
' (fingerprint, database_name, role_name, publisher, source_path, last_used_at)',
|
|
256
|
+
'VALUES (',
|
|
257
|
+
` ${quoteLiteral(fingerprint)},`,
|
|
258
|
+
` ${quoteLiteral(databaseName)},`,
|
|
259
|
+
` ${quoteLiteral(roleName)},`,
|
|
260
|
+
` ${quoteLiteral(publisher || '')},`,
|
|
261
|
+
` ${quoteLiteral(sourcePath || '')},`,
|
|
262
|
+
' now()',
|
|
263
|
+
')',
|
|
264
|
+
'ON CONFLICT (fingerprint) DO UPDATE SET',
|
|
265
|
+
' database_name = EXCLUDED.database_name,',
|
|
266
|
+
' role_name = EXCLUDED.role_name,',
|
|
267
|
+
' publisher = EXCLUDED.publisher,',
|
|
268
|
+
' source_path = EXCLUDED.source_path,',
|
|
269
|
+
' last_used_at = now()',
|
|
270
|
+
].join('\n'),
|
|
271
|
+
port,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return { databaseName, roleName };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function emit({ json, summary, humanLines }) {
|
|
278
|
+
if (json) {
|
|
279
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
280
|
+
} else {
|
|
281
|
+
for (const line of humanLines) process.stdout.write(line + '\n');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function runProvision(argv = []) {
|
|
286
|
+
let opts;
|
|
287
|
+
try {
|
|
288
|
+
opts = parseFlags(argv);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
process.stderr.write(`pgserve provision: ${err.message}\n\n${USAGE}\n`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
if (opts.help) {
|
|
294
|
+
process.stdout.write(USAGE + '\n');
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
const explicit = opts.positional[0];
|
|
298
|
+
const port = resolvePort(opts);
|
|
299
|
+
|
|
300
|
+
let resolved;
|
|
301
|
+
try {
|
|
302
|
+
resolved = resolveFingerprint({ explicit });
|
|
303
|
+
} catch (err) {
|
|
304
|
+
process.stderr.write(`pgserve provision: ${err.message}\n`);
|
|
305
|
+
return 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const names = deriveProvisionedNames({
|
|
309
|
+
fingerprint: resolved.fingerprint,
|
|
310
|
+
publisher: resolved.publisher,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const summary = {
|
|
314
|
+
fingerprint: resolved.fingerprint,
|
|
315
|
+
publisher: resolved.publisher,
|
|
316
|
+
sourcePath: resolved.sourcePath,
|
|
317
|
+
fingerprintKind: resolved.kind,
|
|
318
|
+
databaseName: names.databaseName,
|
|
319
|
+
roleName: names.roleName,
|
|
320
|
+
port,
|
|
321
|
+
action: 'unknown',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Step 1 — make sure pgserve_meta exists (bootstrap + verify cols).
|
|
325
|
+
try {
|
|
326
|
+
await ensurePgserveMetaSchema({ port });
|
|
327
|
+
} catch (err) {
|
|
328
|
+
process.stderr.write(`pgserve provision: cannot bootstrap pgserve_meta: ${err.message}\n`);
|
|
329
|
+
summary.action = 'error';
|
|
330
|
+
summary.error = err.message;
|
|
331
|
+
if (opts.json) emit({ json: true, summary });
|
|
332
|
+
return 2;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Step 2 — fast-path idempotency: existing row + DB still present?
|
|
336
|
+
let existing;
|
|
337
|
+
try {
|
|
338
|
+
existing = selectMetaRow({ port, fingerprint: resolved.fingerprint });
|
|
339
|
+
} catch (err) {
|
|
340
|
+
process.stderr.write(`pgserve provision: ${err.message}\n`);
|
|
341
|
+
return 3;
|
|
342
|
+
}
|
|
343
|
+
if (existing && databaseExists({ port, database: existing.database_name })) {
|
|
344
|
+
touchMetaRow({ port, fingerprint: resolved.fingerprint });
|
|
345
|
+
summary.action = 'touched';
|
|
346
|
+
summary.databaseName = existing.database_name;
|
|
347
|
+
summary.roleName = existing.role_name;
|
|
348
|
+
emit({
|
|
349
|
+
json: opts.json,
|
|
350
|
+
summary,
|
|
351
|
+
humanLines: [
|
|
352
|
+
`pgserve provision: idempotent replay`,
|
|
353
|
+
` fingerprint: ${resolved.fingerprint}`,
|
|
354
|
+
` database: ${existing.database_name} (already exists, last_used_at touched)`,
|
|
355
|
+
` role: ${existing.role_name}`,
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Step 3 — full create sequence under advisory lock.
|
|
362
|
+
try {
|
|
363
|
+
runCreateSequence({
|
|
364
|
+
port,
|
|
365
|
+
fingerprint: resolved.fingerprint,
|
|
366
|
+
publisher: resolved.publisher,
|
|
367
|
+
sourcePath: resolved.sourcePath,
|
|
368
|
+
names,
|
|
369
|
+
});
|
|
370
|
+
summary.action = 'created';
|
|
371
|
+
} catch (err) {
|
|
372
|
+
summary.action = 'error';
|
|
373
|
+
summary.error = err.message;
|
|
374
|
+
process.stderr.write(`pgserve provision: ${err.message}\n`);
|
|
375
|
+
if (opts.json) emit({ json: true, summary });
|
|
376
|
+
return 3;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
emit({
|
|
380
|
+
json: opts.json,
|
|
381
|
+
summary,
|
|
382
|
+
humanLines: [
|
|
383
|
+
`pgserve provision: created`,
|
|
384
|
+
` fingerprint: ${resolved.fingerprint}`,
|
|
385
|
+
` database: ${names.databaseName}`,
|
|
386
|
+
` role: ${names.roleName}`,
|
|
387
|
+
` source_path: ${resolved.sourcePath}`,
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export const __testInternals = Object.freeze({
|
|
394
|
+
parseFlags,
|
|
395
|
+
resolvePort,
|
|
396
|
+
});
|