pgserve 2.5.0 → 2.6.1
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 +48 -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 +229 -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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure orphan classification for `pgserve gc`.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* Splits the gc verb into two halves:
|
|
7
|
+
* 1. (this module) decide which `pgserve_meta` rows refer to dead
|
|
8
|
+
* consumers and which are still in use
|
|
9
|
+
* 2. (gc verb) DROP the orphans + audit-log every drop
|
|
10
|
+
*
|
|
11
|
+
* Keeping classification pure means the gc verb can be tested
|
|
12
|
+
* deterministically (feed in synthetic input → assert the partition)
|
|
13
|
+
* without spinning up postgres, and the rules can be exercised at high
|
|
14
|
+
* fan-out by callers that want a `pgserve gc --dry-run`.
|
|
15
|
+
*
|
|
16
|
+
* Orphan signals (any one is sufficient):
|
|
17
|
+
* 1. The DB row exists in pgserve_meta but the database itself does
|
|
18
|
+
* not exist in `pg_database` — leftover row from a manual DROP.
|
|
19
|
+
* 2. The row's `source_path` is set and that path no longer exists
|
|
20
|
+
* on the filesystem — the consumer directory was removed.
|
|
21
|
+
* 3. The row's `last_used_at` is older than `staleAfterMs` AND the
|
|
22
|
+
* database has zero active connections in `pg_stat_activity`.
|
|
23
|
+
* The "no connections" guard prevents gc from dropping a DB that
|
|
24
|
+
* a long-running consumer is actively using; a missing entry in
|
|
25
|
+
* the activity map is treated as zero connections.
|
|
26
|
+
*
|
|
27
|
+
* Retention signals (none of the above + an explicit "in use" hit):
|
|
28
|
+
* - `last_used_at` is within the staleness window, OR
|
|
29
|
+
* - the database has at least one active connection.
|
|
30
|
+
*
|
|
31
|
+
* Inputs:
|
|
32
|
+
* - `metaRows` array of pgserve_meta row objects: { fingerprint,
|
|
33
|
+
* database_name, role_name, source_path, last_used_at }
|
|
34
|
+
* - `existingDbs` Set<string> of DB names from `pg_database`
|
|
35
|
+
* - `activeDbs` Set<string> of DB names that have ≥1 row in
|
|
36
|
+
* pg_stat_activity (or a Map<string, number> if
|
|
37
|
+
* callers want to record the count too — Set-like
|
|
38
|
+
* access is what we use)
|
|
39
|
+
* - `pathExists(path)` callback returning truthy when the path is on
|
|
40
|
+
* disk. Caller injects fs.existsSync or a mock.
|
|
41
|
+
* - `now` Date — usually `new Date()`; injectable for tests.
|
|
42
|
+
* - `staleAfterMs` ms threshold past `last_used_at` to declare
|
|
43
|
+
* an idle DB stale. Default: 30 days.
|
|
44
|
+
*
|
|
45
|
+
* Outputs:
|
|
46
|
+
* { orphans: [...], retained: [...] }
|
|
47
|
+
* each row in `orphans` has `reason: 'missing_db' | 'missing_path' |
|
|
48
|
+
* 'idle_stale'`; each row in `retained` has `reason: 'active' |
|
|
49
|
+
* 'recent' | 'unknown_meta'`. The `unknown_meta` bucket exists for
|
|
50
|
+
* rows that are missing `last_used_at` entirely — we never DROP one
|
|
51
|
+
* of those without an operator decision.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
const DEFAULT_STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {Object} MetaRow
|
|
58
|
+
* @property {string} fingerprint
|
|
59
|
+
* @property {string} database_name
|
|
60
|
+
* @property {string} role_name
|
|
61
|
+
* @property {string=} source_path
|
|
62
|
+
* @property {string|Date=} last_used_at
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} OrphanFinding
|
|
67
|
+
* @property {MetaRow} row
|
|
68
|
+
* @property {'missing_db'|'missing_path'|'idle_stale'} reason
|
|
69
|
+
* @property {string=} detail
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} RetainedFinding
|
|
74
|
+
* @property {MetaRow} row
|
|
75
|
+
* @property {'active'|'recent'|'unknown_meta'} reason
|
|
76
|
+
* @property {string=} detail
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
function asTime(t) {
|
|
80
|
+
if (!t) return null;
|
|
81
|
+
if (t instanceof Date) {
|
|
82
|
+
const ms = t.getTime();
|
|
83
|
+
return Number.isFinite(ms) ? ms : null;
|
|
84
|
+
}
|
|
85
|
+
if (typeof t === 'string') {
|
|
86
|
+
const ms = Date.parse(t);
|
|
87
|
+
return Number.isFinite(ms) ? ms : null;
|
|
88
|
+
}
|
|
89
|
+
if (typeof t === 'number' && Number.isFinite(t)) return t;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Classify a single meta row. Exposed for unit tests so callers can
|
|
95
|
+
* exercise individual signals without composing a full input set.
|
|
96
|
+
*
|
|
97
|
+
* @returns {OrphanFinding | RetainedFinding}
|
|
98
|
+
*/
|
|
99
|
+
export function classifyRow(row, ctx) {
|
|
100
|
+
if (!row || typeof row.database_name !== 'string' || row.database_name.length === 0) {
|
|
101
|
+
throw new TypeError('classifyRow: row must have a non-empty database_name');
|
|
102
|
+
}
|
|
103
|
+
const {
|
|
104
|
+
existingDbs,
|
|
105
|
+
activeDbs,
|
|
106
|
+
pathExists,
|
|
107
|
+
now,
|
|
108
|
+
staleAfterMs,
|
|
109
|
+
} = ctx;
|
|
110
|
+
|
|
111
|
+
// 1. database is gone but the meta row is still here
|
|
112
|
+
if (!existingDbs.has(row.database_name)) {
|
|
113
|
+
return {
|
|
114
|
+
row,
|
|
115
|
+
reason: 'missing_db',
|
|
116
|
+
detail: `${row.database_name} not in pg_database`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 2. consumer directory was removed
|
|
121
|
+
if (typeof row.source_path === 'string' && row.source_path.length > 0) {
|
|
122
|
+
if (!pathExists(row.source_path)) {
|
|
123
|
+
return {
|
|
124
|
+
row,
|
|
125
|
+
reason: 'missing_path',
|
|
126
|
+
detail: `source_path ${row.source_path} no longer exists`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 3. idle + stale
|
|
132
|
+
const lastUsedMs = asTime(row.last_used_at);
|
|
133
|
+
if (lastUsedMs == null) {
|
|
134
|
+
return {
|
|
135
|
+
row,
|
|
136
|
+
reason: 'unknown_meta',
|
|
137
|
+
detail: 'last_used_at is missing or unparseable; refusing to gc without operator review',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const ageMs = now.getTime() - lastUsedMs;
|
|
141
|
+
const isStale = ageMs >= staleAfterMs;
|
|
142
|
+
const isActive = activeDbs.has(row.database_name);
|
|
143
|
+
|
|
144
|
+
if (isActive) {
|
|
145
|
+
return { row, reason: 'active', detail: `${row.database_name} has ≥1 active connection` };
|
|
146
|
+
}
|
|
147
|
+
if (isStale) {
|
|
148
|
+
return {
|
|
149
|
+
row,
|
|
150
|
+
reason: 'idle_stale',
|
|
151
|
+
detail: `last_used_at is ${Math.floor(ageMs / (24 * 60 * 60 * 1000))}d old, no active connections`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { row, reason: 'recent', detail: `last_used_at is within ${Math.floor(staleAfterMs / (24 * 60 * 60 * 1000))}d window` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ORPHAN_REASONS = new Set(['missing_db', 'missing_path', 'idle_stale']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Partition all rows into orphans + retained.
|
|
161
|
+
* @param {object} args
|
|
162
|
+
* @param {MetaRow[]} args.metaRows
|
|
163
|
+
* @param {Set<string>} args.existingDbs
|
|
164
|
+
* @param {Set<string>} args.activeDbs
|
|
165
|
+
* @param {(p: string) => boolean} [args.pathExists]
|
|
166
|
+
* @param {Date} [args.now]
|
|
167
|
+
* @param {number} [args.staleAfterMs]
|
|
168
|
+
* @returns {{ orphans: OrphanFinding[], retained: RetainedFinding[] }}
|
|
169
|
+
*/
|
|
170
|
+
export function classifyOrphans(args = {}) {
|
|
171
|
+
const {
|
|
172
|
+
metaRows = [],
|
|
173
|
+
existingDbs = new Set(),
|
|
174
|
+
activeDbs = new Set(),
|
|
175
|
+
pathExists = () => true,
|
|
176
|
+
now = new Date(),
|
|
177
|
+
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
|
178
|
+
} = args;
|
|
179
|
+
const ctx = { existingDbs, activeDbs, pathExists, now, staleAfterMs };
|
|
180
|
+
const orphans = [];
|
|
181
|
+
const retained = [];
|
|
182
|
+
for (const row of metaRows) {
|
|
183
|
+
const finding = classifyRow(row, ctx);
|
|
184
|
+
if (ORPHAN_REASONS.has(finding.reason)) orphans.push(finding);
|
|
185
|
+
else retained.push(finding);
|
|
186
|
+
}
|
|
187
|
+
return { orphans, retained };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const __testInternals = Object.freeze({ DEFAULT_STALE_AFTER_MS, asTime });
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* psql query helpers for `pgserve gc`.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
|
|
5
|
+
* (the `pgserve gc` orchestration layer).
|
|
6
|
+
*
|
|
7
|
+
* This module composes the shared psql primitive (`pgQuery` /
|
|
8
|
+
* `quoteIdent` / `quoteLiteral` from `src/lib/pg-query.js`) into the
|
|
9
|
+
* specific SELECT / DROP queries gc runs against `pgserve_meta` +
|
|
10
|
+
* `pg_database` + `pg_stat_activity`.
|
|
11
|
+
*
|
|
12
|
+
* History: PR #91 inlined its own `pgQuery`; PR #92 extracted the
|
|
13
|
+
* canonical primitive to `src/lib/pg-query.js`; the
|
|
14
|
+
* `autopg-distribution-cutover-finalize` G2 dedup PR (#94) collapsed
|
|
15
|
+
* the gc-side copy into a re-exporter at `src/gc/pg-queries.js`. This
|
|
16
|
+
* file is the G2-followup destination — gc-specific helpers only,
|
|
17
|
+
* primitives consumed via direct imports from `src/lib/pg-query.js`.
|
|
18
|
+
*
|
|
19
|
+
* DROP DATABASE caveat: postgres refuses `DROP DATABASE <db>` when
|
|
20
|
+
* sessions are connected. We `pg_terminate_backend()` everything
|
|
21
|
+
* targeting the doomed database first, then DROP. The kill step is
|
|
22
|
+
* gated behind explicit `--apply` at the CLI layer; this module just
|
|
23
|
+
* exposes the primitives.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { pgQuery, quoteIdent, quoteLiteral, PG_QUERY_DEFAULTS } from '../lib/pg-query.js';
|
|
27
|
+
import { PGSERVE_META_TABLE } from '../schema/pgserve-meta.js';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PORT = PG_QUERY_DEFAULTS.port;
|
|
30
|
+
const DEFAULT_DB = PG_QUERY_DEFAULTS.db;
|
|
31
|
+
|
|
32
|
+
const SYSTEM_DBS = new Set(['template0', 'template1', 'postgres']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* SELECT every row from `pgserve_meta`. Returns an array of plain
|
|
36
|
+
* objects matching the table shape from `src/schema/pgserve-meta.js`.
|
|
37
|
+
* Throws ENOPGSERVE_META if the table doesn't exist (caller decides
|
|
38
|
+
* whether that's a "no provisions yet" warn vs. a hard fail).
|
|
39
|
+
*/
|
|
40
|
+
export function selectMetaRows({ port = DEFAULT_PORT } = {}) {
|
|
41
|
+
// Single-line tab-separated form so a missing/null column still
|
|
42
|
+
// parses cleanly. ARRAY_AGG would lose null distinction.
|
|
43
|
+
const exists = pgQuery({
|
|
44
|
+
db: DEFAULT_DB,
|
|
45
|
+
port,
|
|
46
|
+
sql: `SELECT to_regclass('public.${PGSERVE_META_TABLE}') IS NOT NULL`,
|
|
47
|
+
captureStdout: true,
|
|
48
|
+
});
|
|
49
|
+
if (exists.trim() !== 't') {
|
|
50
|
+
const err = new Error(`pgserve_meta does not exist on this host`);
|
|
51
|
+
err.code = 'ENOPGSERVE_META';
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
const out = pgQuery({
|
|
55
|
+
db: DEFAULT_DB,
|
|
56
|
+
port,
|
|
57
|
+
sql: [
|
|
58
|
+
'SELECT',
|
|
59
|
+
" COALESCE(fingerprint, ''),",
|
|
60
|
+
" COALESCE(database_name, ''),",
|
|
61
|
+
" COALESCE(role_name, ''),",
|
|
62
|
+
" COALESCE(publisher, ''),",
|
|
63
|
+
" COALESCE(source_path, ''),",
|
|
64
|
+
" COALESCE(to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'), '')",
|
|
65
|
+
`FROM public.${PGSERVE_META_TABLE}`,
|
|
66
|
+
'ORDER BY fingerprint',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
captureStdout: true,
|
|
69
|
+
});
|
|
70
|
+
if (!out) return [];
|
|
71
|
+
return out.split('\n').filter(Boolean).map((line) => {
|
|
72
|
+
const [fingerprint, database_name, role_name, publisher, source_path, last_used_at] = line.split('\t');
|
|
73
|
+
return {
|
|
74
|
+
fingerprint: fingerprint || '',
|
|
75
|
+
database_name: database_name || '',
|
|
76
|
+
role_name: role_name || '',
|
|
77
|
+
publisher: publisher || '',
|
|
78
|
+
source_path: source_path || '',
|
|
79
|
+
// Empty string → undefined so orphan-detection's `unknown_meta`
|
|
80
|
+
// bucket triggers correctly when last_used_at was NULL on disk.
|
|
81
|
+
last_used_at: last_used_at && last_used_at.length > 0 ? last_used_at : undefined,
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SELECT every non-template database name from pg_database. Excludes
|
|
88
|
+
* the postgres-system DBs ('template0', 'template1', 'postgres').
|
|
89
|
+
*/
|
|
90
|
+
export function selectExistingDbs({ port = DEFAULT_PORT } = {}) {
|
|
91
|
+
const out = pgQuery({
|
|
92
|
+
db: DEFAULT_DB,
|
|
93
|
+
port,
|
|
94
|
+
sql: 'SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname',
|
|
95
|
+
captureStdout: true,
|
|
96
|
+
});
|
|
97
|
+
if (!out) return new Set();
|
|
98
|
+
const arr = out.split('\n').filter(Boolean).filter((d) => !SYSTEM_DBS.has(d));
|
|
99
|
+
return new Set(arr);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return the set of DB names that have ≥1 active connection in
|
|
104
|
+
* pg_stat_activity (excluding our own connection).
|
|
105
|
+
*/
|
|
106
|
+
export function selectActiveDbs({ port = DEFAULT_PORT } = {}) {
|
|
107
|
+
const out = pgQuery({
|
|
108
|
+
db: DEFAULT_DB,
|
|
109
|
+
port,
|
|
110
|
+
sql: [
|
|
111
|
+
'SELECT DISTINCT datname',
|
|
112
|
+
'FROM pg_stat_activity',
|
|
113
|
+
'WHERE datname IS NOT NULL',
|
|
114
|
+
' AND pid <> pg_backend_pid()',
|
|
115
|
+
].join('\n'),
|
|
116
|
+
captureStdout: true,
|
|
117
|
+
});
|
|
118
|
+
if (!out) return new Set();
|
|
119
|
+
return new Set(out.split('\n').filter(Boolean));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Terminate every backend connected to `database`, then `DROP DATABASE`
|
|
124
|
+
* + `DROP ROLE IF EXISTS`. Caller is responsible for orphan
|
|
125
|
+
* classification — this is the dumb side. We commit each drop in its
|
|
126
|
+
* own implicit transaction (psql -At -f - is autocommit) so a partial
|
|
127
|
+
* sweep leaves a consistent audit log.
|
|
128
|
+
*
|
|
129
|
+
* Returns `{ database, role }` for the audit writer.
|
|
130
|
+
*/
|
|
131
|
+
export function dropDatabase({ database, role, port = DEFAULT_PORT } = {}) {
|
|
132
|
+
if (typeof database !== 'string' || database.length === 0 || SYSTEM_DBS.has(database)) {
|
|
133
|
+
throw new Error(`dropDatabase: refusing to drop "${database}" (empty or system DB)`);
|
|
134
|
+
}
|
|
135
|
+
// 1. Disconnect everything from the doomed DB. SELECT-with-side-
|
|
136
|
+
// effect form so we can run it from any other DB.
|
|
137
|
+
pgQuery({
|
|
138
|
+
db: DEFAULT_DB,
|
|
139
|
+
port,
|
|
140
|
+
sql: [
|
|
141
|
+
'SELECT pg_terminate_backend(pid)',
|
|
142
|
+
'FROM pg_stat_activity',
|
|
143
|
+
`WHERE datname = ${quoteLiteral(database)}`,
|
|
144
|
+
' AND pid <> pg_backend_pid()',
|
|
145
|
+
].join('\n'),
|
|
146
|
+
});
|
|
147
|
+
// 2. DROP DATABASE — postgres rejects parameterized DDL, so we have
|
|
148
|
+
// to interpolate. database_name comes from pgserve_meta (which we
|
|
149
|
+
// write ourselves via deriveProvisionedNames) and SYSTEM_DBS is
|
|
150
|
+
// guarded above; the identifier surface is constrained.
|
|
151
|
+
pgQuery({
|
|
152
|
+
db: DEFAULT_DB,
|
|
153
|
+
port,
|
|
154
|
+
sql: `DROP DATABASE IF EXISTS ${quoteIdent(database)}`,
|
|
155
|
+
});
|
|
156
|
+
// 3. DROP ROLE — best-effort; a role may be shared across multiple
|
|
157
|
+
// DBs in older non-cohort installs, so we use IF EXISTS and don't
|
|
158
|
+
// fail the gc run if it can't be dropped (other DBs depend on it).
|
|
159
|
+
if (typeof role === 'string' && role.length > 0) {
|
|
160
|
+
try {
|
|
161
|
+
pgQuery({
|
|
162
|
+
db: DEFAULT_DB,
|
|
163
|
+
port,
|
|
164
|
+
sql: `DROP ROLE IF EXISTS ${quoteIdent(role)}`,
|
|
165
|
+
});
|
|
166
|
+
} catch {
|
|
167
|
+
/* role may be in use elsewhere; not fatal for gc's drop step */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { database, role };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* DELETE the row from `pgserve_meta`. Caller invokes after a successful
|
|
175
|
+
* `dropDatabase` so the next gc run doesn't re-find the same orphan.
|
|
176
|
+
*/
|
|
177
|
+
export function deleteMetaRow({ fingerprint, port = DEFAULT_PORT } = {}) {
|
|
178
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
179
|
+
throw new TypeError('deleteMetaRow: fingerprint must be a non-empty string');
|
|
180
|
+
}
|
|
181
|
+
pgQuery({
|
|
182
|
+
db: DEFAULT_DB,
|
|
183
|
+
port,
|
|
184
|
+
sql: `DELETE FROM public.${PGSERVE_META_TABLE} WHERE fingerprint = ${quoteLiteral(fingerprint)}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const __testInternals = Object.freeze({
|
|
189
|
+
quoteIdent,
|
|
190
|
+
quoteLiteral,
|
|
191
|
+
SYSTEM_DBS,
|
|
192
|
+
DEFAULT_PORT,
|
|
193
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared psql shellout primitive.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3
|
|
5
|
+
* (foundation for the gc + provision orchestration verbs).
|
|
6
|
+
*
|
|
7
|
+
* Why psql shellout vs. node-postgres:
|
|
8
|
+
* - matches the existing pattern in
|
|
9
|
+
* `src/upgrade/steps/cosign-meta-migration.js` (PR #79).
|
|
10
|
+
* - avoids the runtime cost of loading the `pg` driver in a CLI
|
|
11
|
+
* verb that runs once and exits.
|
|
12
|
+
* - no shell expansion: SQL goes through stdin, not a template
|
|
13
|
+
* string, so postgres-style `$$` blocks survive intact.
|
|
14
|
+
*
|
|
15
|
+
* Caller-supplied `db` defaults to `postgres`. The connection-discovery
|
|
16
|
+
* layer (admin.json + runtime.json) is the caller's responsibility —
|
|
17
|
+
* this module does not assume a host shape.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
export const PG_QUERY_DEFAULTS = Object.freeze({
|
|
23
|
+
port: 5432,
|
|
24
|
+
host: '127.0.0.1',
|
|
25
|
+
user: 'postgres',
|
|
26
|
+
db: 'postgres',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the PGPASSWORD value for a psql shell-out. Precedence:
|
|
31
|
+
*
|
|
32
|
+
* 1. caller-supplied `password` (explicit override; tests, callers
|
|
33
|
+
* that read from a known-secret store)
|
|
34
|
+
* 2. `process.env.PGPASSWORD` (operator-set env override; this is
|
|
35
|
+
* what `.pgpass`-style hosts already use today via shell wrappers)
|
|
36
|
+
* 3. literal `'postgres'` — matches the bootstrap password
|
|
37
|
+
* `pgserve install` configures on the local-loopback postgres.
|
|
38
|
+
* Required for fresh-install hosts where the env is unset and
|
|
39
|
+
* no `.pgpass` file exists yet (CV-1, 2026-05-09).
|
|
40
|
+
*
|
|
41
|
+
* Hardcoding the literal `'postgres'` fallback was deliberately AVOIDED
|
|
42
|
+
* in PR #92 (bot-review HIGH) on the reasoning that it would defeat
|
|
43
|
+
* `.pgpass` / peer-auth integration. CV-1 reframed that tradeoff:
|
|
44
|
+
*
|
|
45
|
+
* - peer-auth hosts use `host=<unix-socket-path>` which postgres
|
|
46
|
+
* treats as auth-method `peer` (or `trust` depending on `pg_hba`),
|
|
47
|
+
* so the PGPASSWORD value is ignored entirely; the literal default
|
|
48
|
+
* does no harm there.
|
|
49
|
+
* - `.pgpass`-using hosts can still override with explicit
|
|
50
|
+
* `PGPASSWORD=…` shell env, OR pass `password` explicitly through
|
|
51
|
+
* the function call. Their upstream wrapping is unchanged.
|
|
52
|
+
* - Fresh-install + TCP-loopback (the path CV-1 exercises) gets the
|
|
53
|
+
* literal that `pgserve install` already configured, instead of
|
|
54
|
+
* a `password authentication failed for user "postgres"` FATAL.
|
|
55
|
+
*/
|
|
56
|
+
export function resolvePgPassword({ password, envPassword = process.env.PGPASSWORD } = {}) {
|
|
57
|
+
if (password !== undefined) return password;
|
|
58
|
+
if (envPassword !== undefined) return envPassword;
|
|
59
|
+
return 'postgres';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a single SQL statement (or batch) via psql, fed through stdin
|
|
64
|
+
* (no shell expansion). Throws on non-zero exit. Returns stdout
|
|
65
|
+
* (trimmed when `captureStdout`).
|
|
66
|
+
*
|
|
67
|
+
* @param {object} args
|
|
68
|
+
* @param {string} args.sql SQL to execute
|
|
69
|
+
* @param {string} [args.db='postgres'] target database
|
|
70
|
+
* @param {number} [args.port=5432] postgres port
|
|
71
|
+
* @param {string} [args.host='127.0.0.1'] postgres host
|
|
72
|
+
* @param {string} [args.user='postgres'] postgres user
|
|
73
|
+
* @param {string} [args.password] PGPASSWORD; resolves via
|
|
74
|
+
* `resolvePgPassword()` —
|
|
75
|
+
* caller > env > 'postgres'
|
|
76
|
+
* @param {boolean} [args.captureStdout=false] trim + return stdout
|
|
77
|
+
* @returns {string} stdout (trimmed when `captureStdout`)
|
|
78
|
+
*/
|
|
79
|
+
export function pgQuery({
|
|
80
|
+
sql,
|
|
81
|
+
db = PG_QUERY_DEFAULTS.db,
|
|
82
|
+
port = PG_QUERY_DEFAULTS.port,
|
|
83
|
+
host = PG_QUERY_DEFAULTS.host,
|
|
84
|
+
user = PG_QUERY_DEFAULTS.user,
|
|
85
|
+
password,
|
|
86
|
+
captureStdout = false,
|
|
87
|
+
} = {}) {
|
|
88
|
+
if (typeof sql !== 'string' || sql.length === 0) {
|
|
89
|
+
throw new TypeError('pgQuery: sql must be a non-empty string');
|
|
90
|
+
}
|
|
91
|
+
// PGPASSWORD is always set; defaults to 'postgres' on fresh-install
|
|
92
|
+
// hosts where neither env nor caller supplied one (CV-1 fix). See
|
|
93
|
+
// `resolvePgPassword` docstring for the precedence rules + the
|
|
94
|
+
// updated reasoning about .pgpass / peer-auth host coexistence.
|
|
95
|
+
const env = { ...process.env, PGPASSWORD: resolvePgPassword({ password }) };
|
|
96
|
+
const result = spawnSync(
|
|
97
|
+
'psql',
|
|
98
|
+
[
|
|
99
|
+
'-h', host,
|
|
100
|
+
'-p', String(port),
|
|
101
|
+
'-U', user,
|
|
102
|
+
'-d', db,
|
|
103
|
+
// -At: tuples-only + unaligned. -F: explicit tab field separator
|
|
104
|
+
// (psql defaults to '|' for unaligned mode; callers split rows on
|
|
105
|
+
// '\t' so the separator MUST be '\t'). Bot review CRITICAL.
|
|
106
|
+
'-At', '-F', '\t',
|
|
107
|
+
// ON_ERROR_STOP=1: in script mode (-f), psql by default continues
|
|
108
|
+
// past statement errors and STILL exits 0. A failed CREATE
|
|
109
|
+
// DATABASE / GRANT could otherwise be invisible to the caller.
|
|
110
|
+
// Bot review CRITICAL P1.
|
|
111
|
+
'-v', 'ON_ERROR_STOP=1',
|
|
112
|
+
'-f', '-',
|
|
113
|
+
],
|
|
114
|
+
{ env, input: sql, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
115
|
+
);
|
|
116
|
+
if (result.status !== 0) {
|
|
117
|
+
const stderr = (result.stderr || Buffer.from('')).toString();
|
|
118
|
+
const err = new Error(`psql exited ${result.status}: ${stderr.trim()}`);
|
|
119
|
+
err.status = result.status;
|
|
120
|
+
err.stderr = stderr;
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const stdout = (result.stdout || Buffer.from('')).toString();
|
|
124
|
+
return captureStdout ? stdout.trim() : stdout;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Postgres identifier quoting: wrap in "..." and escape internal ".
|
|
129
|
+
* Used by callers that interpolate identifiers (DDL doesn't accept
|
|
130
|
+
* parameter binding for table / role / database names).
|
|
131
|
+
*/
|
|
132
|
+
export function quoteIdent(name) {
|
|
133
|
+
return `"${String(name).replace(/"/g, '""')}"`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Postgres literal quoting: wrap in '...' and escape internal '.
|
|
138
|
+
* Use this for any string literal that ends up in a DDL string;
|
|
139
|
+
* regular DML should bind parameters instead, but psql's stdin form
|
|
140
|
+
* doesn't accept those, so the safe path for our shellout is to
|
|
141
|
+
* always quoteLiteral defensively.
|
|
142
|
+
*/
|
|
143
|
+
export function quoteLiteral(value) {
|
|
144
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pg_advisory_lock` key derivation for `pgserve provision`.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 3.
|
|
5
|
+
*
|
|
6
|
+
* The wish locks the concurrency story: 10 simultaneous `pgserve
|
|
7
|
+
* provision <fingerprint>` calls must produce exactly 1 database. The
|
|
8
|
+
* server-side coordination is `pg_advisory_lock(key)`; this module
|
|
9
|
+
* derives the integer key from a fingerprint string deterministically.
|
|
10
|
+
*
|
|
11
|
+
* Why two forms:
|
|
12
|
+
* - `pg_advisory_lock(bigint)` — single 64-bit key
|
|
13
|
+
* - `pg_advisory_lock(int4, int4)` — two 32-bit keys (older
|
|
14
|
+
* postgres major versions on hosts the cohort still supports)
|
|
15
|
+
*
|
|
16
|
+
* Both are derived from the same sha256 of the fingerprint, so two
|
|
17
|
+
* provision processes against the same fingerprint will always pick
|
|
18
|
+
* the same lock regardless of which advisory-lock variant they call.
|
|
19
|
+
*
|
|
20
|
+
* Key derivation:
|
|
21
|
+
* - sha256(fingerprint) → 32 bytes
|
|
22
|
+
* - bigint key: reinterpret the first 8 bytes as a signed 64-bit
|
|
23
|
+
* integer, big-endian. The bigint form is what we
|
|
24
|
+
* recommend; the int4 form is provided for parity with
|
|
25
|
+
* callers that need it.
|
|
26
|
+
* - int4 pair: high 32 bits → key1, low 32 bits → key2 (both
|
|
27
|
+
* signed). This matches postgres's two-arg overload.
|
|
28
|
+
*
|
|
29
|
+
* Pure function: no postgres I/O, no network, no globals.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
const PGSERVE_NAMESPACE_TAG = 'pgserve-provision-v1:';
|
|
35
|
+
|
|
36
|
+
function sha256Bytes(input) {
|
|
37
|
+
return crypto.createHash('sha256').update(input, 'utf8').digest();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Derive the bigint advisory-lock key from a fingerprint string.
|
|
42
|
+
* Returned as a JS BigInt; postgres bigint accepts any signed 64-bit
|
|
43
|
+
* integer (range: -2^63 .. 2^63 - 1).
|
|
44
|
+
* @returns {BigInt}
|
|
45
|
+
*/
|
|
46
|
+
export function deriveBigintKey(fingerprint) {
|
|
47
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
48
|
+
throw new TypeError('deriveBigintKey: fingerprint must be a non-empty string');
|
|
49
|
+
}
|
|
50
|
+
const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
|
|
51
|
+
// Read first 8 bytes big-endian, signed.
|
|
52
|
+
const key = bytes.readBigInt64BE(0);
|
|
53
|
+
return key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive the (int4, int4) advisory-lock key pair. Returns plain Numbers
|
|
58
|
+
* in the signed 32-bit range so the caller can pass them straight to
|
|
59
|
+
* pg-cstring/pg-promise without BigInt coercion plumbing.
|
|
60
|
+
* @returns {{ key1: number, key2: number }}
|
|
61
|
+
*/
|
|
62
|
+
export function deriveInt4Pair(fingerprint) {
|
|
63
|
+
if (typeof fingerprint !== 'string' || fingerprint.length === 0) {
|
|
64
|
+
throw new TypeError('deriveInt4Pair: fingerprint must be a non-empty string');
|
|
65
|
+
}
|
|
66
|
+
const bytes = sha256Bytes(PGSERVE_NAMESPACE_TAG + fingerprint);
|
|
67
|
+
// High 32 bits → key1; low 32 bits → key2; both signed.
|
|
68
|
+
const key1 = bytes.readInt32BE(0);
|
|
69
|
+
const key2 = bytes.readInt32BE(4);
|
|
70
|
+
return { key1, key2 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convenience: returns the SQL fragment that acquires the lock with
|
|
75
|
+
* pg_advisory_xact_lock. pgserve provision wraps a transaction around
|
|
76
|
+
* its CREATE DATABASE / CREATE ROLE / INSERT, and xact-scoped locks
|
|
77
|
+
* release automatically when that transaction commits or rolls back —
|
|
78
|
+
* no risk of a dangling session-scoped lock surviving a crashed
|
|
79
|
+
* provision.
|
|
80
|
+
*
|
|
81
|
+
* Returns: `{ sql: '...', params: [BigInt] }`
|
|
82
|
+
*/
|
|
83
|
+
export function buildAdvisoryLockSql(fingerprint) {
|
|
84
|
+
const key = deriveBigintKey(fingerprint);
|
|
85
|
+
return {
|
|
86
|
+
sql: 'SELECT pg_advisory_xact_lock($1::bigint)',
|
|
87
|
+
params: [key],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const __testInternals = Object.freeze({ sha256Bytes, PGSERVE_NAMESPACE_TAG });
|