@everystack/cli 0.4.64 → 0.4.65
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 +2 -2
- package/src/cli/commands/db.ts +71 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.65",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
"jest": "29.7.0",
|
|
178
178
|
"react": "19.2.0",
|
|
179
179
|
"ts-jest": "29.4.9",
|
|
180
|
-
"@everystack/server": "0.4.
|
|
180
|
+
"@everystack/server": "0.4.26"
|
|
181
181
|
},
|
|
182
182
|
"scripts": {
|
|
183
183
|
"test": "jest",
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -501,41 +501,27 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
501
501
|
process.exit(1);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
if (!adminUrl) {
|
|
508
|
-
fail('No ADMIN_DATABASE_URL secret found. Run db:provision first to set up the credential split.');
|
|
509
|
-
process.exit(1);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Step 0: preflight — verify the master credential works (break-glass path).
|
|
513
|
-
step('Preflight: verifying master connection...');
|
|
504
|
+
// Step 0: preflight — verify the operator connection works (break-glass path).
|
|
505
|
+
step('Preflight: verifying operator connection...');
|
|
506
|
+
const opsFn = opsFunction(config);
|
|
514
507
|
try {
|
|
515
|
-
const result = await invokeAction(config.region,
|
|
508
|
+
const result = await invokeAction(config.region, opsFn, 'db:query', { sql: 'SELECT current_user' });
|
|
516
509
|
if ((result as any)?.error) throw new Error((result as any).error);
|
|
517
|
-
success('
|
|
510
|
+
success('Operator connection verified.');
|
|
518
511
|
} catch (err: any) {
|
|
519
|
-
fail(`
|
|
520
|
-
info('
|
|
512
|
+
fail(`Operator connection failed: ${err.message}`);
|
|
513
|
+
info('Fix the operator connection before rotating.');
|
|
521
514
|
process.exit(1);
|
|
522
515
|
}
|
|
523
516
|
|
|
524
|
-
//
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
execute: async (sql: string) => { await runner(sql); },
|
|
517
|
+
// All DB operations run through the ops Lambda (inside the VPC).
|
|
518
|
+
const rotate = async (action: string, payload?: Record<string, unknown>) => {
|
|
519
|
+
const result = await invokeAction(config.region, opsFn, 'db:rotate', { action, ...payload });
|
|
520
|
+
if ((result as any)?.error) throw new Error((result as any).error);
|
|
521
|
+
return result as any;
|
|
530
522
|
};
|
|
531
523
|
|
|
532
524
|
try {
|
|
533
|
-
const {
|
|
534
|
-
detectActiveRole,
|
|
535
|
-
provisionInactiveRole,
|
|
536
|
-
retireRole,
|
|
537
|
-
drainRole,
|
|
538
|
-
} = await import('@everystack/server/rotate');
|
|
539
525
|
|
|
540
526
|
// Detect which slots to rotate.
|
|
541
527
|
const slotFlag = flags.slot || 'both';
|
|
@@ -552,8 +538,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
552
538
|
}
|
|
553
539
|
|
|
554
540
|
const { randomBytes } = await import('node:crypto');
|
|
555
|
-
|
|
556
|
-
|
|
541
|
+
|
|
542
|
+
// Resolve host/port/database from the ops Lambda (same as db:provision does).
|
|
543
|
+
let conn: { host: string; port: string; database: string } | undefined;
|
|
544
|
+
try {
|
|
545
|
+
const psqlInfo = await invokeAction(config.region, opsFn, 'db:psql', {});
|
|
546
|
+
if ((psqlInfo as any)?.host) conn = psqlInfo as any;
|
|
547
|
+
} catch { /* handled below */ }
|
|
548
|
+
if (!conn?.host) {
|
|
549
|
+
fail('Could not resolve database host from the ops Lambda.');
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
const sourceUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
|
|
553
|
+
?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
|
|
557
554
|
|
|
558
555
|
// Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
|
|
559
556
|
const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
|
|
@@ -563,23 +560,22 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
563
560
|
for (const { base, secretKeys } of slots) {
|
|
564
561
|
step(`Rotating ${base}...`);
|
|
565
562
|
|
|
566
|
-
const detected = await
|
|
567
|
-
if (!detected) {
|
|
563
|
+
const detected = await rotate('detect', { baseRole: base });
|
|
564
|
+
if (!detected || (!detected.activeRole && !detected.inactiveRole)) {
|
|
568
565
|
fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
|
|
569
566
|
process.exit(1);
|
|
570
567
|
}
|
|
571
568
|
info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
|
|
572
569
|
oldActiveRoles.push({ base, oldRole: detected.activeRole });
|
|
573
|
-
const active = detected;
|
|
574
570
|
|
|
575
|
-
// Provision the inactive role.
|
|
571
|
+
// Provision the inactive role via the ops Lambda.
|
|
576
572
|
const password = randomBytes(24).toString('hex');
|
|
577
|
-
await
|
|
578
|
-
success(` ${
|
|
573
|
+
const provisioned = await rotate('provision', { baseRole: base, password });
|
|
574
|
+
success(` ${detected.inactiveRole} provisioned with new password.`);
|
|
579
575
|
|
|
580
576
|
// Build the new URL.
|
|
581
577
|
const newUrl = preserveConnParams(
|
|
582
|
-
`postgresql://${
|
|
578
|
+
`postgresql://${detected.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
|
|
583
579
|
sourceUrl,
|
|
584
580
|
);
|
|
585
581
|
|
|
@@ -658,29 +654,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
658
654
|
process.exit(1);
|
|
659
655
|
}
|
|
660
656
|
|
|
661
|
-
// Verify
|
|
662
|
-
//
|
|
657
|
+
// Verify the new credential can log in — prove it works before revoking
|
|
658
|
+
// the old one. Runs inside the VPC via the ops Lambda.
|
|
663
659
|
step('Verifying new credential...');
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
success('New credential connects to the database.');
|
|
674
|
-
} finally {
|
|
675
|
-
await probe.end({ timeout: 1 });
|
|
676
|
-
}
|
|
677
|
-
} else {
|
|
678
|
-
verifyPassed = true;
|
|
660
|
+
const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
|
|
661
|
+
if (newApiUrl) {
|
|
662
|
+
try {
|
|
663
|
+
await rotate('probe-login', { url: newApiUrl });
|
|
664
|
+
success('New credential connects to the database.');
|
|
665
|
+
} catch (err: any) {
|
|
666
|
+
fail(`New credential login failed: ${err.message}`);
|
|
667
|
+
info('Both roles keep LOGIN (safe state). Fix and re-run.');
|
|
668
|
+
process.exit(1);
|
|
679
669
|
}
|
|
680
|
-
} catch (err: any) {
|
|
681
|
-
fail(`New credential login failed: ${err.message}`);
|
|
682
|
-
info('Both roles keep LOGIN (safe state). Fix and re-run.');
|
|
683
|
-
process.exit(1);
|
|
684
670
|
}
|
|
685
671
|
|
|
686
672
|
// Origin HTTP check (non-blocking — the login probe is the real gate).
|
|
@@ -700,34 +686,48 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
700
686
|
// re-detection would pick the wrong one via the tiebreak).
|
|
701
687
|
for (const { base, oldRole } of oldActiveRoles) {
|
|
702
688
|
step(`Retiring ${oldRole}...`);
|
|
703
|
-
await
|
|
689
|
+
await rotate('retire', { role: oldRole });
|
|
704
690
|
success(` ${oldRole} set to NOLOGIN.`);
|
|
705
691
|
|
|
706
|
-
// Also retire the base role if it still has LOGIN (F6 fix
|
|
707
|
-
// pre-rotation credential must not survive rotation).
|
|
692
|
+
// Also retire the base role if it still has LOGIN (F6 fix).
|
|
708
693
|
try {
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
694
|
+
const baseDetect = await rotate('detect', { baseRole: base });
|
|
695
|
+
if (baseDetect === null) {
|
|
696
|
+
// Base role check — query directly via db:query
|
|
697
|
+
const baseResult = await invokeAction(config.region, opsFn, 'db:query', {
|
|
698
|
+
sql: `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
|
|
699
|
+
});
|
|
700
|
+
const rows = (baseResult as any)?.rows ?? baseResult;
|
|
701
|
+
if (Array.isArray(rows) && rows[0]?.rolcanlogin) {
|
|
702
|
+
await rotate('retire', { role: base });
|
|
703
|
+
success(` ${base} (base role) set to NOLOGIN.`);
|
|
704
|
+
}
|
|
715
705
|
}
|
|
716
706
|
} catch { /* best-effort */ }
|
|
717
707
|
|
|
718
708
|
step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
709
|
+
const deadline = Date.now() + graceMs;
|
|
710
|
+
const POLL_MS = 2000;
|
|
711
|
+
while (Date.now() < deadline) {
|
|
712
|
+
const { count } = await rotate('count-sessions', { role: oldRole });
|
|
713
|
+
if (count === 0) { success(` ${oldRole} drained cleanly.`); break; }
|
|
714
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
715
|
+
}
|
|
716
|
+
const { count: remaining } = await rotate('count-sessions', { role: oldRole });
|
|
717
|
+
if (remaining > 0) {
|
|
718
|
+
const { terminated } = await rotate('terminate-sessions', { role: oldRole });
|
|
723
719
|
success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
|
|
724
720
|
}
|
|
725
721
|
}
|
|
726
722
|
|
|
727
723
|
success('Credential rotation complete.');
|
|
728
724
|
|
|
729
|
-
}
|
|
730
|
-
|
|
725
|
+
} catch (err: any) {
|
|
726
|
+
if (err && (err as any).code !== 'EEXIT') {
|
|
727
|
+
fail(`Rotation error: ${err.message}`);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
throw err;
|
|
731
731
|
}
|
|
732
732
|
}
|
|
733
733
|
|