postgresai 0.14.0-beta.1 → 0.14.0-beta.3
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 +29 -14
- package/bin/postgres-ai.ts +44 -56
- package/dist/bin/postgres-ai.js +44 -54
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/init.d.ts +4 -4
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/init.js +162 -163
- package/dist/lib/init.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/init.ts +178 -193
- package/package.json +1 -1
- package/sql/01.role.sql +8 -7
- package/test/init.integration.test.cjs +35 -21
- package/test/init.test.cjs +192 -23
package/lib/init.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import * as readline from "readline";
|
|
2
1
|
import { randomBytes } from "crypto";
|
|
3
2
|
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
|
+
|
|
8
10
|
export type PgClientConfig = {
|
|
9
11
|
connectionString?: string;
|
|
10
12
|
host?: string;
|
|
@@ -12,7 +14,7 @@ export type PgClientConfig = {
|
|
|
12
14
|
user?: string;
|
|
13
15
|
password?: string;
|
|
14
16
|
database?: string;
|
|
15
|
-
ssl?:
|
|
17
|
+
ssl?: boolean | TlsConnectionOptions;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
export type AdminConnection = {
|
|
@@ -57,15 +59,26 @@ function applyTemplate(sql: string, vars: Record<string, string>): string {
|
|
|
57
59
|
|
|
58
60
|
function quoteIdent(ident: string): string {
|
|
59
61
|
// Always quote. Escape embedded quotes by doubling.
|
|
62
|
+
if (ident.includes("\0")) {
|
|
63
|
+
throw new Error("Identifier cannot contain null bytes");
|
|
64
|
+
}
|
|
60
65
|
return `"${ident.replace(/"/g, "\"\"")}"`;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
function quoteLiteral(value: string): string {
|
|
64
69
|
// Single-quote and escape embedded quotes by doubling.
|
|
65
70
|
// 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
|
+
}
|
|
66
74
|
return `'${value.replace(/'/g, "''")}'`;
|
|
67
75
|
}
|
|
68
76
|
|
|
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
|
+
|
|
69
82
|
export function maskConnectionString(dbUrl: string): string {
|
|
70
83
|
// Hide password if present (postgresql://user:pass@host/db).
|
|
71
84
|
try {
|
|
@@ -209,19 +222,8 @@ export function resolveAdminConnection(opts: {
|
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
if (!hasConnDetails) {
|
|
212
|
-
|
|
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
|
-
);
|
|
225
|
+
// Keep this message short: the CLI prints full help (including examples) on this error.
|
|
226
|
+
throw new Error("Connection is required.");
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
const cfg: PgClientConfig = {};
|
|
@@ -240,81 +242,19 @@ export function resolveAdminConnection(opts: {
|
|
|
240
242
|
return { clientConfig: cfg, display: describePgConfig(cfg) };
|
|
241
243
|
}
|
|
242
244
|
|
|
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");
|
|
250
|
-
}
|
|
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
245
|
function generateMonitoringPassword(): string {
|
|
310
|
-
// URL-safe and easy to copy/paste;
|
|
311
|
-
|
|
246
|
+
// URL-safe and easy to copy/paste; 24 bytes => 32 base64url chars (no padding).
|
|
247
|
+
// Note: randomBytes() throws on failure; we add a tiny sanity check for unexpected output.
|
|
248
|
+
const password = randomBytes(24).toString("base64url");
|
|
249
|
+
if (password.length < 30) {
|
|
250
|
+
throw new Error("Password generation failed: unexpected output length");
|
|
251
|
+
}
|
|
252
|
+
return password;
|
|
312
253
|
}
|
|
313
254
|
|
|
314
255
|
export async function resolveMonitoringPassword(opts: {
|
|
315
256
|
passwordFlag?: string;
|
|
316
257
|
passwordEnv?: string;
|
|
317
|
-
prompt?: (prompt: string) => Promise<string>;
|
|
318
258
|
monitoringUser: string;
|
|
319
259
|
}): Promise<{ password: string; generated: boolean }> {
|
|
320
260
|
const fromFlag = (opts.passwordFlag || "").trim();
|
|
@@ -332,9 +272,9 @@ export async function buildInitPlan(params: {
|
|
|
332
272
|
monitoringUser?: string;
|
|
333
273
|
monitoringPassword: string;
|
|
334
274
|
includeOptionalPermissions: boolean;
|
|
335
|
-
roleExists?: boolean;
|
|
336
275
|
}): Promise<InitPlan> {
|
|
337
|
-
|
|
276
|
+
// NOTE: kept async for API stability / potential future async template loading.
|
|
277
|
+
const monitoringUser = params.monitoringUser || DEFAULT_MONITORING_USER;
|
|
338
278
|
const database = params.database;
|
|
339
279
|
|
|
340
280
|
const qRole = quoteIdent(monitoringUser);
|
|
@@ -344,27 +284,26 @@ export async function buildInitPlan(params: {
|
|
|
344
284
|
|
|
345
285
|
const steps: InitStep[] = [];
|
|
346
286
|
|
|
347
|
-
const vars = {
|
|
287
|
+
const vars: Record<string, string> = {
|
|
348
288
|
ROLE_IDENT: qRole,
|
|
349
289
|
DB_IDENT: qDb,
|
|
350
290
|
};
|
|
351
291
|
|
|
352
292
|
// Role creation/update is done in one template file.
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
roleStmt = `alter user ${qRole} with password ${qPw};`;
|
|
359
|
-
} else {
|
|
360
|
-
roleStmt = `do $$ begin
|
|
293
|
+
// Always use a single DO block to avoid race conditions between "role exists?" checks and CREATE USER.
|
|
294
|
+
// We:
|
|
295
|
+
// - create role if missing (and handle duplicate_object in case another session created it concurrently),
|
|
296
|
+
// - then ALTER ROLE to ensure the password is set to the desired value.
|
|
297
|
+
const roleStmt = `do $$ begin
|
|
361
298
|
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
299
|
+
begin
|
|
300
|
+
create user ${qRole} with password ${qPw};
|
|
301
|
+
exception when duplicate_object then
|
|
302
|
+
null;
|
|
303
|
+
end;
|
|
365
304
|
end if;
|
|
305
|
+
alter user ${qRole} with password ${qPw};
|
|
366
306
|
end $$;`;
|
|
367
|
-
}
|
|
368
307
|
|
|
369
308
|
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
370
309
|
steps.push({ name: "01.role", sql: roleSql });
|
|
@@ -411,9 +350,31 @@ export async function applyInitPlan(params: {
|
|
|
411
350
|
const msg = e instanceof Error ? e.message : String(e);
|
|
412
351
|
const errAny = e as any;
|
|
413
352
|
const wrapped: any = new Error(`Failed at step "${step.name}": ${msg}`);
|
|
414
|
-
// Preserve Postgres error
|
|
415
|
-
|
|
416
|
-
|
|
353
|
+
// Preserve useful Postgres error fields so callers can provide better hints / diagnostics.
|
|
354
|
+
const pgErrorFields = [
|
|
355
|
+
"code",
|
|
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;
|
|
417
378
|
}
|
|
418
379
|
throw wrapped;
|
|
419
380
|
}
|
|
@@ -432,11 +393,24 @@ export async function applyInitPlan(params: {
|
|
|
432
393
|
// Apply optional steps outside of the transaction so a failure doesn't abort everything.
|
|
433
394
|
for (const step of params.plan.steps.filter((s) => s.optional)) {
|
|
434
395
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
396
|
+
// Run each optional step in its own mini-transaction to avoid partial application.
|
|
397
|
+
await params.client.query("begin;");
|
|
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
|
+
}
|
|
437
411
|
} catch {
|
|
412
|
+
// If we can't even begin/commit, treat as skipped.
|
|
438
413
|
skippedOptional.push(step.name);
|
|
439
|
-
// best-effort: ignore
|
|
440
414
|
}
|
|
441
415
|
}
|
|
442
416
|
|
|
@@ -455,111 +429,122 @@ export async function verifyInitSetup(params: {
|
|
|
455
429
|
monitoringUser: string;
|
|
456
430
|
includeOptionalPermissions: boolean;
|
|
457
431
|
}): Promise<VerifyInitResult> {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
[role, db]
|
|
475
|
-
);
|
|
476
|
-
if (!connectRes.rows?.[0]?.ok) {
|
|
477
|
-
missingRequired.push(`CONNECT on database "${db}"`);
|
|
478
|
-
}
|
|
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
|
+
}
|
|
479
448
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
+
}
|
|
487
456
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
}
|
|
495
464
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
missingRequired.push("view public.pg_statistic exists");
|
|
499
|
-
} else {
|
|
500
|
-
const viewPrivRes = await params.client.query(
|
|
501
|
-
"select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
|
|
465
|
+
const pgIndexRes = await params.client.query(
|
|
466
|
+
"select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
|
|
502
467
|
[role]
|
|
503
468
|
);
|
|
504
|
-
if (!
|
|
505
|
-
missingRequired.push("SELECT on
|
|
469
|
+
if (!pgIndexRes.rows?.[0]?.ok) {
|
|
470
|
+
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
506
471
|
}
|
|
507
|
-
}
|
|
508
472
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
+
}
|
|
516
485
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
// We accept any ordering as long as public and pg_catalog are included.
|
|
524
|
-
const sp = spLine.toLowerCase();
|
|
525
|
-
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
526
|
-
missingRequired.push("role search_path includes public and pg_catalog");
|
|
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");
|
|
527
492
|
}
|
|
528
|
-
}
|
|
529
493
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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]);
|
|
541
533
|
if (!fnRes.rows?.[0]?.ok) {
|
|
542
|
-
missingOptional.push(
|
|
534
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
543
535
|
}
|
|
544
536
|
}
|
|
545
537
|
}
|
|
546
538
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
for (const fn of optionalFns) {
|
|
555
|
-
const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
|
|
556
|
-
if (!fnRes.rows?.[0]?.ok) {
|
|
557
|
-
missingOptional.push(`EXECUTE on ${fn}`);
|
|
558
|
-
}
|
|
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
|
|
559
546
|
}
|
|
560
547
|
}
|
|
561
|
-
|
|
562
|
-
return { ok: missingRequired.length === 0, missingRequired, missingOptional };
|
|
563
548
|
}
|
|
564
549
|
|
|
565
550
|
|
package/package.json
CHANGED
package/sql/01.role.sql
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
-- Role creation / password update (template-filled by cli/lib/init.ts)
|
|
2
2
|
--
|
|
3
|
-
--
|
|
4
|
-
-- create user "postgres_ai_mon" with password '...';
|
|
5
|
-
-- alter user "postgres_ai_mon" with password '...';
|
|
3
|
+
-- Always uses a race-safe pattern (create if missing, then always alter to set the password):
|
|
6
4
|
-- do $$ begin
|
|
7
|
-
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = '
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
5
|
+
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = '...') then
|
|
6
|
+
-- begin
|
|
7
|
+
-- create user "..." with password '...';
|
|
8
|
+
-- exception when duplicate_object then
|
|
9
|
+
-- null;
|
|
10
|
+
-- end;
|
|
11
11
|
-- end if;
|
|
12
|
+
-- alter user "..." with password '...';
|
|
12
13
|
-- end $$;
|
|
13
14
|
{{ROLE_STMT}}
|
|
14
15
|
|
|
@@ -92,25 +92,41 @@ async function withTempPostgres(t) {
|
|
|
92
92
|
|
|
93
93
|
const port = await getFreePort();
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
let postgresProc;
|
|
96
|
+
try {
|
|
97
|
+
postgresProc = spawn(
|
|
98
|
+
postgresBin,
|
|
99
|
+
["-D", dataDir, "-k", socketDir, "-h", "127.0.0.1", "-p", String(port)],
|
|
100
|
+
{
|
|
101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
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.
|
|
102
122
|
try {
|
|
103
|
-
|
|
104
|
-
async () => {
|
|
105
|
-
if (postgresProc.exitCode === null) throw new Error("still running");
|
|
106
|
-
},
|
|
107
|
-
{ timeoutMs: 5000, intervalMs: 100 }
|
|
108
|
-
);
|
|
123
|
+
if (postgresProc) postgresProc.kill("SIGKILL");
|
|
109
124
|
} catch {
|
|
110
|
-
|
|
125
|
+
// ignore
|
|
111
126
|
}
|
|
112
127
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
113
|
-
|
|
128
|
+
throw e;
|
|
129
|
+
}
|
|
114
130
|
|
|
115
131
|
const { Client } = require("pg");
|
|
116
132
|
|
|
@@ -208,8 +224,8 @@ test(
|
|
|
208
224
|
{
|
|
209
225
|
const r = await runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
|
|
210
226
|
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
211
|
-
assert.match(r.
|
|
212
|
-
assert.match(r.
|
|
227
|
+
assert.match(r.stderr, /Generated monitoring password for postgres_ai_mon/i);
|
|
228
|
+
assert.match(r.stderr, /PGAI_MON_PASSWORD=/);
|
|
213
229
|
}
|
|
214
230
|
}
|
|
215
231
|
);
|
|
@@ -294,12 +310,10 @@ test("integration: init reports nicely when lacking permissions", { skip: !haveP
|
|
|
294
310
|
const limitedUri = `postgresql://limited:${limitedPw}@127.0.0.1:${pg.port}/testdb`;
|
|
295
311
|
const r = await runCliInit([limitedUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
296
312
|
assert.notEqual(r.status, 0);
|
|
297
|
-
assert.match(r.stderr, /init
|
|
313
|
+
assert.match(r.stderr, /Error: init:/);
|
|
298
314
|
// Should include step context and hint.
|
|
299
315
|
assert.match(r.stderr, /Failed at step "/);
|
|
300
|
-
assert.match(r.stderr, /
|
|
301
|
-
assert.match(r.stderr, /How to fix:/i);
|
|
302
|
-
assert.match(r.stderr, /Hint: connect as a superuser/i);
|
|
316
|
+
assert.match(r.stderr, /Fix: connect as a superuser/i);
|
|
303
317
|
});
|
|
304
318
|
|
|
305
319
|
test("integration: init --verify returns 0 when ok and non-zero when missing", { skip: !havePostgresBinaries() }, async (t) => {
|