pgserve 2.6.0 → 2.6.4
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/CHANGELOG.md +181 -0
- package/README.md +20 -0
- package/bin/pgserve-wrapper.cjs +56 -2
- package/console/dist/app.js +7 -7
- package/package.json +1 -1
- package/scripts/aggregate-manifest.sh +42 -26
- package/scripts/verify-published-artifacts.sh +85 -32
- package/src/admin/admin-bootstrap.js +296 -0
- package/src/cli-config.cjs +37 -0
- package/src/cli-install.cjs +175 -0
- package/src/commands/create-app.js +387 -0
- package/src/commands/doctor.js +65 -0
- package/src/commands/gc.js +16 -1
- package/src/commands/verify.js +94 -4
- package/src/cosign/locked-roots.js +141 -0
- package/src/cosign/trust-list.js +29 -6
- package/src/cosign/verify-binary.js +162 -12
- package/src/gc/audit-log.js +92 -0
- package/src/postgres.js +16 -1
- package/src/schema/autopg-meta.js +120 -0
package/src/cli-install.cjs
CHANGED
|
@@ -24,9 +24,131 @@
|
|
|
24
24
|
const { spawnSync, execFileSync } = require('node:child_process');
|
|
25
25
|
const crypto = require('node:crypto');
|
|
26
26
|
const fs = require('node:fs');
|
|
27
|
+
const net = require('node:net');
|
|
27
28
|
const os = require('node:os');
|
|
28
29
|
const path = require('node:path');
|
|
29
30
|
|
|
31
|
+
// pgserve v2.6.1 — `pgserve install --help` should print usage + exit 0,
|
|
32
|
+
// not run the install (B2 HIGH from QA-RECIPE-B2.md). Single source of
|
|
33
|
+
// truth for the help text so the autopg + pgserve bin invocations show
|
|
34
|
+
// the same surface (Decision #7 of finalize wish).
|
|
35
|
+
const INSTALL_USAGE = `Usage:
|
|
36
|
+
pgserve install [options]
|
|
37
|
+
autopg install [options]
|
|
38
|
+
|
|
39
|
+
Register pgserve under pm2 (Tier A supervisor) with hardened defaults.
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--port <N> TCP port for postgres (default: 5432)
|
|
43
|
+
--data <path> Data directory (default: ~/.autopg/data)
|
|
44
|
+
--socket-dir <path> Unix socket directory (default: $XDG_RUNTIME_DIR/pgserve)
|
|
45
|
+
--ui-port <N> Port for the autopg UI process (default: 8433)
|
|
46
|
+
--ui-host <host> Bind host for the UI (default: 127.0.0.1)
|
|
47
|
+
--no-ui Skip the autopg-ui pm2 process (headless / CI)
|
|
48
|
+
--no-pm2 Skip pm2 registration entirely (Tier B / external supervisor)
|
|
49
|
+
--help, -h Show this help and exit
|
|
50
|
+
|
|
51
|
+
Idempotent: re-running with the same args is a no-op when the existing
|
|
52
|
+
admin.json + pm2 state already matches.
|
|
53
|
+
|
|
54
|
+
See \`pgserve --help\` for the full verb list.
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function printInstallUsage(stream = process.stdout) {
|
|
58
|
+
stream.write(INSTALL_USAGE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// pgserve v2.6.1 — `pgserve install` on a host where the chosen port is
|
|
62
|
+
// already in use must fail BEFORE pm2 / admin.json / data-dir side
|
|
63
|
+
// effects (B3 HIGH from QA-RECIPE-B3.md). Pre-flight bind-test on the
|
|
64
|
+
// canonical loopback the postmaster will use; on EADDRINUSE we fail
|
|
65
|
+
// fast with an operator-readable hint pointing at `--port`.
|
|
66
|
+
//
|
|
67
|
+
// Synchronous wrapper around net.createServer().listen() — uses
|
|
68
|
+
// child_process.spawnSync via a self-bind-then-close so the install
|
|
69
|
+
// flow stays synchronous. Returns null on success; throws an Error
|
|
70
|
+
// with `code='EADDRINUSE'` on collision.
|
|
71
|
+
async function assertPortAvailable(port, host = '127.0.0.1') {
|
|
72
|
+
// Test-only escape hatch. Production never sets this env var. Used by
|
|
73
|
+
// tests that assert on `port: 5432` literal output where the host
|
|
74
|
+
// running the test happens to have 5432 bound (dev workstations
|
|
75
|
+
// running a real pgserve). The port-pre-flight contract itself is
|
|
76
|
+
// covered end-to-end by the B3-collision test which intentionally
|
|
77
|
+
// does NOT set this env var so the pre-flight fires.
|
|
78
|
+
if (process.env.PGSERVE_TEST_SKIP_PORT_PREFLIGHT === '1') return null;
|
|
79
|
+
|
|
80
|
+
// Layer 1: connect-probe BOTH IPv4 (127.0.0.1) and IPv6 (::1).
|
|
81
|
+
// Postgres binds both loopback families on startup; any listener on
|
|
82
|
+
// either is an EADDRINUSE for the postmaster. A pure listen()-bind
|
|
83
|
+
// probe can miss this when the conflicting service was started with
|
|
84
|
+
// SO_REUSEADDR or only-one-family, so connect() is the primary check.
|
|
85
|
+
// If connect() succeeds → something is listening → port is busy.
|
|
86
|
+
for (const probeHost of [host, '::1']) {
|
|
87
|
+
const busy = await probePortListening(port, probeHost);
|
|
88
|
+
if (busy) {
|
|
89
|
+
const e = new Error(
|
|
90
|
+
`pgserve install: port ${port} is already in use on ${probeHost} (something is listening).\n` +
|
|
91
|
+
`Specify a different port with \`pgserve install --port <free>\`,\n` +
|
|
92
|
+
`or stop the process bound to ${port} first and retry.`,
|
|
93
|
+
);
|
|
94
|
+
e.code = 'EADDRINUSE';
|
|
95
|
+
throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Layer 2: bind-probe to confirm we ourselves can bind without
|
|
100
|
+
// SO_REUSEADDR conflicts. Catches the case where another process
|
|
101
|
+
// bound the same port with SO_REUSEADDR but isn't currently listening
|
|
102
|
+
// (rare but possible for transitional services).
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const server = net.createServer();
|
|
105
|
+
server.once('error', (err) => {
|
|
106
|
+
if (err.code === 'EADDRINUSE') {
|
|
107
|
+
const e = new Error(
|
|
108
|
+
`pgserve install: port ${port} is already in use on ${host} (EADDRINUSE).\n` +
|
|
109
|
+
`Specify a different port with \`pgserve install --port <free>\`,\n` +
|
|
110
|
+
`or stop the process bound to ${port} first and retry.`,
|
|
111
|
+
);
|
|
112
|
+
e.code = 'EADDRINUSE';
|
|
113
|
+
reject(e);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Non-EADDRINUSE errors (e.g. EACCES on privileged ports) — fail
|
|
117
|
+
// closed with the underlying message; no install side effects yet.
|
|
118
|
+
reject(err);
|
|
119
|
+
});
|
|
120
|
+
server.once('listening', () => {
|
|
121
|
+
server.close(() => resolve(null));
|
|
122
|
+
});
|
|
123
|
+
server.listen(port, host);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Promise that resolves true when something is accepting connections at
|
|
128
|
+
// host:port (port is busy), false on ECONNREFUSED (port is free), and
|
|
129
|
+
// rejects on any other error so callers can fail closed.
|
|
130
|
+
function probePortListening(port, host, timeoutMs = 500) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const socket = new net.Socket();
|
|
133
|
+
let settled = false;
|
|
134
|
+
const finish = (value, err) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
try { socket.destroy(); } catch { /* best-effort */ }
|
|
138
|
+
if (err) reject(err); else resolve(value);
|
|
139
|
+
};
|
|
140
|
+
socket.setTimeout(timeoutMs);
|
|
141
|
+
socket.once('connect', () => finish(true));
|
|
142
|
+
socket.once('timeout', () => finish(false)); // treat slow/no-response as free
|
|
143
|
+
socket.once('error', (err) => {
|
|
144
|
+
if (err.code === 'ECONNREFUSED') return finish(false);
|
|
145
|
+
if (err.code === 'EHOSTUNREACH' || err.code === 'EADDRNOTAVAIL') return finish(false); // IPv6 not configured, etc.
|
|
146
|
+
finish(false, err);
|
|
147
|
+
});
|
|
148
|
+
socket.connect(port, host);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
30
152
|
// pgserve singleton (v2.4): the cohort-shared admin-json + socket-dir
|
|
31
153
|
// helpers live in `src/lib/*.js` as ESM modules (project convention: new
|
|
32
154
|
// modules ship as .js / ESM). cli-install.cjs runs under node — which
|
|
@@ -693,6 +815,13 @@ function cmdAuthDispatch(args) {
|
|
|
693
815
|
* wrapper before this module is required (avoids re-resolving here).
|
|
694
816
|
*/
|
|
695
817
|
async function cmdInstall(args, ctx) {
|
|
818
|
+
// B2 (v2.6.1): `--help` / `-h` MUST short-circuit before any side
|
|
819
|
+
// effects (no pm2 spawn, no admin.json write, no data-dir create).
|
|
820
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
821
|
+
printInstallUsage();
|
|
822
|
+
process.exit(0);
|
|
823
|
+
}
|
|
824
|
+
|
|
696
825
|
const { adminJson, socketDirMod, blockedVersions } = await loadCohortModules();
|
|
697
826
|
|
|
698
827
|
// pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
|
|
@@ -732,6 +861,40 @@ async function cmdInstall(args, ctx) {
|
|
|
732
861
|
}
|
|
733
862
|
|
|
734
863
|
const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
|
|
864
|
+
|
|
865
|
+
// B3 (v2.6.1): pre-flight bind-test the chosen port BEFORE creating
|
|
866
|
+
// pm2 entries / admin.json / data dir. Without this, an operator on
|
|
867
|
+
// a host where 5432 is already occupied gets pm2 reporting `online`
|
|
868
|
+
// while the postmaster crashes silently — divergence between
|
|
869
|
+
// supervisor state and data-plane state. Fail fast with a clear hint.
|
|
870
|
+
try {
|
|
871
|
+
await assertPortAvailable(port);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
if (err.code === 'EADDRINUSE') {
|
|
874
|
+
process.stderr.write(`${err.message}\n`);
|
|
875
|
+
// CV103-2 (v2.6.2): drop the synchronous `process.exit(1)` here.
|
|
876
|
+
// QA's 9-variant repro on v2.6.1 isolated a stdio-pipe race:
|
|
877
|
+
// when stdout is piped (e.g. `pgserve install | cat`) but
|
|
878
|
+
// stderr is inherited, `process.exit(1)` runs Node's shutdown
|
|
879
|
+
// sequence faster than the libuv stderr buffer can flush, and
|
|
880
|
+
// Node has been observed to terminate with exit code 0 instead
|
|
881
|
+
// of 1. Node's own docs flag this:
|
|
882
|
+
//
|
|
883
|
+
// process.exit() will force the process to exit as quickly as
|
|
884
|
+
// possible … including I/O operations to process.stdout and
|
|
885
|
+
// process.stderr.
|
|
886
|
+
// (https://nodejs.org/api/process.html#processexitcode_1)
|
|
887
|
+
//
|
|
888
|
+
// Recommended pattern: set process.exitCode and let Node exit
|
|
889
|
+
// gracefully on its own once the event loop drains. We also
|
|
890
|
+
// throw so the wrapper's rejection handler can suppress its
|
|
891
|
+
// duplicate stderr write — see bin/pgserve-wrapper.cjs.
|
|
892
|
+
process.exitCode = 1;
|
|
893
|
+
throw err;
|
|
894
|
+
}
|
|
895
|
+
throw err;
|
|
896
|
+
}
|
|
897
|
+
|
|
735
898
|
const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
|
|
736
899
|
|
|
737
900
|
// Set up the canonical socket directory before pm2 launches the
|
|
@@ -1145,6 +1308,13 @@ function dispatch(subcommand, args, ctx) {
|
|
|
1145
1308
|
// idempotency-driven serialization (see provision.js header for
|
|
1146
1309
|
// why no advisory lock). Pure node + psql shellout.
|
|
1147
1310
|
return import('./commands/provision.js').then((mod) => mod.runProvision(args));
|
|
1311
|
+
case 'create-app':
|
|
1312
|
+
// autopg-distribution-cutover-finalize (v2.6) — wish Group 3.
|
|
1313
|
+
// Registers a consumer slug + writes admin.json/manifest.json,
|
|
1314
|
+
// freezing TRUSTED_IDENTITIES into autopg_meta.locked_roots at
|
|
1315
|
+
// create time. Idempotent: re-runs touch last_updated only and
|
|
1316
|
+
// preserve the original locked_roots snapshot (BRIEF v5 A6).
|
|
1317
|
+
return import('./commands/create-app.js').then((mod) => mod.runCreateApp(args));
|
|
1148
1318
|
default:
|
|
1149
1319
|
throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
|
|
1150
1320
|
}
|
|
@@ -1153,6 +1323,11 @@ function dispatch(subcommand, args, ctx) {
|
|
|
1153
1323
|
module.exports = {
|
|
1154
1324
|
// Public API for the wrapper.
|
|
1155
1325
|
dispatch,
|
|
1326
|
+
// B2 + B3: surfaced so unit tests can drive helpers without the
|
|
1327
|
+
// full install side-effect chain.
|
|
1328
|
+
printInstallUsage,
|
|
1329
|
+
assertPortAvailable,
|
|
1330
|
+
INSTALL_USAGE,
|
|
1156
1331
|
// Auth surface used by cli-ui.cjs.
|
|
1157
1332
|
verifyAdminPassword,
|
|
1158
1333
|
getAdminFilePath,
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pgserve create-app <slug>` — autopg-distribution-cutover-finalize G3 verb.
|
|
3
|
+
*
|
|
4
|
+
* Registers a consumer app with pgserve:
|
|
5
|
+
* 1. Bootstraps the `public.autopg_meta` table (idempotent IF NOT EXISTS).
|
|
6
|
+
* 2. INSERTs a row keyed by sanitized slug, freezing the current
|
|
7
|
+
* `TRUSTED_IDENTITIES` snapshot into `locked_roots` JSONB at the
|
|
8
|
+
* moment of creation (the LOCK).
|
|
9
|
+
* 3. Writes the per-consumer cache pair to disk:
|
|
10
|
+
* ~/.autopg/<slug>/admin.json (mode 0600)
|
|
11
|
+
* ~/.autopg/<slug>/manifest.json (mode 0600)
|
|
12
|
+
* under a 0700 directory.
|
|
13
|
+
*
|
|
14
|
+
* Idempotency contract (BRIEF v5 A6):
|
|
15
|
+
* On re-run with the same slug, the verb touches `last_updated` only.
|
|
16
|
+
* It does NOT re-lock `locked_roots` to the current TRUSTED_IDENTITIES
|
|
17
|
+
* — the original snapshot from first-create is preserved. This is what
|
|
18
|
+
* makes the upgrade-after-trust-rotation invariant pass: an existing
|
|
19
|
+
* slug's verifier continues to use its frozen lock even after operators
|
|
20
|
+
* mutate the live trust list via `pgserve trust add` / `remove`.
|
|
21
|
+
*
|
|
22
|
+
* Composes:
|
|
23
|
+
* - src/schema/autopg-meta.js → bootstrapAutopgMeta + columns
|
|
24
|
+
* - src/admin/admin-bootstrap.js → bootstrapConsumerAdmin
|
|
25
|
+
* - src/cosign/trust-list.js → TRUSTED_IDENTITIES (the lock)
|
|
26
|
+
* - src/lib/admin-json.js → readAdminJson (port discovery)
|
|
27
|
+
* - src/lib/pg-query.js → pgQuery + quoteLiteral
|
|
28
|
+
*
|
|
29
|
+
* Exit codes:
|
|
30
|
+
* 0 registered (or no-op idempotent re-run)
|
|
31
|
+
* 1 user error (bad flags, empty slug, slug sanitizes to empty)
|
|
32
|
+
* 2 pgserve postmaster unreachable / cannot bootstrap autopg_meta
|
|
33
|
+
* 3 postgres error during create / select / update sequence
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readAdminJson } from '../lib/admin-json.js';
|
|
37
|
+
import { bootstrapAutopgMeta } from '../schema/autopg-meta.js';
|
|
38
|
+
import { bootstrapConsumerAdmin } from '../admin/admin-bootstrap.js';
|
|
39
|
+
import { TRUSTED_IDENTITIES } from '../cosign/trust-list.js';
|
|
40
|
+
import { pgQuery, quoteLiteral } from '../lib/pg-query.js';
|
|
41
|
+
import { sanitizeSlug } from '../provision/db-naming.js';
|
|
42
|
+
|
|
43
|
+
const USAGE = `Usage: pgserve create-app <slug> [options]
|
|
44
|
+
|
|
45
|
+
<slug> consumer slug (sanitized via sanitizeSlug;
|
|
46
|
+
e.g. "@demo/app" -> "demo_app").
|
|
47
|
+
|
|
48
|
+
--port <N> override the postgres port (default: read
|
|
49
|
+
~/.autopg/admin.json or 5432).
|
|
50
|
+
--json emit a JSON summary on stdout.
|
|
51
|
+
-h, --help show this help.
|
|
52
|
+
|
|
53
|
+
Idempotent: re-running with the same slug touches last_updated only.
|
|
54
|
+
The locked_roots snapshot from first-create is preserved — this is what
|
|
55
|
+
keeps existing consumers verifiable after operator-driven trust rotation.
|
|
56
|
+
|
|
57
|
+
Source-of-truth split:
|
|
58
|
+
public.autopg_meta is authoritative.
|
|
59
|
+
The per-consumer admin.json + manifest.json are derived caches.
|
|
60
|
+
On divergence, re-run \`pgserve create-app <slug>\` to regenerate them
|
|
61
|
+
from the table (the v2.4 read-only doctor surface flags divergence;
|
|
62
|
+
--fix tiered modes are deferred to a future wave).`;
|
|
63
|
+
|
|
64
|
+
function parseFlags(argv) {
|
|
65
|
+
const out = { json: false, port: undefined, positional: [] };
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
switch (a) {
|
|
69
|
+
case '--json':
|
|
70
|
+
out.json = true;
|
|
71
|
+
break;
|
|
72
|
+
case '--help':
|
|
73
|
+
case '-h':
|
|
74
|
+
out.help = true;
|
|
75
|
+
break;
|
|
76
|
+
case '--port':
|
|
77
|
+
case '-p': {
|
|
78
|
+
const v = Number(argv[++i]);
|
|
79
|
+
if (!Number.isInteger(v) || v <= 0 || v > 65535) {
|
|
80
|
+
throw new Error('--port requires an integer in [1, 65535]');
|
|
81
|
+
}
|
|
82
|
+
out.port = v;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
if (a.startsWith('--')) {
|
|
87
|
+
throw new Error(`unknown flag: ${a}`);
|
|
88
|
+
}
|
|
89
|
+
out.positional.push(a);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolvePort(opts) {
|
|
96
|
+
if (typeof opts.port === 'number') return opts.port;
|
|
97
|
+
try {
|
|
98
|
+
const admin = readAdminJson();
|
|
99
|
+
if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
|
|
100
|
+
} catch {
|
|
101
|
+
/* admin.json absent — fall through */
|
|
102
|
+
}
|
|
103
|
+
return 5432;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Adapter: shape `pgQuery` to the node-postgres-compatible
|
|
108
|
+
* `client.query(sql)` contract that bootstrapAutopgMeta expects.
|
|
109
|
+
* Mirrors src/commands/provision.js#makePsqlClient.
|
|
110
|
+
*/
|
|
111
|
+
function makePsqlClient({ port, db }) {
|
|
112
|
+
return {
|
|
113
|
+
query: async (sql) => pgQuery({ sql, port, db }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function ensureAutopgMetaSchema({ port }) {
|
|
118
|
+
const client = makePsqlClient({ port, db: 'postgres' });
|
|
119
|
+
await bootstrapAutopgMeta(client);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Look up an existing autopg_meta row for the slug. Returns
|
|
124
|
+
* `{ slug, manifestPath, lockedRoots, createdAt, lastUpdated }` or null.
|
|
125
|
+
*
|
|
126
|
+
* `locked_roots` is JSONB; psql returns it as a JSON string, parsed
|
|
127
|
+
* here. Timestamps are returned as ISO 8601 (psql `TIMESTAMPTZ` default
|
|
128
|
+
* format); we re-emit them through `new Date().toISOString()` so the
|
|
129
|
+
* cache-write side gets a stable shape.
|
|
130
|
+
*/
|
|
131
|
+
function selectAutopgMetaRow({ port, slug }) {
|
|
132
|
+
const out = pgQuery({
|
|
133
|
+
sql: [
|
|
134
|
+
'SELECT',
|
|
135
|
+
" slug,",
|
|
136
|
+
" manifest_path,",
|
|
137
|
+
" locked_roots::text,",
|
|
138
|
+
" to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),",
|
|
139
|
+
" to_char(last_updated AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')",
|
|
140
|
+
'FROM public.autopg_meta',
|
|
141
|
+
`WHERE slug = ${quoteLiteral(slug)}`,
|
|
142
|
+
'LIMIT 1',
|
|
143
|
+
].join('\n'),
|
|
144
|
+
port,
|
|
145
|
+
captureStdout: true,
|
|
146
|
+
});
|
|
147
|
+
if (!out) return null;
|
|
148
|
+
const [foundSlug, manifestPath, lockedRootsJson, createdAt, lastUpdated] = out.split('\t');
|
|
149
|
+
if (!foundSlug) return null;
|
|
150
|
+
let lockedRoots;
|
|
151
|
+
try {
|
|
152
|
+
lockedRoots = JSON.parse(lockedRootsJson);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const wrap = new Error(
|
|
155
|
+
`pgserve create-app: failed to parse locked_roots for slug "${foundSlug}": ${err.message}`,
|
|
156
|
+
);
|
|
157
|
+
wrap.cause = err;
|
|
158
|
+
throw wrap;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
slug: foundSlug,
|
|
162
|
+
manifestPath,
|
|
163
|
+
lockedRoots,
|
|
164
|
+
createdAt,
|
|
165
|
+
lastUpdated,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function insertAutopgMetaRow({ port, slug, manifestPath, lockedRoots, nowIso }) {
|
|
170
|
+
pgQuery({
|
|
171
|
+
sql: [
|
|
172
|
+
'INSERT INTO public.autopg_meta',
|
|
173
|
+
' (slug, manifest_path, locked_roots, created_at, last_updated)',
|
|
174
|
+
'VALUES (',
|
|
175
|
+
` ${quoteLiteral(slug)},`,
|
|
176
|
+
` ${quoteLiteral(manifestPath)},`,
|
|
177
|
+
` ${quoteLiteral(JSON.stringify(lockedRoots))}::jsonb,`,
|
|
178
|
+
` ${quoteLiteral(nowIso)}::timestamptz,`,
|
|
179
|
+
` ${quoteLiteral(nowIso)}::timestamptz`,
|
|
180
|
+
')',
|
|
181
|
+
].join('\n'),
|
|
182
|
+
port,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function touchAutopgMetaRow({ port, slug, manifestPath, nowIso }) {
|
|
187
|
+
// Update `last_updated` + `manifest_path` (the latter may have shifted
|
|
188
|
+
// if the operator re-ran with a different AUTOPG_CONFIG_DIR). Crucially
|
|
189
|
+
// does NOT touch `locked_roots` — that's the lock-preservation
|
|
190
|
+
// invariant per BRIEF v5 A6.
|
|
191
|
+
pgQuery({
|
|
192
|
+
sql: [
|
|
193
|
+
'UPDATE public.autopg_meta SET',
|
|
194
|
+
` manifest_path = ${quoteLiteral(manifestPath)},`,
|
|
195
|
+
` last_updated = ${quoteLiteral(nowIso)}::timestamptz`,
|
|
196
|
+
`WHERE slug = ${quoteLiteral(slug)}`,
|
|
197
|
+
].join('\n'),
|
|
198
|
+
port,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function deepCloneRoots(lockedRoots) {
|
|
203
|
+
return JSON.parse(JSON.stringify(lockedRoots));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function emit({ json, summary, humanLines }) {
|
|
207
|
+
if (json) {
|
|
208
|
+
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
209
|
+
} else {
|
|
210
|
+
for (const line of humanLines) process.stdout.write(`${line}\n`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function runCreateApp(argv = []) {
|
|
215
|
+
let opts;
|
|
216
|
+
try {
|
|
217
|
+
opts = parseFlags(argv);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
process.stderr.write(`pgserve create-app: ${err.message}\n\n${USAGE}\n`);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
if (opts.help) {
|
|
223
|
+
process.stdout.write(`${USAGE}\n`);
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const inputSlug = opts.positional[0];
|
|
227
|
+
if (typeof inputSlug !== 'string' || inputSlug.trim().length === 0) {
|
|
228
|
+
process.stderr.write(`pgserve create-app: <slug> is required\n\n${USAGE}\n`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
const sanitized = sanitizeSlug(inputSlug);
|
|
232
|
+
if (sanitized.length === 0) {
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`pgserve create-app: slug "${inputSlug}" sanitizes to empty; pick a slug `
|
|
235
|
+
+ 'with at least one alphanumeric character\n',
|
|
236
|
+
);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const port = resolvePort(opts);
|
|
241
|
+
const summary = {
|
|
242
|
+
slug: sanitized,
|
|
243
|
+
inputSlug,
|
|
244
|
+
port,
|
|
245
|
+
action: 'unknown',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Step 1 — bootstrap the autopg_meta table.
|
|
249
|
+
try {
|
|
250
|
+
await ensureAutopgMetaSchema({ port });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
process.stderr.write(`pgserve create-app: cannot bootstrap autopg_meta: ${err.message}\n`);
|
|
253
|
+
summary.action = 'error';
|
|
254
|
+
summary.error = err.message;
|
|
255
|
+
if (opts.json) emit({ json: true, summary });
|
|
256
|
+
return 2;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Step 2 — look for an existing row.
|
|
260
|
+
let existing;
|
|
261
|
+
try {
|
|
262
|
+
existing = selectAutopgMetaRow({ port, slug: sanitized });
|
|
263
|
+
} catch (err) {
|
|
264
|
+
process.stderr.write(`pgserve create-app: ${err.message}\n`);
|
|
265
|
+
summary.action = 'error';
|
|
266
|
+
summary.error = err.message;
|
|
267
|
+
if (opts.json) emit({ json: true, summary });
|
|
268
|
+
return 3;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const nowIso = new Date().toISOString();
|
|
272
|
+
|
|
273
|
+
if (existing) {
|
|
274
|
+
// Idempotent re-run path. Preserve `locked_roots` from the table
|
|
275
|
+
// (do NOT re-snapshot live TRUSTED_IDENTITIES — BRIEF v5 A6 lock
|
|
276
|
+
// preservation). Touch last_updated; rewrite the cache files.
|
|
277
|
+
let writeResult;
|
|
278
|
+
try {
|
|
279
|
+
writeResult = bootstrapConsumerAdmin({
|
|
280
|
+
slug: inputSlug,
|
|
281
|
+
lockedRoots: existing.lockedRoots,
|
|
282
|
+
createdAt: existing.createdAt,
|
|
283
|
+
lastUpdated: nowIso,
|
|
284
|
+
});
|
|
285
|
+
} catch (err) {
|
|
286
|
+
process.stderr.write(`pgserve create-app: failed to write cache files: ${err.message}\n`);
|
|
287
|
+
summary.action = 'error';
|
|
288
|
+
summary.error = err.message;
|
|
289
|
+
if (opts.json) emit({ json: true, summary });
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
touchAutopgMetaRow({
|
|
295
|
+
port,
|
|
296
|
+
slug: sanitized,
|
|
297
|
+
manifestPath: writeResult.manifestPath,
|
|
298
|
+
nowIso,
|
|
299
|
+
});
|
|
300
|
+
} catch (err) {
|
|
301
|
+
process.stderr.write(`pgserve create-app: ${err.message}\n`);
|
|
302
|
+
summary.action = 'error';
|
|
303
|
+
summary.error = err.message;
|
|
304
|
+
if (opts.json) emit({ json: true, summary });
|
|
305
|
+
return 3;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
summary.action = 'touched';
|
|
309
|
+
summary.createdAt = existing.createdAt;
|
|
310
|
+
summary.lastUpdated = nowIso;
|
|
311
|
+
summary.lockedRoots = existing.lockedRoots;
|
|
312
|
+
summary.adminPath = writeResult.adminPath;
|
|
313
|
+
summary.manifestPath = writeResult.manifestPath;
|
|
314
|
+
emit({
|
|
315
|
+
json: opts.json,
|
|
316
|
+
summary,
|
|
317
|
+
humanLines: [
|
|
318
|
+
`pgserve create-app: slug "${sanitized}" already registered (touched).`,
|
|
319
|
+
` locked_roots preserved (${existing.lockedRoots.length} entries from createdAt=${existing.createdAt})`,
|
|
320
|
+
` admin: ${writeResult.adminPath}`,
|
|
321
|
+
` manifest: ${writeResult.manifestPath}`,
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Step 3 — fresh registration. Snapshot TRUSTED_IDENTITIES into the
|
|
328
|
+
// table + cache files. The deep-clone strips Object.freeze wrappers
|
|
329
|
+
// so the JSONB write is plain JSON.
|
|
330
|
+
const lockedRoots = deepCloneRoots(TRUSTED_IDENTITIES);
|
|
331
|
+
|
|
332
|
+
let writeResult;
|
|
333
|
+
try {
|
|
334
|
+
writeResult = bootstrapConsumerAdmin({
|
|
335
|
+
slug: inputSlug,
|
|
336
|
+
lockedRoots,
|
|
337
|
+
createdAt: nowIso,
|
|
338
|
+
lastUpdated: nowIso,
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
process.stderr.write(`pgserve create-app: failed to write cache files: ${err.message}\n`);
|
|
342
|
+
summary.action = 'error';
|
|
343
|
+
summary.error = err.message;
|
|
344
|
+
if (opts.json) emit({ json: true, summary });
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
insertAutopgMetaRow({
|
|
350
|
+
port,
|
|
351
|
+
slug: sanitized,
|
|
352
|
+
manifestPath: writeResult.manifestPath,
|
|
353
|
+
lockedRoots,
|
|
354
|
+
nowIso,
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
process.stderr.write(`pgserve create-app: ${err.message}\n`);
|
|
358
|
+
summary.action = 'error';
|
|
359
|
+
summary.error = err.message;
|
|
360
|
+
if (opts.json) emit({ json: true, summary });
|
|
361
|
+
return 3;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
summary.action = 'created';
|
|
365
|
+
summary.createdAt = nowIso;
|
|
366
|
+
summary.lastUpdated = nowIso;
|
|
367
|
+
summary.lockedRoots = lockedRoots;
|
|
368
|
+
summary.adminPath = writeResult.adminPath;
|
|
369
|
+
summary.manifestPath = writeResult.manifestPath;
|
|
370
|
+
emit({
|
|
371
|
+
json: opts.json,
|
|
372
|
+
summary,
|
|
373
|
+
humanLines: [
|
|
374
|
+
`pgserve create-app: registered slug "${sanitized}".`,
|
|
375
|
+
` locked_roots: snapshotted ${lockedRoots.length} entries from TRUSTED_IDENTITIES`,
|
|
376
|
+
` admin: ${writeResult.adminPath}`,
|
|
377
|
+
` manifest: ${writeResult.manifestPath}`,
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export const __testInternals = Object.freeze({
|
|
384
|
+
parseFlags,
|
|
385
|
+
resolvePort,
|
|
386
|
+
deepCloneRoots,
|
|
387
|
+
});
|
package/src/commands/doctor.js
CHANGED
|
@@ -42,6 +42,7 @@ import { getAdminFilePath, readAdminJson, SUPERVISOR_VALUES } from '../lib/admin
|
|
|
42
42
|
import { resolveSocketDir } from '../lib/socket-dir.js';
|
|
43
43
|
import { readRuntimeJson, isLiveRuntime } from '../lib/runtime-json.js';
|
|
44
44
|
import { findBlocked } from '../security/blocked-versions.js';
|
|
45
|
+
import { pgQuery } from '../lib/pg-query.js';
|
|
45
46
|
|
|
46
47
|
const SEVERITY = Object.freeze({ PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL' });
|
|
47
48
|
|
|
@@ -374,6 +375,68 @@ function checkTcpReachable(admin) {
|
|
|
374
375
|
});
|
|
375
376
|
}
|
|
376
377
|
|
|
378
|
+
/**
|
|
379
|
+
* B7 (v2.6.3): surface the audit posture as a doctor finding so operators
|
|
380
|
+
* know whether their postmaster is running pgaudit (structured audit
|
|
381
|
+
* classes) or the log_statement=all fallback (capture-everything via
|
|
382
|
+
* postgres-native logging). Pre-fix the fallback fired silently — the
|
|
383
|
+
* WARN appeared once at startup in pm2 logs and was lost to operators
|
|
384
|
+
* who didn't tail the logs at the right moment.
|
|
385
|
+
*
|
|
386
|
+
* PASS pgaudit shows up in shared_preload_libraries on the live postmaster
|
|
387
|
+
* WARN pgaudit absent (fallback active) OR cannot probe (postmaster
|
|
388
|
+
* unreachable / psql missing)
|
|
389
|
+
*
|
|
390
|
+
* The check fires SQL via the cohort-canonical pgQuery (psql shellout),
|
|
391
|
+
* so it inherits its env contract (PGPASSWORD literal-postgres fallback
|
|
392
|
+
* for fresh-install hosts per CV-1) and times out via psql's own
|
|
393
|
+
* connection timeout. Failure modes are mapped to WARN, never FAIL —
|
|
394
|
+
* this is a posture diagnostic, not a connectivity gate.
|
|
395
|
+
*/
|
|
396
|
+
function checkPgauditLoaded(admin) {
|
|
397
|
+
if (!admin || !Number.isInteger(admin.port) || admin.port <= 0) {
|
|
398
|
+
return check(
|
|
399
|
+
'pgaudit_loaded',
|
|
400
|
+
'audit posture not probed',
|
|
401
|
+
SEVERITY.WARN,
|
|
402
|
+
'admin.json missing or has no port; cannot connect to postmaster to probe SHOW shared_preload_libraries',
|
|
403
|
+
'run `pgserve install` (Tier A) or `autopg service install` (Tier B) first',
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
let preloadLibs;
|
|
407
|
+
try {
|
|
408
|
+
preloadLibs = pgQuery({
|
|
409
|
+
sql: 'SHOW shared_preload_libraries',
|
|
410
|
+
port: admin.port,
|
|
411
|
+
captureStdout: true,
|
|
412
|
+
});
|
|
413
|
+
} catch (err) {
|
|
414
|
+
return check(
|
|
415
|
+
'pgaudit_loaded',
|
|
416
|
+
'audit posture probe failed',
|
|
417
|
+
SEVERITY.WARN,
|
|
418
|
+
`psql shellout failed: ${err?.stderr?.trim?.() || err?.message || err}`,
|
|
419
|
+
'verify postmaster is up (`pgserve status`) and psql is on PATH',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
const value = (preloadLibs || '').trim();
|
|
423
|
+
if (/(^|,)\s*pgaudit\s*(,|$)/i.test(value)) {
|
|
424
|
+
return check(
|
|
425
|
+
'pgaudit_loaded',
|
|
426
|
+
'pgaudit loaded — structured audit posture',
|
|
427
|
+
SEVERITY.PASS,
|
|
428
|
+
`shared_preload_libraries=${value || '(empty)'}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return check(
|
|
432
|
+
'pgaudit_loaded',
|
|
433
|
+
'pgaudit NOT loaded — fallback to log_statement=all',
|
|
434
|
+
SEVERITY.WARN,
|
|
435
|
+
`shared_preload_libraries=${value || '(empty)'}; audit data captured via log_statement=all (postgres-native), not pgaudit's structured classes`,
|
|
436
|
+
'bundle pgaudit.so with the embedded postgres binaries (Branch A in QA-RECIPE-B7.md) to switch the postmaster onto pgaudit; the current fallback is functional for compliance but noisier than pgaudit',
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
377
440
|
// ─── orchestration ────────────────────────────────────────────────────
|
|
378
441
|
|
|
379
442
|
/**
|
|
@@ -392,6 +455,7 @@ export async function runChecks() {
|
|
|
392
455
|
findings.push(checkSupervisorLiveness(admin));
|
|
393
456
|
findings.push(checkRuntimeJson(admin));
|
|
394
457
|
findings.push(await checkUdsReachable(admin));
|
|
458
|
+
findings.push(checkPgauditLoaded(admin));
|
|
395
459
|
findings.push(await checkTcpReachable(admin));
|
|
396
460
|
|
|
397
461
|
return findings;
|
|
@@ -460,6 +524,7 @@ export const __testInternals = Object.freeze({
|
|
|
460
524
|
checkAdminJsonShape,
|
|
461
525
|
checkRuntimeJson,
|
|
462
526
|
checkSupervisorLiveness,
|
|
527
|
+
checkPgauditLoaded,
|
|
463
528
|
exitCodeFor,
|
|
464
529
|
SEVERITY,
|
|
465
530
|
});
|