@everystack/cli 0.4.60 → 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/backfill.ts +3 -3
- package/src/cli/alter-type-dependents.ts +1 -1
- package/src/cli/apply-authority.ts +1 -1
- package/src/cli/apply-execute.ts +10 -10
- package/src/cli/audit-api.ts +4 -4
- package/src/cli/audit-source-api.ts +2 -2
- package/src/cli/authz-adoption-class.ts +4 -4
- package/src/cli/authz-baseline.ts +2 -2
- package/src/cli/authz-canonical.ts +2 -2
- package/src/cli/authz-compile.ts +3 -3
- package/src/cli/authz-contract-io.ts +2 -2
- package/src/cli/authz-contract.ts +4 -4
- package/src/cli/authz-derive.ts +2 -2
- package/src/cli/authz-identity.ts +1 -1
- package/src/cli/authz-ownership.ts +1 -1
- package/src/cli/authz-reconcile.ts +4 -4
- package/src/cli/authz-redteam.ts +1 -1
- package/src/cli/authz-render.ts +2 -2
- package/src/cli/aws.ts +57 -4
- package/src/cli/backfill.ts +3 -3
- package/src/cli/branch-db.ts +5 -5
- package/src/cli/bundle-weight.ts +2 -2
- package/src/cli/commands/analyze.ts +2 -2
- package/src/cli/commands/audit.ts +10 -10
- package/src/cli/commands/bundle.ts +6 -6
- package/src/cli/commands/cache.ts +75 -3
- package/src/cli/commands/console.ts +6 -6
- package/src/cli/commands/db-apply.ts +25 -25
- package/src/cli/commands/db-approvers.ts +4 -4
- package/src/cli/commands/db-authz.ts +15 -15
- package/src/cli/commands/db-backfill.ts +10 -10
- package/src/cli/commands/db-backup.ts +7 -7
- package/src/cli/commands/db-branch.ts +8 -8
- package/src/cli/commands/db-build.ts +7 -7
- package/src/cli/commands/db-check.ts +16 -16
- package/src/cli/commands/db-diff.ts +6 -6
- package/src/cli/commands/db-exec.ts +8 -8
- package/src/cli/commands/db-export.ts +10 -10
- package/src/cli/commands/db-fingerprint.ts +14 -14
- package/src/cli/commands/db-fork.ts +6 -6
- package/src/cli/commands/db-generate.ts +19 -19
- package/src/cli/commands/db-plan.ts +25 -25
- package/src/cli/commands/db-pull.ts +18 -18
- package/src/cli/commands/db-reconcile.ts +14 -14
- package/src/cli/commands/db-refresh.ts +7 -7
- package/src/cli/commands/db-snapshot.ts +5 -5
- package/src/cli/commands/db-swap.ts +26 -26
- package/src/cli/commands/db-sync.ts +18 -18
- package/src/cli/commands/db.ts +283 -18
- package/src/cli/commands/deploy.ts +39 -5
- package/src/cli/commands/diag.ts +10 -10
- package/src/cli/commands/lighthouse.ts +3 -3
- package/src/cli/commands/logs.ts +5 -5
- package/src/cli/commands/pipeline-run.ts +7 -7
- package/src/cli/commands/runbook.ts +5 -5
- package/src/cli/commands/secrets.ts +5 -5
- package/src/cli/commands/security-probe.ts +4 -4
- package/src/cli/commands/security.ts +6 -6
- package/src/cli/commands/status.ts +3 -3
- package/src/cli/commands/task-probe.ts +4 -4
- package/src/cli/commands/ui-audit.ts +5 -5
- package/src/cli/commands/update.ts +64 -8
- package/src/cli/component-audit.ts +5 -5
- package/src/cli/config.ts +2 -2
- package/src/cli/db-build.ts +6 -6
- package/src/cli/db-source.ts +2 -2
- package/src/cli/declared-derived.ts +5 -5
- package/src/cli/declared-diff.ts +5 -5
- package/src/cli/deploy-probe.ts +3 -3
- package/src/cli/derived-apply.ts +5 -5
- package/src/cli/derived-compile.ts +4 -4
- package/src/cli/derived-grants.ts +1 -1
- package/src/cli/derived-introspect.ts +3 -3
- package/src/cli/derived-lint.ts +2 -2
- package/src/cli/derived-plan.ts +5 -5
- package/src/cli/derived-render.ts +6 -6
- package/src/cli/direct-venue.ts +2 -2
- package/src/cli/discover.ts +2 -2
- package/src/cli/edge-plan.ts +8 -8
- package/src/cli/exec-digest.ts +1 -1
- package/src/cli/exec-execute.ts +1 -1
- package/src/cli/exec-log.ts +1 -1
- package/src/cli/exec-run.ts +2 -2
- package/src/cli/git-descent.ts +5 -5
- package/src/cli/index.ts +48 -47
- package/src/cli/migration-compile.ts +7 -7
- package/src/cli/migration-generate.ts +6 -6
- package/src/cli/model-api.ts +16 -16
- package/src/cli/model-render.ts +6 -6
- package/src/cli/mutation-lease.ts +1 -1
- package/src/cli/observability.ts +1 -1
- package/src/cli/refresh-execute.ts +2 -2
- package/src/cli/runbook/generate.ts +3 -3
- package/src/cli/runbook/index.ts +7 -7
- package/src/cli/runbook/sections.ts +2 -2
- package/src/cli/schema-compile.ts +4 -4
- package/src/cli/schema-diff.ts +1 -1
- package/src/cli/schema-fingerprint.ts +7 -7
- package/src/cli/schema-introspect.ts +3 -3
- package/src/cli/schema-source.ts +1 -1
- package/src/cli/schema-swap.ts +4 -4
- package/src/cli/security-catalog.ts +1 -1
- package/src/cli/stage-backup.ts +3 -3
- package/src/cli/stage-read-consistency.ts +6 -6
- package/src/cli/state-apply.ts +7 -7
- package/src/cli/swap-execute.ts +3 -3
- package/src/cli/swap-heartbeat.ts +1 -1
- package/src/cli/swap-pair.ts +7 -7
- package/src/cli/task-poll.ts +2 -2
- package/src/cli/ui-bloat.ts +1 -1
- package/src/cli/utils/secrets.ts +1 -1
- package/src/exec.ts +7 -7
- package/src/reconcile.ts +2 -2
- package/src/refresh.ts +2 -2
- package/src/sst.ts +1 -1
package/src/cli/commands/db.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* No HTTP endpoints, no shared secrets — IAM is the authorization layer.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { resolveConfig, opsFunction } from '../config
|
|
9
|
-
import { invokeAction } from '../aws
|
|
10
|
-
import { step, success, fail, info, warn } from '../output
|
|
11
|
-
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice
|
|
8
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
9
|
+
import { invokeAction } from '../aws';
|
|
10
|
+
import { step, success, fail, info, warn } from '../output';
|
|
11
|
+
import { opsAdviceLines, IAM_ADVICE } from '../ops-advice';
|
|
12
12
|
|
|
13
13
|
/** Secret-store keys that may hold the privileged (operator) connection URL,
|
|
14
14
|
* in precedence order. Names vary by deployment; --secret-name overrides. */
|
|
@@ -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 {
|
|
@@ -488,8 +753,8 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
488
753
|
|
|
489
754
|
// Load the stage secrets once — the direct venue reads the privileged URL from here (never
|
|
490
755
|
// from argv), and the writer below reuses them.
|
|
491
|
-
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets
|
|
492
|
-
const { parseAppName } = await import('../discover
|
|
756
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets');
|
|
757
|
+
const { parseAppName } = await import('../discover');
|
|
493
758
|
const secretsCtx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
494
759
|
let stageSecrets: Record<string, string> = {};
|
|
495
760
|
try {
|
|
@@ -527,12 +792,12 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
527
792
|
// all-public app sets no path (unchanged behavior).
|
|
528
793
|
let declaredSchemas: string[] = [];
|
|
529
794
|
try {
|
|
530
|
-
const { resolveModelsPath } = await import('../models-path
|
|
531
|
-
const { loadModels } = await import('./db-generate
|
|
795
|
+
const { resolveModelsPath } = await import('../models-path');
|
|
796
|
+
const { loadModels } = await import('./db-generate');
|
|
532
797
|
const models = await loadModels(resolveModelsPath(flags.models));
|
|
533
798
|
let derivedObjects: Array<{ schema: string }> = [];
|
|
534
799
|
try {
|
|
535
|
-
const { loadDeclaredDerived } = await import('../declared-derived
|
|
800
|
+
const { loadDeclaredDerived } = await import('../declared-derived');
|
|
536
801
|
const declared = await loadDeclaredDerived(flags.models);
|
|
537
802
|
if (declared) derivedObjects = declared.objects;
|
|
538
803
|
} catch {
|
|
@@ -555,10 +820,10 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
555
820
|
// and the auto-fallback below.
|
|
556
821
|
const provisionDirect = async (url: string): Promise<any> => {
|
|
557
822
|
const { runProvision } = await loadServerProvision();
|
|
558
|
-
const { createUrlRunner } = await import('../db-source
|
|
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.
|
|
@@ -746,7 +1011,7 @@ function printDoctorReport(report: any): void {
|
|
|
746
1011
|
*/
|
|
747
1012
|
async function runDoctorDirect(apiUrl: string, adminUrl: string, sourceLabel: string): Promise<boolean> {
|
|
748
1013
|
const { runDoctorProbe } = await loadServerDoctor();
|
|
749
|
-
const { createUrlRunner } = await import('../db-source
|
|
1014
|
+
const { createUrlRunner } = await import('../db-source');
|
|
750
1015
|
step('Probing database connections (api + operator)...');
|
|
751
1016
|
const apiConn = await createUrlRunner(apiUrl);
|
|
752
1017
|
const opsConn = await createUrlRunner(adminUrl);
|
|
@@ -797,8 +1062,8 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
797
1062
|
|
|
798
1063
|
// Load the stage secrets once — the direct venue (--direct, and the no-Ops auto-fallback)
|
|
799
1064
|
// reads DATABASE_URL + ADMIN_DATABASE_URL from here, never from argv.
|
|
800
|
-
const { loadSstSecrets } = await import('../utils/secrets
|
|
801
|
-
const { parseAppName } = await import('../discover
|
|
1065
|
+
const { loadSstSecrets } = await import('../utils/secrets');
|
|
1066
|
+
const { parseAppName } = await import('../discover');
|
|
802
1067
|
let stageSecrets: Record<string, string> = {};
|
|
803
1068
|
try {
|
|
804
1069
|
stageSecrets = await loadSstSecrets({ appName: await parseAppName(), stage: flags.stage, region: config.region });
|
|
@@ -957,8 +1222,8 @@ export async function dbPsqlCommand(flags: Record<string, string>): Promise<void
|
|
|
957
1222
|
step('Resolving the admin connection from the secret store...');
|
|
958
1223
|
let secrets: Record<string, string>;
|
|
959
1224
|
try {
|
|
960
|
-
const { loadSstSecrets } = await import('../utils/secrets
|
|
961
|
-
const { parseAppName } = await import('../discover
|
|
1225
|
+
const { loadSstSecrets } = await import('../utils/secrets');
|
|
1226
|
+
const { parseAppName } = await import('../discover');
|
|
962
1227
|
const appName = await parseAppName();
|
|
963
1228
|
secrets = await loadSstSecrets({ appName, stage: flags.stage, region: config.region });
|
|
964
1229
|
} catch (err: any) {
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
|
-
import { step, success, fail, info, warn } from '../output
|
|
15
|
-
import { invalidateCachedConfig } from '../discover
|
|
16
|
-
import { resolveConfig } from '../config
|
|
17
|
-
import { probeDeployedFunctions } from '../deploy-probe
|
|
14
|
+
import { step, success, fail, info, warn } from '../output';
|
|
15
|
+
import { invalidateCachedConfig } from '../discover';
|
|
16
|
+
import { resolveConfig } from '../config';
|
|
17
|
+
import { probeDeployedFunctions } from '../deploy-probe';
|
|
18
|
+
import { getDistributionStatus } from '../aws';
|
|
18
19
|
|
|
19
20
|
/** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
|
|
20
21
|
export function buildDeployArgs(flags: Record<string, string>): string[] {
|
|
@@ -68,8 +69,9 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
|
|
|
68
69
|
// `sst deploy` exiting 0 only proves the code UPLOADED — a bundle that crashes at INIT still
|
|
69
70
|
// deploys "green" and takes the whole ops plane down until the next verb trips over it. Probe
|
|
70
71
|
// the deployed functions now so the operator learns here, with the real cause attached.
|
|
72
|
+
let config: Awaited<ReturnType<typeof resolveConfig>> | undefined;
|
|
71
73
|
try {
|
|
72
|
-
|
|
74
|
+
config = await resolveConfig(stage);
|
|
73
75
|
const { fatal } = await probeDeployedFunctions(stage, config);
|
|
74
76
|
if (fatal) {
|
|
75
77
|
fail('Deploy landed, but a deployed function cannot serve — every --stage verb will fail until this is fixed and redeployed.');
|
|
@@ -81,8 +83,40 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
|
|
|
81
83
|
info(`Skipped the post-deploy probe: ${err?.message ?? err}`);
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Wait for CloudFront distribution to finish propagating.
|
|
87
|
+
if (config?.distributionId) {
|
|
88
|
+
step('Waiting for CloudFront distribution...');
|
|
89
|
+
const deployed = await waitForDistribution(config.region, config.distributionId);
|
|
90
|
+
if (deployed) {
|
|
91
|
+
success('Distribution deployed.');
|
|
92
|
+
} else {
|
|
93
|
+
warn('Distribution did not reach Deployed within timeout. It may still be propagating.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
84
97
|
// The 0.4 flow: schema changes ride db:plan → db:apply. Migration-based apps still have
|
|
85
98
|
// db:migrate, but the copy-paste hint must advertise the canonical path — this exact
|
|
86
99
|
// line got copied into consumer runbooks with the retired verb.
|
|
87
100
|
info('Next: schema changes via `everystack db:plan --stage ' + stage + '` → `db:apply`; `everystack update` to publish the app bundle.');
|
|
88
101
|
}
|
|
102
|
+
|
|
103
|
+
const DISTRIBUTION_POLL_MS = 10_000;
|
|
104
|
+
const DISTRIBUTION_TIMEOUT_MS = 300_000;
|
|
105
|
+
|
|
106
|
+
/** @internal Exported for testing. */
|
|
107
|
+
export async function waitForDistribution(
|
|
108
|
+
region: string,
|
|
109
|
+
distributionId: string,
|
|
110
|
+
): Promise<boolean> {
|
|
111
|
+
const deadline = Date.now() + DISTRIBUTION_TIMEOUT_MS;
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
try {
|
|
114
|
+
const status = await getDistributionStatus(region, distributionId);
|
|
115
|
+
if (status === 'Deployed') return true;
|
|
116
|
+
} catch {
|
|
117
|
+
// Permission error or transient — keep trying.
|
|
118
|
+
}
|
|
119
|
+
await new Promise((r) => setTimeout(r, DISTRIBUTION_POLL_MS));
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
package/src/cli/commands/diag.ts
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* everystack diag [url] --hydration # Analyze SSR hydration issues
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { resolveConfig } from '../config
|
|
17
|
-
import { getFromS3 } from '../aws
|
|
18
|
-
import { step, success, fail, info, warn } from '../output
|
|
19
|
-
import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer
|
|
16
|
+
import { resolveConfig } from '../config';
|
|
17
|
+
import { getFromS3 } from '../aws';
|
|
18
|
+
import { step, success, fail, info, warn } from '../output';
|
|
19
|
+
import { analyzeHtml, generateHydrationReport } from '../hydration-analyzer';
|
|
20
20
|
|
|
21
21
|
interface DiagResult {
|
|
22
22
|
url: string;
|
|
@@ -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('');
|
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
formatLighthouseReport,
|
|
21
21
|
type ExtractedRun,
|
|
22
22
|
type LhReport,
|
|
23
|
-
} from '../lighthouse
|
|
24
|
-
import { resolveConfig } from '../config
|
|
25
|
-
import { step, info, fail, warn } from '../output
|
|
23
|
+
} from '../lighthouse';
|
|
24
|
+
import { resolveConfig } from '../config';
|
|
25
|
+
import { step, info, fail, warn } from '../output';
|
|
26
26
|
|
|
27
27
|
function runLighthouse(url: string, outPath: string): LhReport {
|
|
28
28
|
// Warm the edge so we measure the real-user (cached-HTML) experience.
|
package/src/cli/commands/logs.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* immune to the Lambda hash rotating on redeploy.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { resolveConfig } from '../config
|
|
14
|
-
import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws
|
|
15
|
-
import { summarizeRuntimeFailures } from '../ops-diagnostics
|
|
16
|
-
import { queryLogArchive, type ObservabilityQuery } from '../observability
|
|
17
|
-
import { step, success, fail, info } from '../output
|
|
13
|
+
import { resolveConfig } from '../config';
|
|
14
|
+
import { resolveLogGroup, fetchRecentLogEvents, importOptionalAws } from '../aws';
|
|
15
|
+
import { summarizeRuntimeFailures } from '../ops-diagnostics';
|
|
16
|
+
import { queryLogArchive, type ObservabilityQuery } from '../observability';
|
|
17
|
+
import { step, success, fail, info } from '../output';
|
|
18
18
|
|
|
19
19
|
/** Default S3 key prefix for the observability archive (loggingPlugin default). */
|
|
20
20
|
const DEFAULT_PREFIX = '_observability';
|
|
@@ -18,19 +18,19 @@ import { randomUUID } from 'node:crypto';
|
|
|
18
18
|
import { existsSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import { pathToFileURL } from 'node:url';
|
|
21
|
-
import { resolveDbSource, createUrlPipelineRunner, connectingVia, type DbSource } from '../db-source
|
|
22
|
-
import { resolvePipelinePath, PIPELINE_HOMES } from '../pipeline-path
|
|
23
|
-
import { resolveConfig, opsFunction } from '../config
|
|
24
|
-
import { invokeAction } from '../aws
|
|
25
|
-
import { currentGitRef } from '../state-apply
|
|
26
|
-
import { step, success, fail, info, warn } from '../output
|
|
21
|
+
import { resolveDbSource, createUrlPipelineRunner, connectingVia, type DbSource } from '../db-source';
|
|
22
|
+
import { resolvePipelinePath, PIPELINE_HOMES } from '../pipeline-path';
|
|
23
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
24
|
+
import { invokeAction } from '../aws';
|
|
25
|
+
import { currentGitRef } from '../state-apply';
|
|
26
|
+
import { step, success, fail, info, warn } from '../output';
|
|
27
27
|
import {
|
|
28
28
|
loadServerPipeline,
|
|
29
29
|
type Pipeline,
|
|
30
30
|
type PipelineLane,
|
|
31
31
|
type RunPipelineOptions,
|
|
32
32
|
type PipelineRunOutcome,
|
|
33
|
-
} from '../pipeline-loader
|
|
33
|
+
} from '../pipeline-loader';
|
|
34
34
|
|
|
35
35
|
/** --rebuild → rebuild, --curate → curate, neither → all. Both is a contradiction. */
|
|
36
36
|
export function pipelineLaneFromFlags(flags: Record<string, string>): PipelineLane {
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { pathToFileURL } from 'node:url';
|
|
19
|
-
import { detectProjectReality } from '../runbook/detect
|
|
20
|
-
import { generateRunbook, type RunbookResult } from '../runbook/generate
|
|
21
|
-
import { renderDataModelSection } from '../runbook/model-section
|
|
22
|
-
import { parseRunbook, sha8 } from '../runbook/markers
|
|
23
|
-
import { fail, info, step, success, warn } from '../output
|
|
19
|
+
import { detectProjectReality } from '../runbook/detect';
|
|
20
|
+
import { generateRunbook, type RunbookResult } from '../runbook/generate';
|
|
21
|
+
import { renderDataModelSection } from '../runbook/model-section';
|
|
22
|
+
import { parseRunbook, sha8 } from '../runbook/markers';
|
|
23
|
+
import { fail, info, step, success, warn } from '../output';
|
|
24
24
|
|
|
25
25
|
const OUT_REL = path.join('docs', 'RUNBOOK.md');
|
|
26
26
|
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs/promises';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
-
import { step, success, fail, info, warn } from '../output
|
|
19
|
-
import { loadSstSecrets, putSstSecrets } from '../utils/secrets
|
|
20
|
-
import { parseDotenv, serializeDotenv } from '../utils/dotenv
|
|
21
|
-
import { isMissingOptionalDependencyError } from '../aws
|
|
18
|
+
import { step, success, fail, info, warn } from '../output';
|
|
19
|
+
import { loadSstSecrets, putSstSecrets } from '../utils/secrets';
|
|
20
|
+
import { parseDotenv, serializeDotenv } from '../utils/dotenv';
|
|
21
|
+
import { isMissingOptionalDependencyError } from '../aws';
|
|
22
22
|
|
|
23
23
|
interface SecretsContext {
|
|
24
24
|
appName: string;
|
|
@@ -34,7 +34,7 @@ async function resolveContext(flags: Record<string, string>): Promise<SecretsCon
|
|
|
34
34
|
process.exit(1);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const { parseAppName } = await import('../discover
|
|
37
|
+
const { parseAppName } = await import('../discover');
|
|
38
38
|
const appName = await parseAppName();
|
|
39
39
|
const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
|
|
40
40
|
|
|
@@ -25,10 +25,10 @@ import {
|
|
|
25
25
|
type CatalogColumn,
|
|
26
26
|
type CatalogGrant,
|
|
27
27
|
type ProbeResult,
|
|
28
|
-
} from '../security-probe
|
|
29
|
-
import { resolveConfig, opsFunction } from '../config
|
|
30
|
-
import { invokeAction } from '../aws
|
|
31
|
-
import { step, info, fail, success, warn } from '../output
|
|
28
|
+
} from '../security-probe';
|
|
29
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
30
|
+
import { invokeAction } from '../aws';
|
|
31
|
+
import { step, info, fail, success, warn } from '../output';
|
|
32
32
|
|
|
33
33
|
const SYS_SCHEMAS = "('pg_catalog','information_schema','pg_toast')";
|
|
34
34
|
|
|
@@ -22,17 +22,17 @@ import {
|
|
|
22
22
|
type AuditReport,
|
|
23
23
|
type FunctionDescriptor,
|
|
24
24
|
type ViewDescriptor,
|
|
25
|
-
} from '../security-audit
|
|
25
|
+
} from '../security-audit';
|
|
26
26
|
import {
|
|
27
27
|
FUNCTIONS_SQL,
|
|
28
28
|
RELATIONS_SQL,
|
|
29
29
|
catalogFunctionToDescriptor,
|
|
30
30
|
catalogRelationToDescriptor,
|
|
31
|
-
} from '../security-catalog
|
|
32
|
-
import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract
|
|
33
|
-
import { resolveConfig, opsFunction } from '../config
|
|
34
|
-
import { invokeAction } from '../aws
|
|
35
|
-
import { step, success, fail, info, warn } from '../output
|
|
31
|
+
} from '../security-catalog';
|
|
32
|
+
import { SCHEMA_ACL_SQL, parseSchemaAcl } from '../authz-contract';
|
|
33
|
+
import { resolveConfig, opsFunction } from '../config';
|
|
34
|
+
import { invokeAction } from '../aws';
|
|
35
|
+
import { step, success, fail, info, warn } from '../output';
|
|
36
36
|
|
|
37
37
|
const MIGRATION_DIRS = ['drizzle', 'db/migrations', 'migrations', 'db/drizzle'];
|
|
38
38
|
const WAIVER_FILES = ['security-audit.json', '.security-audit.json'];
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* CDN and Lambda metrics still work with nothing but IAM credentials.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { resolveConfig, opsFunction, type CliConfig } from '../config
|
|
15
|
-
import { invokeAction, importOptionalAws } from '../aws
|
|
16
|
-
import { fail, info, step } from '../output
|
|
14
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config';
|
|
15
|
+
import { invokeAction, importOptionalAws } from '../aws';
|
|
16
|
+
import { fail, info, step } from '../output';
|
|
17
17
|
|
|
18
18
|
export interface MetricSpec {
|
|
19
19
|
namespace: string;
|