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/gc/audit-log.js
CHANGED
|
@@ -29,6 +29,8 @@ export const AUDIT_DIR_NAME = 'audit';
|
|
|
29
29
|
export const AUDIT_FILE_PREFIX = 'gc-';
|
|
30
30
|
export const AUDIT_FILE_MODE = 0o600;
|
|
31
31
|
export const AUDIT_DIR_MODE = 0o700;
|
|
32
|
+
export const AUDIT_DEFAULT_RETENTION_DAYS = 90;
|
|
33
|
+
const AUDIT_FILENAME_RE = /^gc-(\d{4})-(\d{2})-(\d{2})\.log$/;
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* @typedef {Object} GcAuditEvent
|
|
@@ -147,4 +149,94 @@ export function readGcAuditDay({ homeDir = os.homedir(), date = new Date() } = {
|
|
|
147
149
|
return out;
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Rotate audit logs by deleting `gc-<YYYY-MM-DD>.log` files older than
|
|
154
|
+
* `retentionDays` (default 90). Each deletion writes a `rotate` audit event
|
|
155
|
+
* to today's log so the rotation itself is auditable.
|
|
156
|
+
*
|
|
157
|
+
* Boundary guard: NEVER deletes the current day's log file, even if the
|
|
158
|
+
* retention math would otherwise include it (clock skew, manual mtime edits,
|
|
159
|
+
* timezone-confusion regressions). The current day is determined from
|
|
160
|
+
* `today` (UTC) — the same date the next `writeGcAudit` call would use.
|
|
161
|
+
*
|
|
162
|
+
* Files whose names do not match `gc-<YYYY-MM-DD>.log` are skipped — the
|
|
163
|
+
* rotator only touches its own log files.
|
|
164
|
+
*
|
|
165
|
+
* Returns `{ deleted: string[], kept: string[], errors: Array<{file,error}> }`
|
|
166
|
+
* so callers can surface a summary line without re-walking the directory.
|
|
167
|
+
*/
|
|
168
|
+
export function rotateGcAuditLogs({
|
|
169
|
+
homeDir = os.homedir(),
|
|
170
|
+
retentionDays = AUDIT_DEFAULT_RETENTION_DAYS,
|
|
171
|
+
today = new Date(),
|
|
172
|
+
} = {}) {
|
|
173
|
+
if (!Number.isFinite(retentionDays) || retentionDays < 0) {
|
|
174
|
+
throw new TypeError('rotateGcAuditLogs: retentionDays must be a non-negative number');
|
|
175
|
+
}
|
|
176
|
+
if (!(today instanceof Date) || Number.isNaN(today.getTime())) {
|
|
177
|
+
throw new TypeError('rotateGcAuditLogs: today must be a valid Date');
|
|
178
|
+
}
|
|
179
|
+
const dir = getAuditDir({ homeDir });
|
|
180
|
+
const result = { deleted: [], kept: [], errors: [] };
|
|
181
|
+
let entries;
|
|
182
|
+
try {
|
|
183
|
+
entries = fs.readdirSync(dir);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (err.code === 'ENOENT') return result;
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
const todayStr = formatUtcDate(today);
|
|
189
|
+
const cutoffMs = Date.UTC(
|
|
190
|
+
today.getUTCFullYear(),
|
|
191
|
+
today.getUTCMonth(),
|
|
192
|
+
today.getUTCDate(),
|
|
193
|
+
) - retentionDays * 24 * 60 * 60 * 1000;
|
|
194
|
+
for (const name of entries) {
|
|
195
|
+
const match = AUDIT_FILENAME_RE.exec(name);
|
|
196
|
+
if (!match) continue;
|
|
197
|
+
const [, yyyy, mm, dd] = match;
|
|
198
|
+
const dateStr = `${yyyy}-${mm}-${dd}`;
|
|
199
|
+
if (dateStr === todayStr) {
|
|
200
|
+
// Boundary guard — the current day's log is always retained.
|
|
201
|
+
result.kept.push(name);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const fileMs = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd));
|
|
205
|
+
if (fileMs >= cutoffMs) {
|
|
206
|
+
result.kept.push(name);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const full = path.join(dir, name);
|
|
210
|
+
try {
|
|
211
|
+
fs.unlinkSync(full);
|
|
212
|
+
result.deleted.push(name);
|
|
213
|
+
writeGcAudit(
|
|
214
|
+
{
|
|
215
|
+
action: 'rotate',
|
|
216
|
+
detail: `deleted ${name} (>${retentionDays} days old)`,
|
|
217
|
+
},
|
|
218
|
+
{ homeDir, date: today },
|
|
219
|
+
);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
result.errors.push({ file: name, error: err.message });
|
|
222
|
+
// Record the per-file failure in today's audit log so an operator
|
|
223
|
+
// investigating "why is gc-2026-01-01.log still here?" can find the
|
|
224
|
+
// exact reason (permission denied / file in use / etc.) instead of
|
|
225
|
+
// only seeing the rotate-summary count.
|
|
226
|
+
try {
|
|
227
|
+
writeGcAudit(
|
|
228
|
+
{
|
|
229
|
+
action: 'rotate-error',
|
|
230
|
+
detail: `failed to delete ${name}: ${err.message}`,
|
|
231
|
+
},
|
|
232
|
+
{ homeDir, date: today },
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
/* best-effort — never let audit-write failure abort the rotation pass */
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
150
242
|
export const __testInternals = Object.freeze({ formatUtcDate });
|
package/src/postgres.js
CHANGED
|
@@ -1049,8 +1049,23 @@ export class PostgresManager extends EventEmitter {
|
|
|
1049
1049
|
if (!expected) {
|
|
1050
1050
|
this.socketDir = null;
|
|
1051
1051
|
this.databaseDir = null;
|
|
1052
|
+
// B5 (v2.6.3): include the captured postgres stderr/stdout tail
|
|
1053
|
+
// in the WARN so operators tailing pm2 logs see the actual cause
|
|
1054
|
+
// (e.g. "FATAL: could not bind IPv4 address ... Address already
|
|
1055
|
+
// in use", "FATAL: data directory ... has wrong ownership", etc.)
|
|
1056
|
+
// instead of just "subprocess exited unexpectedly". The tail is
|
|
1057
|
+
// capped at 4 KB so a runaway log spam can't bloat the WARN
|
|
1058
|
+
// payload; the bottom of startupOutput is where the fatal
|
|
1059
|
+
// diagnostic typically lands. Empty string when postgres exited
|
|
1060
|
+
// before producing any output (rare; preserved as 'no postgres
|
|
1061
|
+
// output captured' so the field stays present in structured
|
|
1062
|
+
// logs).
|
|
1063
|
+
const STDERR_TAIL_BUDGET = 4096;
|
|
1064
|
+
const tail = startupOutput.length > STDERR_TAIL_BUDGET
|
|
1065
|
+
? startupOutput.slice(-STDERR_TAIL_BUDGET)
|
|
1066
|
+
: startupOutput;
|
|
1052
1067
|
this.logger?.warn(
|
|
1053
|
-
{ code },
|
|
1068
|
+
{ code, postgresStderrTail: tail.trim() || 'no postgres output captured' },
|
|
1054
1069
|
'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
|
|
1055
1070
|
);
|
|
1056
1071
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `autopg_meta` table bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* pgserve singleton (v2.6) — `autopg-distribution-cutover-finalize`
|
|
5
|
+
* wish, Group 3 (`pgserve create-app` + manifest LOCK 1).
|
|
6
|
+
*
|
|
7
|
+
* Source-of-truth split (per wish Decision #2 / G3 deliverable 4):
|
|
8
|
+
*
|
|
9
|
+
* `autopg_meta` is the single source of truth for "which apps are
|
|
10
|
+
* registered with this pgserve instance + what cosign trust roots are
|
|
11
|
+
* locked at create-app time." The per-consumer manifest file at
|
|
12
|
+
* `~/.autopg/<sanitized-slug>/manifest.json` (and the sibling
|
|
13
|
+
* `admin.json`) are derived caches; on divergence, the table wins.
|
|
14
|
+
*
|
|
15
|
+
* The `--fix` mutation modes that would regenerate the cache files
|
|
16
|
+
* from the table are NOT implemented in v2.4 read-only V1
|
|
17
|
+
* (src/commands/doctor.js:440-442 prints "--fix tiered modes are not
|
|
18
|
+
* implemented in v2.4"). Until those land, the cache-recovery story is
|
|
19
|
+
* manual: operator deletes the per-consumer dir + re-runs
|
|
20
|
+
* `pgserve create-app <slug>`. The verb is idempotent and preserves
|
|
21
|
+
* the locked_roots already on the row (idempotent re-run touches
|
|
22
|
+
* `last_updated` ONLY).
|
|
23
|
+
*
|
|
24
|
+
* Why a separate table from `pgserve_meta`: different lifecycle.
|
|
25
|
+
* `pgserve_meta` is per-database (provision/gc cohort, fingerprint as
|
|
26
|
+
* PK). `autopg_meta` is per-consumer-app (slug as PK), and an app can
|
|
27
|
+
* exist before any of its DBs do. Splitting the tables keeps each
|
|
28
|
+
* bootstrap genuinely additive + lets the wish Group 4/5 work
|
|
29
|
+
* (per-consumer doctor surface) reach for autopg_meta without
|
|
30
|
+
* crossing into pgserve_meta's invariants.
|
|
31
|
+
*
|
|
32
|
+
* Idempotency: every statement uses `IF NOT EXISTS` (table, indexes).
|
|
33
|
+
* Re-running on an already-bootstrapped database is a no-op.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const AUTOPG_META_TABLE = 'autopg_meta';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Base columns owned by this module.
|
|
40
|
+
*
|
|
41
|
+
* - slug: PRIMARY KEY — the sanitized consumer slug
|
|
42
|
+
* (sanitizeSlug from src/provision/db-naming.js)
|
|
43
|
+
* - manifest_path: NOT NULL — absolute path to the cache manifest
|
|
44
|
+
* file at ~/.autopg/<slug>/manifest.json
|
|
45
|
+
* - locked_roots: NOT NULL — JSONB array shaped like
|
|
46
|
+
* TRUSTED_IDENTITIES entries, frozen-at-create
|
|
47
|
+
* - created_at: NOT NULL DEFAULT now() — set on insert
|
|
48
|
+
* - last_updated: NOT NULL DEFAULT now() — touched by every
|
|
49
|
+
* create-app re-run; locked_roots stays untouched
|
|
50
|
+
*/
|
|
51
|
+
export const AUTOPG_META_COLUMNS = Object.freeze([
|
|
52
|
+
'slug',
|
|
53
|
+
'manifest_path',
|
|
54
|
+
'locked_roots',
|
|
55
|
+
'created_at',
|
|
56
|
+
'last_updated',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Schema-qualified name. The doctor / verifier read paths probe
|
|
60
|
+
// `to_regclass('public.autopg_meta')`; an unqualified CREATE TABLE
|
|
61
|
+
// could land in a non-public schema if a non-default search_path is
|
|
62
|
+
// configured on the active role, leaving subsequent reads unable to
|
|
63
|
+
// find it. Match the qualification convention pgserve_meta uses.
|
|
64
|
+
const QUALIFIED = `public.${AUTOPG_META_TABLE}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Idempotent statements that CREATE the table + supporting indexes.
|
|
68
|
+
* Returned as an array so callers can run each one individually for
|
|
69
|
+
* clear error reporting (mirrors src/schema/pgserve-meta.js shape).
|
|
70
|
+
*/
|
|
71
|
+
export function getBootstrapStatements() {
|
|
72
|
+
return [
|
|
73
|
+
[
|
|
74
|
+
`CREATE TABLE IF NOT EXISTS ${QUALIFIED} (`,
|
|
75
|
+
' slug TEXT PRIMARY KEY,',
|
|
76
|
+
' manifest_path TEXT NOT NULL,',
|
|
77
|
+
' locked_roots JSONB NOT NULL,',
|
|
78
|
+
' created_at TIMESTAMPTZ NOT NULL DEFAULT now(),',
|
|
79
|
+
' last_updated TIMESTAMPTZ NOT NULL DEFAULT now()',
|
|
80
|
+
')',
|
|
81
|
+
].join('\n'),
|
|
82
|
+
`CREATE INDEX IF NOT EXISTS ${AUTOPG_META_TABLE}_last_updated_idx ON ${QUALIFIED} (last_updated)`,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Single SQL string variant — convenient for embedding the bootstrap in
|
|
88
|
+
* a transaction or pg-init script.
|
|
89
|
+
*/
|
|
90
|
+
export function getBootstrapSQL() {
|
|
91
|
+
return `${getBootstrapStatements().join(';\n\n')};\n`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Apply the bootstrap via a node-postgres-compatible client. The client
|
|
96
|
+
* must expose an async `query(sql)` method (matches both `pg.Client` and
|
|
97
|
+
* `pg.PoolClient`). Returns the list of statements executed.
|
|
98
|
+
*
|
|
99
|
+
* Statements run sequentially so a failure on the index half doesn't
|
|
100
|
+
* masquerade as success after the table half ran.
|
|
101
|
+
*/
|
|
102
|
+
export async function bootstrapAutopgMeta(client) {
|
|
103
|
+
if (!client || typeof client.query !== 'function') {
|
|
104
|
+
throw new TypeError('bootstrapAutopgMeta: client must expose an async query() method');
|
|
105
|
+
}
|
|
106
|
+
const statements = getBootstrapStatements();
|
|
107
|
+
for (const sql of statements) {
|
|
108
|
+
await client.query(sql);
|
|
109
|
+
}
|
|
110
|
+
return statements;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Predicate the doctor / verify read paths can call before deciding
|
|
115
|
+
* whether to query the table. Callers pass the result of
|
|
116
|
+
* `SELECT to_regclass('public.autopg_meta') IS NOT NULL`.
|
|
117
|
+
*/
|
|
118
|
+
export function tableExistsFromRegclass(toRegclassResult) {
|
|
119
|
+
return toRegclassResult === true;
|
|
120
|
+
}
|