@everystack/cli 0.4.61 → 0.4.64
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.64",
|
|
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.25"
|
|
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
|
+
// Resolve the direct connection (the rotate command always runs direct — it needs
|
|
505
|
+
// to query pg_roles and ALTER ROLE, which the ops action doesn't expose).
|
|
506
|
+
const adminUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl;
|
|
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...');
|
|
514
|
+
try {
|
|
515
|
+
const result = await invokeAction(config.region, opsFunction(config), 'db:query', { sql: 'SELECT current_user' });
|
|
516
|
+
if ((result as any)?.error) throw new Error((result as any).error);
|
|
517
|
+
success('Master connection verified.');
|
|
518
|
+
} catch (err: any) {
|
|
519
|
+
fail(`Master connection failed: ${err.message}`);
|
|
520
|
+
info('The master credential is the break-glass. Fix it before rotating.');
|
|
521
|
+
process.exit(1);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Connect directly for role management.
|
|
525
|
+
const { createUrlRunner } = await import('../db-source');
|
|
526
|
+
const { runner, end } = await createUrlRunner(adminUrl);
|
|
527
|
+
const deps = {
|
|
528
|
+
query: async (sql: string) => runner(sql) as Promise<any[]>,
|
|
529
|
+
execute: async (sql: string) => { await runner(sql); },
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
const {
|
|
534
|
+
detectActiveRole,
|
|
535
|
+
provisionInactiveRole,
|
|
536
|
+
retireRole,
|
|
537
|
+
drainRole,
|
|
538
|
+
} = await import('@everystack/server/rotate');
|
|
539
|
+
|
|
540
|
+
// Detect which slots to rotate.
|
|
541
|
+
const slotFlag = flags.slot || 'both';
|
|
542
|
+
if (!['api', 'ops', 'both'].includes(slotFlag)) {
|
|
543
|
+
fail(`Invalid --slot: ${slotFlag}. Use api, ops, or both.`);
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
const slots: Array<{ base: string; secretKeys: string[] }> = [];
|
|
547
|
+
if (slotFlag === 'api' || slotFlag === 'both') {
|
|
548
|
+
slots.push({ base: 'authenticator', secretKeys: ['DatabaseUrl', 'ReadDatabaseUrl'] });
|
|
549
|
+
}
|
|
550
|
+
if (slotFlag === 'ops' || slotFlag === 'both') {
|
|
551
|
+
slots.push({ base: 'migrator', secretKeys: ['AdminDatabaseUrl'] });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const { randomBytes } = await import('node:crypto');
|
|
555
|
+
const conn = parseUrlConnection(adminUrl);
|
|
556
|
+
const sourceUrl = adminUrl;
|
|
557
|
+
|
|
558
|
+
// Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
|
|
559
|
+
const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
|
|
560
|
+
// Track credential env overrides to set during rolling restart.
|
|
561
|
+
const credEnvOverrides: Record<string, string> = {};
|
|
562
|
+
|
|
563
|
+
for (const { base, secretKeys } of slots) {
|
|
564
|
+
step(`Rotating ${base}...`);
|
|
565
|
+
|
|
566
|
+
const detected = await detectActiveRole(base, deps);
|
|
567
|
+
if (!detected) {
|
|
568
|
+
fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
|
|
572
|
+
oldActiveRoles.push({ base, oldRole: detected.activeRole });
|
|
573
|
+
const active = detected;
|
|
574
|
+
|
|
575
|
+
// Provision the inactive role.
|
|
576
|
+
const password = randomBytes(24).toString('hex');
|
|
577
|
+
await provisionInactiveRole(active, password, deps);
|
|
578
|
+
success(` ${active.inactiveRole} provisioned with new password.`);
|
|
579
|
+
|
|
580
|
+
// Build the new URL.
|
|
581
|
+
const newUrl = preserveConnParams(
|
|
582
|
+
`postgresql://${active.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
|
|
583
|
+
sourceUrl,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
// Write to the SST secret store (blob-first per Fable ruling — must
|
|
587
|
+
// complete before UpdateFunctionConfiguration so a crash at any point
|
|
588
|
+
// is recovered by re-running rotate or by deploying).
|
|
589
|
+
step(` Updating secrets (${secretKeys.join(', ')})...`);
|
|
590
|
+
try {
|
|
591
|
+
const secrets = { ...stageSecrets };
|
|
592
|
+
for (const key of secretKeys) {
|
|
593
|
+
let credUrl = newUrl;
|
|
594
|
+
if (key === 'ReadDatabaseUrl') {
|
|
595
|
+
const existingReadUrl = stageSecrets.READ_DATABASE_URL ?? stageSecrets.ReadDatabaseUrl;
|
|
596
|
+
if (existingReadUrl) {
|
|
597
|
+
try {
|
|
598
|
+
const u = new URL(newUrl);
|
|
599
|
+
u.hostname = new URL(existingReadUrl).hostname;
|
|
600
|
+
credUrl = u.toString();
|
|
601
|
+
} catch { /* use primary host */ }
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
secrets[key] = credUrl;
|
|
605
|
+
credEnvOverrides[`EVERYSTACK_CRED_${key}`] = credUrl;
|
|
606
|
+
}
|
|
607
|
+
await putSstSecrets(secretsCtx, secrets);
|
|
608
|
+
stageSecrets = secrets;
|
|
609
|
+
success(` Secrets written.`);
|
|
610
|
+
} catch (err: any) {
|
|
611
|
+
fail(` Secret write failed: ${err.message}`);
|
|
612
|
+
info(' Ensure your IAM role has s3:PutObject + kms:Encrypt on the SST state bucket.');
|
|
613
|
+
process.exit(1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Rolling restart all linked functions with credential env overrides.
|
|
618
|
+
// Each function gets only the credentials it links — preserving the split.
|
|
619
|
+
// Track failures on credential-carrying functions — if ANY fail, abort
|
|
620
|
+
// before retire (both roles keep LOGIN = safe re-runnable state).
|
|
621
|
+
const { rollingRestart } = await import('../aws');
|
|
622
|
+
step('Rolling restart...');
|
|
623
|
+
const apiCreds: Record<string, string> = {};
|
|
624
|
+
const opsCreds: Record<string, string> = {};
|
|
625
|
+
for (const [k, v] of Object.entries(credEnvOverrides)) {
|
|
626
|
+
if (k.includes('Admin')) {
|
|
627
|
+
opsCreds[k] = v;
|
|
628
|
+
} else {
|
|
629
|
+
apiCreds[k] = v;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const allCreds = { ...apiCreds, ...opsCreds };
|
|
633
|
+
const functions: Array<{ name: string; creds: Record<string, string>; critical: boolean }> = [
|
|
634
|
+
{ name: config.apiFunctionName, creds: apiCreds, critical: true },
|
|
635
|
+
...(config.opsFunctionName ? [{ name: config.opsFunctionName, creds: allCreds, critical: true }] : []),
|
|
636
|
+
...(config.workerFunctionName ? [{ name: config.workerFunctionName, creds: {}, critical: false }] : []),
|
|
637
|
+
...(config.imageFunctionName ? [{ name: config.imageFunctionName, creds: {}, critical: false }] : []),
|
|
638
|
+
];
|
|
639
|
+
|
|
640
|
+
let restartFailed = false;
|
|
641
|
+
for (const { name: fn, creds, critical } of functions) {
|
|
642
|
+
try {
|
|
643
|
+
const { newGen } = await rollingRestart(config.region, fn, creds);
|
|
644
|
+
success(` ${fn} → gen ${newGen}`);
|
|
645
|
+
} catch (err: any) {
|
|
646
|
+
if (critical) {
|
|
647
|
+
fail(` ${fn}: ${err.message}`);
|
|
648
|
+
restartFailed = true;
|
|
649
|
+
} else {
|
|
650
|
+
warn(` ${fn}: ${err.message}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (restartFailed) {
|
|
656
|
+
fail('Restart failed on a credential-carrying function. Both roles keep LOGIN (safe state).');
|
|
657
|
+
info('Fix the issue and re-run db:provision --rotate. No credential was revoked.');
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Verify origin with a login probe — prove the new credential works
|
|
662
|
+
// before revoking the old one.
|
|
663
|
+
step('Verifying new credential...');
|
|
664
|
+
let verifyPassed = false;
|
|
665
|
+
try {
|
|
666
|
+
const { default: pg } = await import('postgres');
|
|
667
|
+
const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
|
|
668
|
+
if (newApiUrl) {
|
|
669
|
+
const probe = pg(newApiUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
670
|
+
try {
|
|
671
|
+
await probe`SELECT 1`;
|
|
672
|
+
verifyPassed = true;
|
|
673
|
+
success('New credential connects to the database.');
|
|
674
|
+
} finally {
|
|
675
|
+
await probe.end({ timeout: 1 });
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
verifyPassed = true;
|
|
679
|
+
}
|
|
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
|
+
}
|
|
685
|
+
|
|
686
|
+
// Origin HTTP check (non-blocking — the login probe is the real gate).
|
|
687
|
+
if (config.baseUrl) {
|
|
688
|
+
try {
|
|
689
|
+
const probe = `${config.baseUrl}?_probe=${Date.now()}`;
|
|
690
|
+
const res = await fetch(probe, { redirect: 'follow' });
|
|
691
|
+
if (res.ok) success('Origin responding.');
|
|
692
|
+
else warn(`Origin returned ${res.status}.`);
|
|
693
|
+
} catch (err: any) {
|
|
694
|
+
warn(`Origin probe failed: ${err.message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Retire old roles and drain sessions — using the roles recorded at
|
|
699
|
+
// detection time, NOT re-detected (F1 fix: both roles have LOGIN now,
|
|
700
|
+
// re-detection would pick the wrong one via the tiebreak).
|
|
701
|
+
for (const { base, oldRole } of oldActiveRoles) {
|
|
702
|
+
step(`Retiring ${oldRole}...`);
|
|
703
|
+
await retireRole(oldRole, deps);
|
|
704
|
+
success(` ${oldRole} set to NOLOGIN.`);
|
|
705
|
+
|
|
706
|
+
// Also retire the base role if it still has LOGIN (F6 fix: a leaked
|
|
707
|
+
// pre-rotation credential must not survive rotation).
|
|
708
|
+
try {
|
|
709
|
+
const baseRows = await deps.query(
|
|
710
|
+
`SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
|
|
711
|
+
);
|
|
712
|
+
if (baseRows[0]?.rolcanlogin) {
|
|
713
|
+
await retireRole(base, deps);
|
|
714
|
+
success(` ${base} (base role) set to NOLOGIN.`);
|
|
715
|
+
}
|
|
716
|
+
} catch { /* best-effort */ }
|
|
717
|
+
|
|
718
|
+
step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
|
|
719
|
+
const { drained, terminated } = await drainRole(oldRole, graceMs, deps);
|
|
720
|
+
if (drained && terminated === 0) {
|
|
721
|
+
success(` ${oldRole} drained cleanly.`);
|
|
722
|
+
} else if (drained) {
|
|
723
|
+
success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
success('Credential rotation complete.');
|
|
728
|
+
|
|
729
|
+
} finally {
|
|
730
|
+
await end?.();
|
|
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>)
|