@everystack/cli 0.4.61 → 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 +19 -2
- package/src/cli/aws.ts +5 -1
- package/src/cli/commands/db.ts +268 -3
- package/src/cli/commands/diag.ts +6 -6
- package/src/cli/index.ts +2 -1
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>",
|
|
@@ -23,70 +23,87 @@
|
|
|
23
23
|
],
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
+
"source": "./src/index.ts",
|
|
26
27
|
"types": "./src/index.ts",
|
|
27
28
|
"default": "./src/index.ts"
|
|
28
29
|
},
|
|
29
30
|
"./model": {
|
|
31
|
+
"source": "./src/cli/model-api.ts",
|
|
30
32
|
"types": "./src/cli/model-api.ts",
|
|
31
33
|
"default": "./src/cli/model-api.ts"
|
|
32
34
|
},
|
|
33
35
|
"./audit": {
|
|
36
|
+
"source": "./src/cli/audit-api.ts",
|
|
34
37
|
"types": "./src/cli/audit-api.ts",
|
|
35
38
|
"default": "./src/cli/audit-api.ts"
|
|
36
39
|
},
|
|
37
40
|
"./apply": {
|
|
41
|
+
"source": "./src/cli/apply-execute.ts",
|
|
38
42
|
"types": "./src/cli/apply-execute.ts",
|
|
39
43
|
"default": "./src/cli/apply-execute.ts"
|
|
40
44
|
},
|
|
41
45
|
"./db-source": {
|
|
46
|
+
"source": "./src/cli/db-source.ts",
|
|
42
47
|
"types": "./src/cli/db-source.ts",
|
|
43
48
|
"default": "./src/cli/db-source.ts"
|
|
44
49
|
},
|
|
45
50
|
"./reconcile": {
|
|
51
|
+
"source": "./src/reconcile.ts",
|
|
46
52
|
"types": "./src/reconcile.ts",
|
|
47
53
|
"default": "./src/reconcile.ts"
|
|
48
54
|
},
|
|
49
55
|
"./refresh": {
|
|
56
|
+
"source": "./src/refresh.ts",
|
|
50
57
|
"types": "./src/refresh.ts",
|
|
51
58
|
"default": "./src/refresh.ts"
|
|
52
59
|
},
|
|
53
60
|
"./backfill": {
|
|
61
|
+
"source": "./src/backfill.ts",
|
|
54
62
|
"types": "./src/backfill.ts",
|
|
55
63
|
"default": "./src/backfill.ts"
|
|
56
64
|
},
|
|
57
65
|
"./exec": {
|
|
66
|
+
"source": "./src/exec.ts",
|
|
58
67
|
"types": "./src/exec.ts",
|
|
59
68
|
"default": "./src/exec.ts"
|
|
60
69
|
},
|
|
61
70
|
"./audit/source": {
|
|
71
|
+
"source": "./src/cli/audit-source-api.ts",
|
|
62
72
|
"types": "./src/cli/audit-source-api.ts",
|
|
63
73
|
"default": "./src/cli/audit-source-api.ts"
|
|
64
74
|
},
|
|
65
75
|
"./runbook": {
|
|
76
|
+
"source": "./src/cli/runbook/index.ts",
|
|
66
77
|
"types": "./src/cli/runbook/index.ts",
|
|
67
78
|
"default": "./src/cli/runbook/index.ts"
|
|
68
79
|
},
|
|
69
80
|
"./client": {
|
|
81
|
+
"source": "./src/client/index.ts",
|
|
70
82
|
"types": "./src/client/index.ts",
|
|
71
83
|
"default": "./src/client/index.ts"
|
|
72
84
|
},
|
|
73
85
|
"./schema": {
|
|
86
|
+
"source": "./src/schema.ts",
|
|
74
87
|
"types": "./src/schema.ts",
|
|
75
88
|
"default": "./src/schema.ts"
|
|
76
89
|
},
|
|
77
90
|
"./handler": {
|
|
91
|
+
"source": "./src/handler/index.ts",
|
|
78
92
|
"types": "./src/handler/index.ts",
|
|
79
93
|
"default": "./src/handler/index.ts"
|
|
80
94
|
},
|
|
81
95
|
"./plugin": {
|
|
96
|
+
"source": "./src/plugin.ts",
|
|
82
97
|
"types": "./src/plugin.ts",
|
|
83
98
|
"default": "./src/plugin.ts"
|
|
84
99
|
},
|
|
85
100
|
"./env": {
|
|
101
|
+
"source": "./src/env.ts",
|
|
86
102
|
"types": "./src/env.ts",
|
|
87
103
|
"default": "./src/env.js"
|
|
88
104
|
},
|
|
89
105
|
"./sst": {
|
|
106
|
+
"source": "./src/sst.ts",
|
|
90
107
|
"types": "./src/sst.ts",
|
|
91
108
|
"default": "./src/sst.ts"
|
|
92
109
|
}
|
|
@@ -160,7 +177,7 @@
|
|
|
160
177
|
"jest": "29.7.0",
|
|
161
178
|
"react": "19.2.0",
|
|
162
179
|
"ts-jest": "29.4.9",
|
|
163
|
-
"@everystack/server": "0.4.
|
|
180
|
+
"@everystack/server": "0.4.26"
|
|
164
181
|
},
|
|
165
182
|
"scripts": {
|
|
166
183
|
"test": "jest",
|
package/src/cli/aws.ts
CHANGED
|
@@ -734,10 +734,14 @@ export async function describeRdsSnapshots(
|
|
|
734
734
|
*
|
|
735
735
|
* The call is a no-code-upload configuration change — it completes in ~2s,
|
|
736
736
|
* and Lambda drains in-flight requests before retiring each environment.
|
|
737
|
+
*
|
|
738
|
+
* Optional extraEnv merges additional env vars (e.g. rotated credentials).
|
|
739
|
+
* Existing env vars are preserved (read-modify-write).
|
|
737
740
|
*/
|
|
738
741
|
export async function rollingRestart(
|
|
739
742
|
region: string,
|
|
740
743
|
functionName: string,
|
|
744
|
+
extraEnv?: Record<string, string>,
|
|
741
745
|
): Promise<{ previousGen: string; newGen: string }> {
|
|
742
746
|
const client = await getLambda(region);
|
|
743
747
|
const {
|
|
@@ -755,7 +759,7 @@ export async function rollingRestart(
|
|
|
755
759
|
await client.send(new UpdateFunctionConfigurationCommand({
|
|
756
760
|
FunctionName: functionName,
|
|
757
761
|
Environment: {
|
|
758
|
-
Variables: { ...env, EVERYSTACK_GEN: newGen },
|
|
762
|
+
Variables: { ...env, ...extraEnv, EVERYSTACK_GEN: newGen },
|
|
759
763
|
},
|
|
760
764
|
}));
|
|
761
765
|
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -258,7 +258,7 @@ export function buildProvisionSecretPlan(args: {
|
|
|
258
258
|
* same seam as pipeline-loader). */
|
|
259
259
|
export interface ServerProvision {
|
|
260
260
|
runProvision(
|
|
261
|
-
payload: { authPassword?: string; adminPassword?: string; searchPath?: string[] },
|
|
261
|
+
payload: { authPassword?: string; adminPassword?: string; searchPath?: string[]; rotationPairs?: boolean },
|
|
262
262
|
deps: {
|
|
263
263
|
execute: (sql: string) => Promise<unknown>;
|
|
264
264
|
connection?: { host: string; port?: number | string; database: string } | null;
|
|
@@ -471,12 +471,277 @@ export function parseUrlConnection(url: string): { host: string; port: string; d
|
|
|
471
471
|
};
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* db:provision --rotate — swap the active A/B role for one or both credential slots.
|
|
476
|
+
*
|
|
477
|
+
* Steps: preflight master → detect active → provision inactive → write SSM →
|
|
478
|
+
* rolling restart → verify origin → retire old → drain sessions.
|
|
479
|
+
*/
|
|
480
|
+
async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
481
|
+
const stage = flags.stage!;
|
|
482
|
+
const graceMs = (flags.grace ? Number(flags.grace) : 60) * 1000;
|
|
483
|
+
|
|
484
|
+
step('Resolving deployed config...');
|
|
485
|
+
let config;
|
|
486
|
+
try {
|
|
487
|
+
config = await resolveConfig(stage);
|
|
488
|
+
} catch (err: any) {
|
|
489
|
+
fail(err.message);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets');
|
|
494
|
+
const { parseAppName } = await import('../discover');
|
|
495
|
+
const secretsCtx = { appName: await parseAppName(), stage, region: config.region };
|
|
496
|
+
let stageSecrets: Record<string, string> = {};
|
|
497
|
+
try {
|
|
498
|
+
stageSecrets = await loadSstSecrets(secretsCtx);
|
|
499
|
+
} catch (err: any) {
|
|
500
|
+
fail(`Cannot read stage secrets: ${err.message}`);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Step 0: preflight — verify the operator connection works (break-glass path).
|
|
505
|
+
step('Preflight: verifying operator connection...');
|
|
506
|
+
const opsFn = opsFunction(config);
|
|
507
|
+
try {
|
|
508
|
+
const result = await invokeAction(config.region, opsFn, 'db:query', { sql: 'SELECT current_user' });
|
|
509
|
+
if ((result as any)?.error) throw new Error((result as any).error);
|
|
510
|
+
success('Operator connection verified.');
|
|
511
|
+
} catch (err: any) {
|
|
512
|
+
fail(`Operator connection failed: ${err.message}`);
|
|
513
|
+
info('Fix the operator connection before rotating.');
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
|
|
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;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
|
|
526
|
+
// Detect which slots to rotate.
|
|
527
|
+
const slotFlag = flags.slot || 'both';
|
|
528
|
+
if (!['api', 'ops', 'both'].includes(slotFlag)) {
|
|
529
|
+
fail(`Invalid --slot: ${slotFlag}. Use api, ops, or both.`);
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
const slots: Array<{ base: string; secretKeys: string[] }> = [];
|
|
533
|
+
if (slotFlag === 'api' || slotFlag === 'both') {
|
|
534
|
+
slots.push({ base: 'authenticator', secretKeys: ['DatabaseUrl', 'ReadDatabaseUrl'] });
|
|
535
|
+
}
|
|
536
|
+
if (slotFlag === 'ops' || slotFlag === 'both') {
|
|
537
|
+
slots.push({ base: 'migrator', secretKeys: ['AdminDatabaseUrl'] });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const { randomBytes } = await import('node:crypto');
|
|
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;
|
|
554
|
+
|
|
555
|
+
// Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
|
|
556
|
+
const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
|
|
557
|
+
// Track credential env overrides to set during rolling restart.
|
|
558
|
+
const credEnvOverrides: Record<string, string> = {};
|
|
559
|
+
|
|
560
|
+
for (const { base, secretKeys } of slots) {
|
|
561
|
+
step(`Rotating ${base}...`);
|
|
562
|
+
|
|
563
|
+
const detected = await rotate('detect', { baseRole: base });
|
|
564
|
+
if (!detected || (!detected.activeRole && !detected.inactiveRole)) {
|
|
565
|
+
fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
|
|
566
|
+
process.exit(1);
|
|
567
|
+
}
|
|
568
|
+
info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
|
|
569
|
+
oldActiveRoles.push({ base, oldRole: detected.activeRole });
|
|
570
|
+
|
|
571
|
+
// Provision the inactive role via the ops Lambda.
|
|
572
|
+
const password = randomBytes(24).toString('hex');
|
|
573
|
+
const provisioned = await rotate('provision', { baseRole: base, password });
|
|
574
|
+
success(` ${detected.inactiveRole} provisioned with new password.`);
|
|
575
|
+
|
|
576
|
+
// Build the new URL.
|
|
577
|
+
const newUrl = preserveConnParams(
|
|
578
|
+
`postgresql://${detected.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
|
|
579
|
+
sourceUrl,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// Write to the SST secret store (blob-first per Fable ruling — must
|
|
583
|
+
// complete before UpdateFunctionConfiguration so a crash at any point
|
|
584
|
+
// is recovered by re-running rotate or by deploying).
|
|
585
|
+
step(` Updating secrets (${secretKeys.join(', ')})...`);
|
|
586
|
+
try {
|
|
587
|
+
const secrets = { ...stageSecrets };
|
|
588
|
+
for (const key of secretKeys) {
|
|
589
|
+
let credUrl = newUrl;
|
|
590
|
+
if (key === 'ReadDatabaseUrl') {
|
|
591
|
+
const existingReadUrl = stageSecrets.READ_DATABASE_URL ?? stageSecrets.ReadDatabaseUrl;
|
|
592
|
+
if (existingReadUrl) {
|
|
593
|
+
try {
|
|
594
|
+
const u = new URL(newUrl);
|
|
595
|
+
u.hostname = new URL(existingReadUrl).hostname;
|
|
596
|
+
credUrl = u.toString();
|
|
597
|
+
} catch { /* use primary host */ }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
secrets[key] = credUrl;
|
|
601
|
+
credEnvOverrides[`EVERYSTACK_CRED_${key}`] = credUrl;
|
|
602
|
+
}
|
|
603
|
+
await putSstSecrets(secretsCtx, secrets);
|
|
604
|
+
stageSecrets = secrets;
|
|
605
|
+
success(` Secrets written.`);
|
|
606
|
+
} catch (err: any) {
|
|
607
|
+
fail(` Secret write failed: ${err.message}`);
|
|
608
|
+
info(' Ensure your IAM role has s3:PutObject + kms:Encrypt on the SST state bucket.');
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Rolling restart all linked functions with credential env overrides.
|
|
614
|
+
// Each function gets only the credentials it links — preserving the split.
|
|
615
|
+
// Track failures on credential-carrying functions — if ANY fail, abort
|
|
616
|
+
// before retire (both roles keep LOGIN = safe re-runnable state).
|
|
617
|
+
const { rollingRestart } = await import('../aws');
|
|
618
|
+
step('Rolling restart...');
|
|
619
|
+
const apiCreds: Record<string, string> = {};
|
|
620
|
+
const opsCreds: Record<string, string> = {};
|
|
621
|
+
for (const [k, v] of Object.entries(credEnvOverrides)) {
|
|
622
|
+
if (k.includes('Admin')) {
|
|
623
|
+
opsCreds[k] = v;
|
|
624
|
+
} else {
|
|
625
|
+
apiCreds[k] = v;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const allCreds = { ...apiCreds, ...opsCreds };
|
|
629
|
+
const functions: Array<{ name: string; creds: Record<string, string>; critical: boolean }> = [
|
|
630
|
+
{ name: config.apiFunctionName, creds: apiCreds, critical: true },
|
|
631
|
+
...(config.opsFunctionName ? [{ name: config.opsFunctionName, creds: allCreds, critical: true }] : []),
|
|
632
|
+
...(config.workerFunctionName ? [{ name: config.workerFunctionName, creds: {}, critical: false }] : []),
|
|
633
|
+
...(config.imageFunctionName ? [{ name: config.imageFunctionName, creds: {}, critical: false }] : []),
|
|
634
|
+
];
|
|
635
|
+
|
|
636
|
+
let restartFailed = false;
|
|
637
|
+
for (const { name: fn, creds, critical } of functions) {
|
|
638
|
+
try {
|
|
639
|
+
const { newGen } = await rollingRestart(config.region, fn, creds);
|
|
640
|
+
success(` ${fn} → gen ${newGen}`);
|
|
641
|
+
} catch (err: any) {
|
|
642
|
+
if (critical) {
|
|
643
|
+
fail(` ${fn}: ${err.message}`);
|
|
644
|
+
restartFailed = true;
|
|
645
|
+
} else {
|
|
646
|
+
warn(` ${fn}: ${err.message}`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (restartFailed) {
|
|
652
|
+
fail('Restart failed on a credential-carrying function. Both roles keep LOGIN (safe state).');
|
|
653
|
+
info('Fix the issue and re-run db:provision --rotate. No credential was revoked.');
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
|
|
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.
|
|
659
|
+
step('Verifying new credential...');
|
|
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);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Origin HTTP check (non-blocking — the login probe is the real gate).
|
|
673
|
+
if (config.baseUrl) {
|
|
674
|
+
try {
|
|
675
|
+
const probe = `${config.baseUrl}?_probe=${Date.now()}`;
|
|
676
|
+
const res = await fetch(probe, { redirect: 'follow' });
|
|
677
|
+
if (res.ok) success('Origin responding.');
|
|
678
|
+
else warn(`Origin returned ${res.status}.`);
|
|
679
|
+
} catch (err: any) {
|
|
680
|
+
warn(`Origin probe failed: ${err.message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Retire old roles and drain sessions — using the roles recorded at
|
|
685
|
+
// detection time, NOT re-detected (F1 fix: both roles have LOGIN now,
|
|
686
|
+
// re-detection would pick the wrong one via the tiebreak).
|
|
687
|
+
for (const { base, oldRole } of oldActiveRoles) {
|
|
688
|
+
step(`Retiring ${oldRole}...`);
|
|
689
|
+
await rotate('retire', { role: oldRole });
|
|
690
|
+
success(` ${oldRole} set to NOLOGIN.`);
|
|
691
|
+
|
|
692
|
+
// Also retire the base role if it still has LOGIN (F6 fix).
|
|
693
|
+
try {
|
|
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
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch { /* best-effort */ }
|
|
707
|
+
|
|
708
|
+
step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
|
|
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 });
|
|
719
|
+
success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
success('Credential rotation complete.');
|
|
724
|
+
|
|
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
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
474
734
|
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
475
735
|
if (!flags.stage) {
|
|
476
736
|
fail('--stage is required for db:provision (it writes the stage\'s DATABASE_URL secret — the new credentials are never displayed, so they must land in the secret store)');
|
|
477
737
|
process.exit(1);
|
|
478
738
|
}
|
|
479
739
|
|
|
740
|
+
if (flags.rotate) {
|
|
741
|
+
await rotateCredentials(flags);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
480
745
|
step('Resolving deployed config...');
|
|
481
746
|
let config;
|
|
482
747
|
try {
|
|
@@ -558,7 +823,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
558
823
|
const { createUrlRunner } = await import('../db-source');
|
|
559
824
|
const { runner, end } = await createUrlRunner(url);
|
|
560
825
|
try {
|
|
561
|
-
return await runProvision({ authPassword, adminPassword, searchPath }, {
|
|
826
|
+
return await runProvision({ authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] }, {
|
|
562
827
|
execute: async (statement) => runner(statement),
|
|
563
828
|
connection: parseUrlConnection(url),
|
|
564
829
|
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
@@ -591,7 +856,7 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
591
856
|
conn = parseUrlConnection(directUrl);
|
|
592
857
|
} else {
|
|
593
858
|
try {
|
|
594
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath });
|
|
859
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword, searchPath, rotationPairs: !!flags['rotation-pairs'] });
|
|
595
860
|
} catch (err: any) {
|
|
596
861
|
// Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
|
|
597
862
|
// provision directly over it. Master never crosses the CLI; the operator sets nothing new.
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -85,7 +85,7 @@ export async function diagCommand(
|
|
|
85
85
|
cacheStatus: response.headers.get('x-cache') ?? undefined,
|
|
86
86
|
cacheAge: response.headers.get('age') ?? undefined,
|
|
87
87
|
cacheControl: response.headers.get('cache-control') ?? undefined,
|
|
88
|
-
serverDebug: response.headers.get('x-
|
|
88
|
+
serverDebug: response.headers.get('x-everystack-release') ?? response.headers.get('x-everystack-created') ?? undefined,
|
|
89
89
|
};
|
|
90
90
|
|
|
91
91
|
// Fetch full HTML if hydration analysis requested
|
|
@@ -138,7 +138,7 @@ export async function diagCommand(
|
|
|
138
138
|
if (diag.serverDebug) {
|
|
139
139
|
console.log(` Serving Release: ${diag.serverDebug}`);
|
|
140
140
|
} else {
|
|
141
|
-
warn(' No
|
|
141
|
+
warn(' No x-everystack-release header found. Update @everystack/server (>= 0.4.22) to get release headers on SSR responses.');
|
|
142
142
|
}
|
|
143
143
|
console.log('');
|
|
144
144
|
|
|
@@ -168,15 +168,15 @@ export async function diagCommand(
|
|
|
168
168
|
// 4. Version comparison
|
|
169
169
|
if (!manifest) {
|
|
170
170
|
warn(' Cannot compare: no manifest available.');
|
|
171
|
-
} else if (diag.serverDebug && manifest.
|
|
172
|
-
if (diag.serverDebug === manifest.
|
|
171
|
+
} else if (diag.serverDebug && manifest.updateId) {
|
|
172
|
+
if (diag.serverDebug === manifest.updateId) {
|
|
173
173
|
success(' Version match: serving the latest release');
|
|
174
174
|
} else {
|
|
175
|
-
fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.
|
|
175
|
+
fail(` Version mismatch: serving ${diag.serverDebug}, latest is ${manifest.updateId}`);
|
|
176
176
|
info(' Run `everystack cache:purge --origin web` to bust the CDN cache.');
|
|
177
177
|
}
|
|
178
178
|
} else {
|
|
179
|
-
warn(' Cannot compare: missing
|
|
179
|
+
warn(' Cannot compare: missing x-everystack-release header or manifest updateId.');
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
console.log('');
|
package/src/cli/index.ts
CHANGED
|
@@ -353,7 +353,8 @@ Usage:
|
|
|
353
353
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
354
354
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
355
355
|
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)
|
|
356
|
-
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain
|
|
356
|
+
everystack db:provision --stage <name> [--direct | --database-url <url>] [--rotation-pairs] Create the least-privilege role chain (idempotent). --rotation-pairs creates A/B pairs for live rotation
|
|
357
|
+
everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
|
|
357
358
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
358
359
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
359
360
|
everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)
|