postgresai 0.14.0-beta.3 → 0.14.0-dev.11
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 +18 -41
- package/bin/postgres-ai.ts +51 -147
- package/dist/bin/postgres-ai.js +45 -139
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/init.d.ts +4 -15
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/init.js +94 -181
- package/dist/lib/init.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/init.ts +106 -215
- package/package.json +1 -1
- package/sql/01.role.sql +7 -8
- package/test/init.integration.test.cjs +18 -98
- package/test/init.test.cjs +22 -217
package/lib/init.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
+
import * as readline from "readline";
|
|
1
2
|
import { randomBytes } from "crypto";
|
|
2
3
|
import { URL } from "url";
|
|
3
|
-
import type { ConnectionOptions as TlsConnectionOptions } from "tls";
|
|
4
4
|
import type { Client as PgClient } from "pg";
|
|
5
5
|
import * as fs from "fs";
|
|
6
6
|
import * as path from "path";
|
|
7
7
|
|
|
8
|
-
export const DEFAULT_MONITORING_USER = "postgres_ai_mon";
|
|
9
|
-
|
|
10
8
|
export type PgClientConfig = {
|
|
11
9
|
connectionString?: string;
|
|
12
10
|
host?: string;
|
|
@@ -14,7 +12,7 @@ export type PgClientConfig = {
|
|
|
14
12
|
user?: string;
|
|
15
13
|
password?: string;
|
|
16
14
|
database?: string;
|
|
17
|
-
ssl?:
|
|
15
|
+
ssl?: any;
|
|
18
16
|
};
|
|
19
17
|
|
|
20
18
|
export type AdminConnection = {
|
|
@@ -59,26 +57,15 @@ function applyTemplate(sql: string, vars: Record<string, string>): string {
|
|
|
59
57
|
|
|
60
58
|
function quoteIdent(ident: string): string {
|
|
61
59
|
// Always quote. Escape embedded quotes by doubling.
|
|
62
|
-
if (ident.includes("\0")) {
|
|
63
|
-
throw new Error("Identifier cannot contain null bytes");
|
|
64
|
-
}
|
|
65
60
|
return `"${ident.replace(/"/g, "\"\"")}"`;
|
|
66
61
|
}
|
|
67
62
|
|
|
68
63
|
function quoteLiteral(value: string): string {
|
|
69
64
|
// Single-quote and escape embedded quotes by doubling.
|
|
70
65
|
// This is used where Postgres grammar requires a literal (e.g., CREATE/ALTER ROLE PASSWORD).
|
|
71
|
-
if (value.includes("\0")) {
|
|
72
|
-
throw new Error("Literal cannot contain null bytes");
|
|
73
|
-
}
|
|
74
66
|
return `'${value.replace(/'/g, "''")}'`;
|
|
75
67
|
}
|
|
76
68
|
|
|
77
|
-
export function redactPasswordsInSql(sql: string): string {
|
|
78
|
-
// Replace PASSWORD '<literal>' (handles doubled quotes inside).
|
|
79
|
-
return sql.replace(/password\s+'(?:''|[^'])*'/gi, "password '<redacted>'");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
69
|
export function maskConnectionString(dbUrl: string): string {
|
|
83
70
|
// Hide password if present (postgresql://user:pass@host/db).
|
|
84
71
|
try {
|
|
@@ -222,8 +209,19 @@ export function resolveAdminConnection(opts: {
|
|
|
222
209
|
}
|
|
223
210
|
|
|
224
211
|
if (!hasConnDetails) {
|
|
225
|
-
|
|
226
|
-
|
|
212
|
+
throw new Error(
|
|
213
|
+
[
|
|
214
|
+
"Connection is required.",
|
|
215
|
+
"",
|
|
216
|
+
"Examples:",
|
|
217
|
+
" postgresai init postgresql://admin@host:5432/dbname",
|
|
218
|
+
" postgresai init \"dbname=dbname host=host user=admin\"",
|
|
219
|
+
" postgresai init -h host -p 5432 -U admin -d dbname",
|
|
220
|
+
"",
|
|
221
|
+
"Admin password:",
|
|
222
|
+
" --admin-password <password> (or set PGPASSWORD)",
|
|
223
|
+
].join("\n")
|
|
224
|
+
);
|
|
227
225
|
}
|
|
228
226
|
|
|
229
227
|
const cfg: PgClientConfig = {};
|
|
@@ -242,19 +240,81 @@ export function resolveAdminConnection(opts: {
|
|
|
242
240
|
return { clientConfig: cfg, display: describePgConfig(cfg) };
|
|
243
241
|
}
|
|
244
242
|
|
|
245
|
-
function
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
243
|
+
export async function promptHidden(prompt: string): Promise<string> {
|
|
244
|
+
// Implement our own hidden input reader so:
|
|
245
|
+
// - prompt text is visible
|
|
246
|
+
// - only user input is masked
|
|
247
|
+
// - we don't rely on non-public readline internals
|
|
248
|
+
if (!process.stdin.isTTY) {
|
|
249
|
+
throw new Error("Cannot prompt for password in non-interactive mode");
|
|
251
250
|
}
|
|
252
|
-
|
|
251
|
+
|
|
252
|
+
const stdin = process.stdin;
|
|
253
|
+
const stdout = process.stdout as NodeJS.WriteStream;
|
|
254
|
+
|
|
255
|
+
stdout.write(prompt);
|
|
256
|
+
|
|
257
|
+
return await new Promise<string>((resolve, reject) => {
|
|
258
|
+
let value = "";
|
|
259
|
+
|
|
260
|
+
const cleanup = () => {
|
|
261
|
+
try {
|
|
262
|
+
stdin.setRawMode(false);
|
|
263
|
+
} catch {
|
|
264
|
+
// ignore
|
|
265
|
+
}
|
|
266
|
+
stdin.removeListener("keypress", onKeypress);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const onKeypress = (str: string, key: any) => {
|
|
270
|
+
if (key?.ctrl && key?.name === "c") {
|
|
271
|
+
stdout.write("\n");
|
|
272
|
+
cleanup();
|
|
273
|
+
reject(new Error("Cancelled"));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
278
|
+
stdout.write("\n");
|
|
279
|
+
cleanup();
|
|
280
|
+
resolve(value);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (key?.name === "backspace") {
|
|
285
|
+
if (value.length > 0) {
|
|
286
|
+
value = value.slice(0, -1);
|
|
287
|
+
// Erase one mask char.
|
|
288
|
+
stdout.write("\b \b");
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Ignore other control keys.
|
|
294
|
+
if (key?.ctrl || key?.meta) return;
|
|
295
|
+
|
|
296
|
+
if (typeof str === "string" && str.length > 0) {
|
|
297
|
+
value += str;
|
|
298
|
+
stdout.write("*");
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
readline.emitKeypressEvents(stdin);
|
|
303
|
+
stdin.setRawMode(true);
|
|
304
|
+
stdin.on("keypress", onKeypress);
|
|
305
|
+
stdin.resume();
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function generateMonitoringPassword(): string {
|
|
310
|
+
// URL-safe and easy to copy/paste; length ~32 chars.
|
|
311
|
+
return randomBytes(24).toString("base64url");
|
|
253
312
|
}
|
|
254
313
|
|
|
255
314
|
export async function resolveMonitoringPassword(opts: {
|
|
256
315
|
passwordFlag?: string;
|
|
257
316
|
passwordEnv?: string;
|
|
317
|
+
prompt?: (prompt: string) => Promise<string>;
|
|
258
318
|
monitoringUser: string;
|
|
259
319
|
}): Promise<{ password: string; generated: boolean }> {
|
|
260
320
|
const fromFlag = (opts.passwordFlag || "").trim();
|
|
@@ -272,9 +332,9 @@ export async function buildInitPlan(params: {
|
|
|
272
332
|
monitoringUser?: string;
|
|
273
333
|
monitoringPassword: string;
|
|
274
334
|
includeOptionalPermissions: boolean;
|
|
335
|
+
roleExists?: boolean;
|
|
275
336
|
}): Promise<InitPlan> {
|
|
276
|
-
|
|
277
|
-
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
337
|
+
const monitoringUser = params.monitoringUser || "postgres_ai_mon";
|
|
278
338
|
const database = params.database;
|
|
279
339
|
|
|
280
340
|
const qRole = quoteIdent(monitoringUser);
|
|
@@ -284,26 +344,27 @@ export async function buildInitPlan(params: {
|
|
|
284
344
|
|
|
285
345
|
const steps: InitStep[] = [];
|
|
286
346
|
|
|
287
|
-
const vars
|
|
347
|
+
const vars = {
|
|
288
348
|
ROLE_IDENT: qRole,
|
|
289
349
|
DB_IDENT: qDb,
|
|
290
350
|
};
|
|
291
351
|
|
|
292
352
|
// Role creation/update is done in one template file.
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
353
|
+
// If roleExists is unknown, use a single DO block to create-or-alter safely.
|
|
354
|
+
let roleStmt: string | null = null;
|
|
355
|
+
if (params.roleExists === false) {
|
|
356
|
+
roleStmt = `create user ${qRole} with password ${qPw};`;
|
|
357
|
+
} else if (params.roleExists === true) {
|
|
358
|
+
roleStmt = `alter user ${qRole} with password ${qPw};`;
|
|
359
|
+
} else {
|
|
360
|
+
roleStmt = `do $$ begin
|
|
298
361
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
null;
|
|
303
|
-
end;
|
|
362
|
+
create user ${qRole} with password ${qPw};
|
|
363
|
+
else
|
|
364
|
+
alter user ${qRole} with password ${qPw};
|
|
304
365
|
end if;
|
|
305
|
-
alter user ${qRole} with password ${qPw};
|
|
306
366
|
end $$;`;
|
|
367
|
+
}
|
|
307
368
|
|
|
308
369
|
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
309
370
|
steps.push({ name: "01.role", sql: roleSql });
|
|
@@ -350,201 +411,31 @@ export async function applyInitPlan(params: {
|
|
|
350
411
|
const msg = e instanceof Error ? e.message : String(e);
|
|
351
412
|
const errAny = e as any;
|
|
352
413
|
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
353
|
-
// Preserve
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
"detail",
|
|
357
|
-
"hint",
|
|
358
|
-
"position",
|
|
359
|
-
"internalPosition",
|
|
360
|
-
"internalQuery",
|
|
361
|
-
"where",
|
|
362
|
-
"schema",
|
|
363
|
-
"table",
|
|
364
|
-
"column",
|
|
365
|
-
"dataType",
|
|
366
|
-
"constraint",
|
|
367
|
-
"file",
|
|
368
|
-
"line",
|
|
369
|
-
"routine",
|
|
370
|
-
] as const;
|
|
371
|
-
if (errAny && typeof errAny === "object") {
|
|
372
|
-
for (const field of pgErrorFields) {
|
|
373
|
-
if (errAny[field] !== undefined) wrapped[field] = errAny[field];
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
if (e instanceof Error && e.stack) {
|
|
377
|
-
wrapped.stack = e.stack;
|
|
414
|
+
// Preserve Postgres error code so callers can provide better hints (e.g., 42501 insufficient_privilege).
|
|
415
|
+
if (errAny && typeof errAny === "object" && typeof errAny.code === "string") {
|
|
416
|
+
wrapped.code = errAny.code;
|
|
378
417
|
}
|
|
379
418
|
throw wrapped;
|
|
380
419
|
}
|
|
381
420
|
}
|
|
382
421
|
await params.client.query("commit;");
|
|
383
422
|
} catch (e) {
|
|
384
|
-
|
|
385
|
-
try {
|
|
386
|
-
await params.client.query("rollback;");
|
|
387
|
-
} catch {
|
|
388
|
-
// ignore
|
|
389
|
-
}
|
|
423
|
+
await params.client.query("rollback;");
|
|
390
424
|
throw e;
|
|
391
425
|
}
|
|
392
426
|
|
|
393
427
|
// Apply optional steps outside of the transaction so a failure doesn't abort everything.
|
|
394
428
|
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
395
429
|
try {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
try {
|
|
399
|
-
await params.client.query(step.sql, step.params as any);
|
|
400
|
-
await params.client.query("commit;");
|
|
401
|
-
applied.push(step.name);
|
|
402
|
-
} catch {
|
|
403
|
-
try {
|
|
404
|
-
await params.client.query("rollback;");
|
|
405
|
-
} catch {
|
|
406
|
-
// ignore rollback errors
|
|
407
|
-
}
|
|
408
|
-
skippedOptional.push(step.name);
|
|
409
|
-
// best-effort: ignore
|
|
410
|
-
}
|
|
430
|
+
await params.client.query(step.sql, step.params as any);
|
|
431
|
+
applied.push(step.name);
|
|
411
432
|
} catch {
|
|
412
|
-
// If we can't even begin/commit, treat as skipped.
|
|
413
433
|
skippedOptional.push(step.name);
|
|
434
|
+
// best-effort: ignore
|
|
414
435
|
}
|
|
415
436
|
}
|
|
416
437
|
|
|
417
438
|
return { applied, skippedOptional };
|
|
418
439
|
}
|
|
419
440
|
|
|
420
|
-
export type VerifyInitResult = {
|
|
421
|
-
ok: boolean;
|
|
422
|
-
missingRequired: string[];
|
|
423
|
-
missingOptional: string[];
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
export async function verifyInitSetup(params: {
|
|
427
|
-
client: PgClient;
|
|
428
|
-
database: string;
|
|
429
|
-
monitoringUser: string;
|
|
430
|
-
includeOptionalPermissions: boolean;
|
|
431
|
-
}): Promise<VerifyInitResult> {
|
|
432
|
-
// Use a repeatable-read snapshot so all checks see a consistent view.
|
|
433
|
-
await params.client.query("begin isolation level repeatable read;");
|
|
434
|
-
try {
|
|
435
|
-
const missingRequired: string[] = [];
|
|
436
|
-
const missingOptional: string[] = [];
|
|
437
|
-
|
|
438
|
-
const role = params.monitoringUser;
|
|
439
|
-
const db = params.database;
|
|
440
|
-
|
|
441
|
-
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
442
|
-
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
443
|
-
if (!roleExists) {
|
|
444
|
-
missingRequired.push(`role "${role}" does not exist`);
|
|
445
|
-
// If role is missing, other checks will error or be meaningless.
|
|
446
|
-
return { ok: false, missingRequired, missingOptional };
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const connectRes = await params.client.query(
|
|
450
|
-
"select has_database_privilege($1, $2, 'CONNECT') as ok",
|
|
451
|
-
[role, db]
|
|
452
|
-
);
|
|
453
|
-
if (!connectRes.rows?.[0]?.ok) {
|
|
454
|
-
missingRequired.push(`CONNECT on database "${db}"`);
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const pgMonitorRes = await params.client.query(
|
|
458
|
-
"select pg_has_role($1, 'pg_monitor', 'member') as ok",
|
|
459
|
-
[role]
|
|
460
|
-
);
|
|
461
|
-
if (!pgMonitorRes.rows?.[0]?.ok) {
|
|
462
|
-
missingRequired.push("membership in role pg_monitor");
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const pgIndexRes = await params.client.query(
|
|
466
|
-
"select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
|
|
467
|
-
[role]
|
|
468
|
-
);
|
|
469
|
-
if (!pgIndexRes.rows?.[0]?.ok) {
|
|
470
|
-
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
|
|
474
|
-
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
475
|
-
missingRequired.push("view public.pg_statistic exists");
|
|
476
|
-
} else {
|
|
477
|
-
const viewPrivRes = await params.client.query(
|
|
478
|
-
"select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
|
|
479
|
-
[role]
|
|
480
|
-
);
|
|
481
|
-
if (!viewPrivRes.rows?.[0]?.ok) {
|
|
482
|
-
missingRequired.push("SELECT on view public.pg_statistic");
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const schemaUsageRes = await params.client.query(
|
|
487
|
-
"select has_schema_privilege($1, 'public', 'USAGE') as ok",
|
|
488
|
-
[role]
|
|
489
|
-
);
|
|
490
|
-
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
491
|
-
missingRequired.push("USAGE on schema public");
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
495
|
-
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
496
|
-
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
|
|
497
|
-
if (typeof spLine !== "string" || !spLine) {
|
|
498
|
-
missingRequired.push("role search_path is set");
|
|
499
|
-
} else {
|
|
500
|
-
// We accept any ordering as long as public and pg_catalog are included.
|
|
501
|
-
const sp = spLine.toLowerCase();
|
|
502
|
-
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
503
|
-
missingRequired.push("role search_path includes public and pg_catalog");
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (params.includeOptionalPermissions) {
|
|
508
|
-
// Optional RDS/Aurora extras
|
|
509
|
-
{
|
|
510
|
-
const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
|
|
511
|
-
if ((extRes.rowCount ?? 0) === 0) {
|
|
512
|
-
missingOptional.push("extension rds_tools");
|
|
513
|
-
} else {
|
|
514
|
-
const fnRes = await params.client.query(
|
|
515
|
-
"select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
|
|
516
|
-
[role]
|
|
517
|
-
);
|
|
518
|
-
if (!fnRes.rows?.[0]?.ok) {
|
|
519
|
-
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Optional self-managed extras
|
|
525
|
-
const optionalFns = [
|
|
526
|
-
"pg_catalog.pg_stat_file(text)",
|
|
527
|
-
"pg_catalog.pg_stat_file(text, boolean)",
|
|
528
|
-
"pg_catalog.pg_ls_dir(text)",
|
|
529
|
-
"pg_catalog.pg_ls_dir(text, boolean, boolean)",
|
|
530
|
-
];
|
|
531
|
-
for (const fn of optionalFns) {
|
|
532
|
-
const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
|
|
533
|
-
if (!fnRes.rows?.[0]?.ok) {
|
|
534
|
-
missingOptional.push(`EXECUTE on ${fn}`);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return { ok: missingRequired.length === 0, missingRequired, missingOptional };
|
|
540
|
-
} finally {
|
|
541
|
-
// Read-only: rollback to release snapshot; do not mask original errors.
|
|
542
|
-
try {
|
|
543
|
-
await params.client.query("rollback;");
|
|
544
|
-
} catch {
|
|
545
|
-
// ignore
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
441
|
|
package/package.json
CHANGED
package/sql/01.role.sql
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
-- Role creation / password update (template-filled by cli/lib/init.ts)
|
|
2
2
|
--
|
|
3
|
-
--
|
|
3
|
+
-- Example expansions (for readability/review):
|
|
4
|
+
-- create user "postgres_ai_mon" with password '...';
|
|
5
|
+
-- alter user "postgres_ai_mon" with password '...';
|
|
4
6
|
-- do $$ begin
|
|
5
|
-
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = '
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
-- null;
|
|
10
|
-
-- end;
|
|
7
|
+
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = 'postgres_ai_mon') then
|
|
8
|
+
-- create user "postgres_ai_mon" with password '...';
|
|
9
|
+
-- else
|
|
10
|
+
-- alter user "postgres_ai_mon" with password '...';
|
|
11
11
|
-- end if;
|
|
12
|
-
-- alter user "..." with password '...';
|
|
13
12
|
-- end $$;
|
|
14
13
|
{{ROLE_STMT}}
|
|
15
14
|
|
|
@@ -92,41 +92,25 @@ async function withTempPostgres(t) {
|
|
|
92
92
|
|
|
93
93
|
const port = await getFreePort();
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
// Register cleanup immediately so failures below don't leave a running postgres and hang CI.
|
|
106
|
-
t.after(async () => {
|
|
107
|
-
postgresProc.kill("SIGTERM");
|
|
108
|
-
try {
|
|
109
|
-
await waitFor(
|
|
110
|
-
async () => {
|
|
111
|
-
if (postgresProc.exitCode === null) throw new Error("still running");
|
|
112
|
-
},
|
|
113
|
-
{ timeoutMs: 5000, intervalMs: 100 }
|
|
114
|
-
);
|
|
115
|
-
} catch {
|
|
116
|
-
postgresProc.kill("SIGKILL");
|
|
117
|
-
}
|
|
118
|
-
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
119
|
-
});
|
|
120
|
-
} catch (e) {
|
|
121
|
-
// If anything goes wrong before cleanup is registered, ensure we don't leak a running postgres.
|
|
95
|
+
const postgresProc = spawn(postgresBin, ["-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)], {
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Register cleanup immediately so failures below don't leave a running postgres and hang CI.
|
|
100
|
+
t.after(async () => {
|
|
101
|
+
postgresProc.kill("SIGTERM");
|
|
122
102
|
try {
|
|
123
|
-
|
|
103
|
+
await waitFor(
|
|
104
|
+
async () => {
|
|
105
|
+
if (postgresProc.exitCode === null) throw new Error("still running");
|
|
106
|
+
},
|
|
107
|
+
{ timeoutMs: 5000, intervalMs: 100 }
|
|
108
|
+
);
|
|
124
109
|
} catch {
|
|
125
|
-
|
|
110
|
+
postgresProc.kill("SIGKILL");
|
|
126
111
|
}
|
|
127
112
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
128
|
-
|
|
129
|
-
}
|
|
113
|
+
});
|
|
130
114
|
|
|
131
115
|
const { Client } = require("pg");
|
|
132
116
|
|
|
@@ -224,8 +208,7 @@ test(
|
|
|
224
208
|
{
|
|
225
209
|
const r = await runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
|
|
226
210
|
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
227
|
-
assert.match(r.
|
|
228
|
-
assert.match(r.stderr, /PGAI_MON_PASSWORD=/);
|
|
211
|
+
assert.match(r.stdout, /Generated password for monitoring user/i);
|
|
229
212
|
}
|
|
230
213
|
}
|
|
231
214
|
);
|
|
@@ -310,73 +293,10 @@ test("integration: init reports nicely when lacking permissions", { skip: !haveP
|
|
|
310
293
|
const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
|
|
311
294
|
const r = await runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
312
295
|
assert.notEqual(r.status, 0);
|
|
313
|
-
assert.match(r.stderr, /
|
|
296
|
+
assert.match(r.stderr, /init failed:/);
|
|
314
297
|
// Should include step context and hint.
|
|
315
298
|
assert.match(r.stderr, /Failed at step "/);
|
|
316
|
-
assert.match(r.stderr, /
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
test("integration: init --verify returns 0 when ok and non-zero when missing", { skip: !havePostgresBinaries() }, async (t) => {
|
|
320
|
-
const pg = await withTempPostgres(t);
|
|
321
|
-
const { Client } = require("pg");
|
|
322
|
-
|
|
323
|
-
// Prepare: run init
|
|
324
|
-
{
|
|
325
|
-
const r = await runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
326
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Verify should pass
|
|
330
|
-
{
|
|
331
|
-
const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
332
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
333
|
-
assert.match(r.stdout, /init verify: OK/i);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Break a required privilege and ensure verify fails
|
|
337
|
-
{
|
|
338
|
-
const c = new Client({ connectionString: pg.adminUri });
|
|
339
|
-
await c.connect();
|
|
340
|
-
// pg_catalog tables are often readable via PUBLIC by default; revoke from PUBLIC too so the failure is deterministic.
|
|
341
|
-
await c.query("revoke select on pg_catalog.pg_index from public");
|
|
342
|
-
await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
|
|
343
|
-
await c.end();
|
|
344
|
-
}
|
|
345
|
-
{
|
|
346
|
-
const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
347
|
-
assert.notEqual(r.status, 0);
|
|
348
|
-
assert.match(r.stderr, /init verify failed/i);
|
|
349
|
-
assert.match(r.stderr, /pg_catalog\.pg_index/i);
|
|
350
|
-
}
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("integration: init --reset-password updates the monitoring role login password", { skip: !havePostgresBinaries() }, async (t) => {
|
|
354
|
-
const pg = await withTempPostgres(t);
|
|
355
|
-
const { Client } = require("pg");
|
|
356
|
-
|
|
357
|
-
// Initial setup with password pw1
|
|
358
|
-
{
|
|
359
|
-
const r = await runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
360
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Reset to pw2
|
|
364
|
-
{
|
|
365
|
-
const r = await runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
|
|
366
|
-
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
367
|
-
assert.match(r.stdout, /password reset/i);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Connect as monitoring user with new password should work
|
|
371
|
-
{
|
|
372
|
-
const c = new Client({
|
|
373
|
-
connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
|
|
374
|
-
});
|
|
375
|
-
await c.connect();
|
|
376
|
-
const ok = await c.query("select 1 as ok");
|
|
377
|
-
assert.equal(ok.rows[0].ok, 1);
|
|
378
|
-
await c.end();
|
|
379
|
-
}
|
|
299
|
+
assert.match(r.stderr, /Hint: connect as a superuser/i);
|
|
380
300
|
});
|
|
381
301
|
|
|
382
302
|
|