@everystack/cli 0.4.28 → 0.4.29
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/package.json +1 -1
- package/src/cli/commands/db-swap.ts +11 -3
- package/src/cli/commands/db.ts +33 -32
- package/src/cli/index.ts +1 -1
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
|
34
34
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
35
35
|
import { invokeAction, presignGet } from '../aws.js';
|
|
36
36
|
import { keyForArtifactId, metaKey } from '../backup.js';
|
|
37
|
+
import { pgEnvFromUrl } from './db.js';
|
|
37
38
|
import { step, success, fail, warn, info } from '../output.js';
|
|
38
39
|
|
|
39
40
|
/** A COPY-aware line transform that rewrites the schema token on statement lines only. */
|
|
@@ -73,10 +74,17 @@ function schemaRewriteStream(from: string, to: string): Transform {
|
|
|
73
74
|
* rewrite → psql. The archive names `<schema>`; the rewrite lands it as `<incoming>`, COPY-data-safe.
|
|
74
75
|
*/
|
|
75
76
|
async function restoreIntoIncoming(url: string, artifactPath: string, schema: string, incoming: string): Promise<void> {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
77
|
+
// Connect via PG* env, not `-d <url>`. libpq VALIDATES URI query params against its keyword
|
|
78
|
+
// list and REJECTS non-keywords like `search_path` ("invalid URI query parameter") — and the
|
|
79
|
+
// operator URL db:operator-url mints bakes search_path in (fine for postgres.js, fatal for a
|
|
80
|
+
// libpq client). pgEnvFromUrl extracts only libpq keywords (dropping search_path, routing
|
|
81
|
+
// sslmode→PGSSLMODE) and puts the password in PGPASSWORD, off the process argv. pg_restore -f -
|
|
82
|
+
// just reads the archive file to SQL on stdout (no connection).
|
|
78
83
|
const restore = spawn('pg_restore', ['-f', '-', artifactPath], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
79
|
-
const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1'
|
|
84
|
+
const psql = spawn('psql', ['-v', 'ON_ERROR_STOP=1'], {
|
|
85
|
+
stdio: ['pipe', 'ignore', 'pipe'],
|
|
86
|
+
env: { ...process.env, ...pgEnvFromUrl(url) },
|
|
87
|
+
});
|
|
80
88
|
let rErr = '', pErr = '';
|
|
81
89
|
restore.stderr.on('data', (d) => { rErr += d.toString(); });
|
|
82
90
|
psql.stderr.on('data', (d) => { pErr += d.toString(); });
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -211,7 +211,7 @@ export function buildProvisionSecretPlan(args: {
|
|
|
211
211
|
* same seam as pipeline-loader). */
|
|
212
212
|
export interface ServerProvision {
|
|
213
213
|
runProvision(
|
|
214
|
-
payload: { authPassword?: string; adminPassword?: string },
|
|
214
|
+
payload: { authPassword?: string; adminPassword?: string; searchPath?: string[] },
|
|
215
215
|
deps: {
|
|
216
216
|
execute: (sql: string) => Promise<unknown>;
|
|
217
217
|
connection?: { host: string; port?: number | string; database: string } | null;
|
|
@@ -473,6 +473,31 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
473
473
|
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
474
474
|
step('Provisioning roles...');
|
|
475
475
|
|
|
476
|
+
// The declared schema set — resolved BEFORE provisioning so it can be set as the ROLE default
|
|
477
|
+
// (ALTER ROLE … SET search_path), the right layer for bare-ref resolution (search-path-ownership).
|
|
478
|
+
// Union the DERIVED-object schemas (matviews/views live in schemas the barrel never names) so the
|
|
479
|
+
// authenticator flip doesn't go dark on bare-ref matview reads. Best-effort: a modelless or
|
|
480
|
+
// all-public app sets no path (unchanged behavior).
|
|
481
|
+
let declaredSchemas: string[] = [];
|
|
482
|
+
try {
|
|
483
|
+
const { resolveModelsPath } = await import('../models-path.js');
|
|
484
|
+
const { loadModels } = await import('./db-generate.js');
|
|
485
|
+
const models = await loadModels(resolveModelsPath(flags.models));
|
|
486
|
+
let derivedObjects: Array<{ schema: string }> = [];
|
|
487
|
+
try {
|
|
488
|
+
const { loadDeclaredDerived } = await import('../declared-derived.js');
|
|
489
|
+
const declared = await loadDeclaredDerived(flags.models);
|
|
490
|
+
if (declared) derivedObjects = declared.objects;
|
|
491
|
+
} catch {
|
|
492
|
+
// No derived layer resolvable — the table schemas still resolve correctly.
|
|
493
|
+
}
|
|
494
|
+
declaredSchemas = collectDeclaredSchemas({ models, derivedObjects });
|
|
495
|
+
} catch {
|
|
496
|
+
// No models resolvable here — leave the roles schema-agnostic.
|
|
497
|
+
}
|
|
498
|
+
const searchPath = declaredSearchPath(declaredSchemas);
|
|
499
|
+
if (searchPath.length) info(` Declared search_path (set as the role default via ALTER ROLE): ${searchPath.join(', ')}`);
|
|
500
|
+
|
|
476
501
|
// Generate BOTH login passwords and keep them in memory only — they are written straight
|
|
477
502
|
// into the secret store and are NEVER printed, logged, or returned to a human.
|
|
478
503
|
const { randomBytes } = await import('node:crypto');
|
|
@@ -486,7 +511,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
486
511
|
const { createUrlRunner } = await import('../db-source.js');
|
|
487
512
|
const { runner, end } = await createUrlRunner(url);
|
|
488
513
|
try {
|
|
489
|
-
return await runProvision({ authPassword, adminPassword }, {
|
|
514
|
+
return await runProvision({ authPassword, adminPassword, searchPath }, {
|
|
490
515
|
execute: async (statement) => runner(statement),
|
|
491
516
|
connection: parseUrlConnection(url),
|
|
492
517
|
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
@@ -519,7 +544,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
519
544
|
conn = parseUrlConnection(directUrl);
|
|
520
545
|
} else {
|
|
521
546
|
try {
|
|
522
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
547
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath });
|
|
523
548
|
} catch (err: any) {
|
|
524
549
|
// Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
|
|
525
550
|
// provision directly over it. Master never crosses the CLI; the operator sets nothing new.
|
|
@@ -575,31 +600,6 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
575
600
|
// wrong-database scare, and that check meant re-handling the master credential).
|
|
576
601
|
success(`Target database: ${conn.database} @ ${conn.host}:${conn.port ?? 5432} — secrets for stage "${flags.stage}" will point here.`);
|
|
577
602
|
|
|
578
|
-
// Bake the app's declared schemas into the minted URLs, so a multi-schema API connects with
|
|
579
|
-
// the right search_path instead of going dark on bare refs (best-effort: a modelless app or
|
|
580
|
-
// an all-public one changes nothing).
|
|
581
|
-
let declaredSchemas: string[] = [];
|
|
582
|
-
try {
|
|
583
|
-
const { resolveModelsPath } = await import('../models-path.js');
|
|
584
|
-
const { loadModels } = await import('./db-generate.js');
|
|
585
|
-
const models = await loadModels(resolveModelsPath(flags.models));
|
|
586
|
-
// Union the DERIVED-object schemas (matviews/views live in schemas the barrel never
|
|
587
|
-
// names) so the authenticator flip doesn't go dark on bare-ref matview reads.
|
|
588
|
-
let derivedObjects: Array<{ schema: string }> = [];
|
|
589
|
-
try {
|
|
590
|
-
const { loadDeclaredDerived } = await import('../declared-derived.js');
|
|
591
|
-
const declared = await loadDeclaredDerived(flags.models);
|
|
592
|
-
if (declared) derivedObjects = declared.objects;
|
|
593
|
-
} catch {
|
|
594
|
-
// No derived layer resolvable — the table schemas still bake correctly.
|
|
595
|
-
}
|
|
596
|
-
declaredSchemas = collectDeclaredSchemas({ models, derivedObjects });
|
|
597
|
-
} catch {
|
|
598
|
-
// No models resolvable here — leave the URLs schema-agnostic.
|
|
599
|
-
}
|
|
600
|
-
const searchPath = declaredSearchPath(declaredSchemas);
|
|
601
|
-
if (searchPath.length) info(` search_path baked into DATABASE_URL: ${searchPath.join(', ')}`);
|
|
602
|
-
|
|
603
603
|
// Preserve the connection params (sslmode, …) from the URL the app already connects with — the
|
|
604
604
|
// minted URL is built from bare host/port/db components, so without this a `?sslmode=require`
|
|
605
605
|
// would be dropped and the new roles could fail to connect on an SSL-forced RDS. Prefer the
|
|
@@ -608,11 +608,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
608
608
|
?? stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
|
|
609
609
|
?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
|
|
610
610
|
|
|
611
|
+
// The minted secret is credentials-only: search_path now lives on the ROLE (ALTER ROLE, above),
|
|
612
|
+
// not baked into the URL. This keeps the secret a pure credential and — critically — yields a
|
|
613
|
+
// libpq-clean URL (no ?search_path=, which psql/pg_restore reject as an unknown keyword). Real
|
|
614
|
+
// connection params (sslmode, …) are still carried from the source URL.
|
|
611
615
|
const mint = (role: string, password: string): string =>
|
|
612
|
-
|
|
613
|
-
preserveConnParams(`postgresql://${role}:${password}@${conn.host}:${conn.port ?? 5432}/${conn.database}`, sourceUrl),
|
|
614
|
-
declaredSchemas,
|
|
615
|
-
);
|
|
616
|
+
preserveConnParams(`postgresql://${role}:${password}@${conn.host}:${conn.port ?? 5432}/${conn.database}`, sourceUrl);
|
|
616
617
|
|
|
617
618
|
const authUrl = mint(result.loginRole, authPassword);
|
|
618
619
|
const adminUrl = result.adminRole ? mint(result.adminRole, adminPassword) : undefined;
|
package/src/cli/index.ts
CHANGED
|
@@ -357,7 +357,7 @@ Usage:
|
|
|
357
357
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
358
358
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
359
359
|
everystack db:doctor [--stage <name>] [--direct | --database-url <api> [--admin-database-url <ops>]] Check the DB is least-privilege + RLS-subject (api vs operator connection). No flag = ops-Lambda venue (auto-falls to direct via the stage secrets if the handler has no dbPlugin). --direct = probe both connections from the stage's DATABASE_URL + ADMIN_DATABASE_URL secrets; --database-url = explicit local venue (a lone URL is probed as both)
|
|
360
|
-
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are
|
|
360
|
+
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are set as the ROLE default (ALTER ROLE … SET search_path) — the secret stays credentials-only, and the URL is libpq-clean
|
|
361
361
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
362
362
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
363
363
|
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|