@everystack/cli 0.2.39 → 0.3.3
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/README.md +28 -5
- package/package.json +12 -2
- package/src/cli/authz-compile.ts +364 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +218 -0
- package/src/cli/authz-reconcile.ts +152 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +300 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +211 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +68 -0
- package/src/cli/migration-compile.ts +133 -0
- package/src/cli/migration-generate.ts +178 -0
- package/src/cli/model-api.ts +36 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +496 -0
- package/src/cli/schema-diff.ts +608 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:authz:pull` / `db:authz:diff` — the Authorization Contract commands.
|
|
3
|
+
*
|
|
4
|
+
* db:authz:pull --stage <name> introspect the live authz → write authz/*.contract.json
|
|
5
|
+
* db:authz:diff --stage <name> validate the live DB against the committed contract
|
|
6
|
+
*
|
|
7
|
+
* The contract is the brownfield slice of the Model (docs/plans/v3-authz-contract.md):
|
|
8
|
+
* authorization that lived only in scattered migrations becomes a reviewable file you
|
|
9
|
+
* can diff a deployed database against. `pull` is the export + on-ramp; `diff` is the
|
|
10
|
+
* audit and the CI gate (non-zero exit on any drift).
|
|
11
|
+
*
|
|
12
|
+
* Both introspect through the ops Lambda `db:query` action (read-only SQL), the same
|
|
13
|
+
* path security:audit uses. The core (introspect/assemble/diff) is backend-agnostic and
|
|
14
|
+
* pure; only the runner here is AWS-bound.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { pathToFileURL } from 'node:url';
|
|
19
|
+
import type { ModelDescriptor } from '@everystack/model';
|
|
20
|
+
import {
|
|
21
|
+
introspectContract,
|
|
22
|
+
diffContracts,
|
|
23
|
+
type QueryRunner,
|
|
24
|
+
type AuthzContract,
|
|
25
|
+
} from '../authz-contract.js';
|
|
26
|
+
import {
|
|
27
|
+
buildProbeSql,
|
|
28
|
+
PROBE_SELECT_SQL,
|
|
29
|
+
probeRoles,
|
|
30
|
+
toProbeResult,
|
|
31
|
+
evaluateRedTeam,
|
|
32
|
+
GRANT_COMPLETENESS_SQL,
|
|
33
|
+
toGrantGap,
|
|
34
|
+
} from '../authz-redteam.js';
|
|
35
|
+
import {
|
|
36
|
+
buildOwnerProbeSql,
|
|
37
|
+
OWNER_PROBE_SELECT_SQL,
|
|
38
|
+
toOwnerProbeResult,
|
|
39
|
+
evaluateOwnerProbe,
|
|
40
|
+
type OwnerProbe,
|
|
41
|
+
} from '../authz-owner-probe.js';
|
|
42
|
+
import { modelOwner } from '../authz-compile.js';
|
|
43
|
+
import { writeContract, loadContract } from '../authz-contract-io.js';
|
|
44
|
+
import { renderContractMarkdown } from '../authz-render.js';
|
|
45
|
+
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
46
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
47
|
+
import { invokeAction } from '../aws.js';
|
|
48
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
49
|
+
|
|
50
|
+
const DEFAULT_DIR = 'authz';
|
|
51
|
+
|
|
52
|
+
/** Map a FUNCTIONS_SQL row to the minimal shape the contract records. */
|
|
53
|
+
const mapFunctionRow = (row: any) => {
|
|
54
|
+
const d = catalogFunctionToDescriptor(row);
|
|
55
|
+
return {
|
|
56
|
+
schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath,
|
|
57
|
+
owner: d.owner, ownerBypassesRls: d.ownerBypassesRls,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
62
|
+
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
63
|
+
return async (sql: string) => {
|
|
64
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
65
|
+
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
66
|
+
return result?.rows ?? [];
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function introspectStage(flags: Record<string, string>): Promise<AuthzContract> {
|
|
71
|
+
step('Resolving deployed config...');
|
|
72
|
+
const config = await resolveConfig(flags.stage);
|
|
73
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
74
|
+
step('Introspecting authorization (rls + grants + policies + secdef)...');
|
|
75
|
+
const runner = lambdaRunner(config.region, opsFunction(config));
|
|
76
|
+
return introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function dbAuthzPullCommand(flags: Record<string, string>): Promise<void> {
|
|
80
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
81
|
+
let contract: AuthzContract;
|
|
82
|
+
try {
|
|
83
|
+
contract = await introspectStage(flags);
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
fail(err.message);
|
|
86
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction permission.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const files = await writeContract(dir, contract);
|
|
91
|
+
const policies = contract.tables.reduce((n, t) => n + t.policies.length, 0);
|
|
92
|
+
console.log('');
|
|
93
|
+
success(`Wrote ${files.length} file(s) to ${dir}`);
|
|
94
|
+
info(`${contract.tables.length} table(s), ${policies} policy(ies), ${contract.functions.length} SECDEF function(s).`);
|
|
95
|
+
console.log('');
|
|
96
|
+
warn('pull OVERWROTE the committed contract with the live state — `git diff` IS your drift report.');
|
|
97
|
+
warn('Do not run `db:authz:diff` to find drift right after a pull: it compares the live DB to what');
|
|
98
|
+
warn('you just wrote, so it is always clean. Review `git diff`, then commit only if the change is intended.');
|
|
99
|
+
info('Note: column exposure (hidden/protected) is handler-layer and not part of the DB pull.');
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function dbAuthzDiffCommand(flags: Record<string, string>): Promise<void> {
|
|
104
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
105
|
+
|
|
106
|
+
let declared: AuthzContract;
|
|
107
|
+
let live: AuthzContract;
|
|
108
|
+
try {
|
|
109
|
+
declared = await loadContract(dir);
|
|
110
|
+
live = await introspectStage(flags);
|
|
111
|
+
} catch (err: any) {
|
|
112
|
+
fail(err.message);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (declared.tables.length === 0 && declared.functions.length === 0) {
|
|
117
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const findings = diffContracts(declared, live);
|
|
122
|
+
console.log('');
|
|
123
|
+
if (findings.length === 0) {
|
|
124
|
+
success(`db:authz:diff — live database matches the declared contract (${declared.tables.length} tables)`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fail(`db:authz:diff — ${findings.length} drift finding(s): the live database does NOT match the declared contract`);
|
|
129
|
+
console.log('');
|
|
130
|
+
for (const f of findings) {
|
|
131
|
+
warn(`[${f.kind}] ${f.subject} — ${f.detail}`);
|
|
132
|
+
}
|
|
133
|
+
console.log('');
|
|
134
|
+
info('Either the live DB drifted (reconcile it) or the change is intended (re-pull + commit the contract delta).');
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function dbAuthzReportCommand(flags: Record<string, string>): Promise<void> {
|
|
139
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
140
|
+
let contract: AuthzContract;
|
|
141
|
+
try {
|
|
142
|
+
contract = await loadContract(dir);
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
fail(err.message);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
if (contract.tables.length === 0) {
|
|
148
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
// Render from the committed contract — no database needed. Print to stdout so it can
|
|
152
|
+
// be piped or redirected; `pull` writes the same content to authz/AUTHORIZATION.md.
|
|
153
|
+
console.log(renderContractMarkdown(contract));
|
|
154
|
+
process.exit(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function dbAuthzTestCommand(flags: Record<string, string>): Promise<void> {
|
|
158
|
+
const dir = path.resolve(flags.dir || flags.out || DEFAULT_DIR);
|
|
159
|
+
|
|
160
|
+
let contract: AuthzContract;
|
|
161
|
+
try {
|
|
162
|
+
contract = await loadContract(dir);
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
fail(err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
if (contract.tables.length === 0) {
|
|
168
|
+
fail(`No committed contract found in ${dir}. Run \`everystack db:authz:pull --stage <name>\` first.`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let rows: any[];
|
|
173
|
+
let gapRows: any[];
|
|
174
|
+
try {
|
|
175
|
+
step('Resolving deployed config...');
|
|
176
|
+
const config = await resolveConfig(flags.stage);
|
|
177
|
+
const fn = opsFunction(config);
|
|
178
|
+
info(`Region: ${config.region}, Function: ${fn}`);
|
|
179
|
+
step('Red-teaming enforcement (SET ROLE + attempt per role/table/command, rolled back)...');
|
|
180
|
+
const setup = buildProbeSql(probeRoles(contract), contract.tables.map((t) => t.table));
|
|
181
|
+
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read: PROBE_SELECT_SQL });
|
|
182
|
+
if (result?.error) throw new Error(result.error);
|
|
183
|
+
rows = result?.rows ?? [];
|
|
184
|
+
step('Checking SECDEF grant-completeness (owner can EXECUTE every helper it calls)...');
|
|
185
|
+
const gc: any = await invokeAction(config.region, fn, 'db:query', { sql: GRANT_COMPLETENESS_SQL });
|
|
186
|
+
if (gc?.error) throw new Error(gc.error);
|
|
187
|
+
gapRows = gc?.rows ?? [];
|
|
188
|
+
} catch (err: any) {
|
|
189
|
+
fail(`db:authz:test failed: ${err.message}`);
|
|
190
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const findings = evaluateRedTeam(contract, rows.map(toProbeResult));
|
|
195
|
+
const holes = findings.filter((f) => f.severity === 'hole');
|
|
196
|
+
const broken = findings.filter((f) => f.severity === 'broken');
|
|
197
|
+
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
198
|
+
const gaps = gapRows.map(toGrantGap);
|
|
199
|
+
|
|
200
|
+
console.log('');
|
|
201
|
+
for (const f of holes) fail(`[HOLE] ${f.detail}`);
|
|
202
|
+
for (const f of broken) warn(`[BROKEN] ${f.detail}`);
|
|
203
|
+
for (const g of gaps) fail(`[GRANT-GAP] ${g.secdef} calls ${g.helper} but its owner cannot EXECUTE it (42501 in prod once ownership is normalized)`);
|
|
204
|
+
if (unprobed.length) {
|
|
205
|
+
const roles = [...new Set(unprobed.map((f) => f.role))].join(', ');
|
|
206
|
+
info(`[unprobed] could not SET ROLE into: ${roles} — grant the operator membership to cover them.`);
|
|
207
|
+
}
|
|
208
|
+
console.log('');
|
|
209
|
+
|
|
210
|
+
if (holes.length === 0 && broken.length === 0 && gaps.length === 0) {
|
|
211
|
+
success(`db:authz:test — enforcement matches the contract (${contract.tables.length} tables probed, default-deny holds, SECDEF grants complete)`);
|
|
212
|
+
process.exit(0);
|
|
213
|
+
}
|
|
214
|
+
fail(`db:authz:test — ${holes.length} enforcement hole(s), ${broken.length} broken grant(s), ${gaps.length} SECDEF grant gap(s). The database does not enforce the contract.`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
|
|
219
|
+
async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
220
|
+
const abs = path.resolve(modelsPath);
|
|
221
|
+
let mod: any;
|
|
222
|
+
try {
|
|
223
|
+
mod = await import(pathToFileURL(abs).href);
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
|
|
226
|
+
}
|
|
227
|
+
const models = mod.models ?? mod.default;
|
|
228
|
+
if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
|
|
229
|
+
return models;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `db:authz:owner` — the owner-aware red-team. Where `db:authz:test` proves grants (can a ROLE
|
|
234
|
+
* do a command), this proves ISOLATION: can one user read or mutate another user's row? It is
|
|
235
|
+
* Model-driven — every `can({ owner })` names the owner column — and discovers two real
|
|
236
|
+
* identities from the data, so it needs no fixtures. For each owner-scoped table it checks that
|
|
237
|
+
* the owner sees their own rows (the anti-vacuous twin), the other identity's rows stay hidden,
|
|
238
|
+
* and an UPDATE can't touch them. Reuses the self-reverting `db:authz:probe` runner. Non-zero
|
|
239
|
+
* exit on any leak or vacuous policy; an `unprobed` table (one identity) is a warning, not a fail.
|
|
240
|
+
*/
|
|
241
|
+
export async function dbAuthzOwnerCommand(flags: Record<string, string>): Promise<void> {
|
|
242
|
+
const modelsPath = flags.models || 'models/index.ts';
|
|
243
|
+
const schema = flags.schema || 'public';
|
|
244
|
+
|
|
245
|
+
let probes: OwnerProbe[];
|
|
246
|
+
let publicReadTables = new Set<string>();
|
|
247
|
+
let rows: any[];
|
|
248
|
+
try {
|
|
249
|
+
step(`Loading models from ${modelsPath}...`);
|
|
250
|
+
const models = await loadModels(modelsPath);
|
|
251
|
+
const ownerModels = models
|
|
252
|
+
.map((m) => ({ m, owner: modelOwner(m, { schema }) }))
|
|
253
|
+
.filter((x): x is { m: (typeof models)[number]; owner: NonNullable<typeof x.owner> } => x.owner !== null);
|
|
254
|
+
// A table with a public read (`can('read')`, no owner/role/via) is intentionally not
|
|
255
|
+
// reader-isolated — flag it so the probe reports its cross-owner reads as expected, not IDOR.
|
|
256
|
+
const isPublicRead = (m: (typeof models)[number]): boolean =>
|
|
257
|
+
m.abilities.some((a) => a.action === 'read' && !a.condition.role && !a.condition.owner && !a.condition.via);
|
|
258
|
+
publicReadTables = new Set(ownerModels.filter(({ m }) => isPublicRead(m)).map(({ owner }) => owner.table));
|
|
259
|
+
probes = ownerModels.map(({ owner }) => ({ table: owner.table, ownerColumn: owner.ownerColumn, claim: owner.claim }));
|
|
260
|
+
info(`${probes.length} owner-scoped model(s).`);
|
|
261
|
+
if (probes.length === 0) {
|
|
262
|
+
success('db:authz:owner — no owner-scoped models (no `can({ owner })`); nothing to probe.');
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
step('Resolving deployed config...');
|
|
266
|
+
const config = await resolveConfig(flags.stage);
|
|
267
|
+
const fn = opsFunction(config);
|
|
268
|
+
info(`Region: ${config.region}, Function: ${fn}`);
|
|
269
|
+
step('Red-teaming owner isolation (two JWT identities per table, rolled back)...');
|
|
270
|
+
const result: any = await invokeAction(config.region, fn, 'db:authz:probe', {
|
|
271
|
+
setup: buildOwnerProbeSql(probes), read: OWNER_PROBE_SELECT_SQL,
|
|
272
|
+
});
|
|
273
|
+
if (result?.error) throw new Error(result.error);
|
|
274
|
+
rows = result?.rows ?? [];
|
|
275
|
+
} catch (err: any) {
|
|
276
|
+
fail(`db:authz:owner failed: ${err.message}`);
|
|
277
|
+
info('Ensure the stage is deployed with @everystack/server >= the version that adds db:authz:probe.');
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const findings = evaluateOwnerProbe(rows.map(toOwnerProbeResult), publicReadTables);
|
|
282
|
+
const leaks = findings.filter((f) => f.severity === 'read-leak' || f.severity === 'write-leak');
|
|
283
|
+
const vacuous = findings.filter((f) => f.severity === 'vacuous');
|
|
284
|
+
const unprobed = findings.filter((f) => f.severity === 'unprobed');
|
|
285
|
+
const publicReads = findings.filter((f) => f.severity === 'public-read');
|
|
286
|
+
|
|
287
|
+
console.log('');
|
|
288
|
+
for (const f of leaks) fail(`[LEAK] ${f.detail}`);
|
|
289
|
+
for (const f of vacuous) fail(`[VACUOUS] ${f.detail}`);
|
|
290
|
+
for (const f of publicReads) info(`[public] ${f.detail}`);
|
|
291
|
+
for (const f of unprobed) info(`[unprobed] ${f.detail}`);
|
|
292
|
+
console.log('');
|
|
293
|
+
|
|
294
|
+
if (leaks.length === 0 && vacuous.length === 0) {
|
|
295
|
+
success(`db:authz:owner — owner isolation holds (${probes.length} table(s) probed${unprobed.length ? `, ${unprobed.length} unprobed` : ''}).`);
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
fail(`db:authz:owner — ${leaks.length} IDOR leak(s), ${vacuous.length} vacuous policy(ies). One user can reach another's rows.`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:backup family — logical pg_dump/pg_restore backups (the postgres-native, DB-agnostic path).
|
|
3
|
+
*
|
|
4
|
+
* These run server-side in the ops Lambda (the DB is private), so the CLI is a thin invokeAction
|
|
5
|
+
* wrapper. db:backup:probe is the deploy-verification for the pg_dump layer (B1); the
|
|
6
|
+
* dump/list/restore/download commands land in B2/B3. See docs/plans/db-backup-restore.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
10
|
+
import { invokeAction, presignGet } from '../aws.js';
|
|
11
|
+
import { parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard } from '../backup.js';
|
|
12
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
13
|
+
|
|
14
|
+
function fmtBytes(n?: number): string {
|
|
15
|
+
if (n == null) return '-';
|
|
16
|
+
if (n < 1024) return `${n} B`;
|
|
17
|
+
const u = ['KB', 'MB', 'GB', 'TB'];
|
|
18
|
+
let v = n / 1024, i = 0;
|
|
19
|
+
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
|
|
20
|
+
return `${v.toFixed(1)} ${u[i]}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* db:backup:probe — confirm the pg_dump layer is attached to the ops function AND that its major
|
|
25
|
+
* version can dump the live server (pg_dump >= server major; there is no bypass flag). This is how
|
|
26
|
+
* we prove B1 after a deploy before trusting db:backup.
|
|
27
|
+
*/
|
|
28
|
+
export async function dbBackupProbeCommand(flags: Record<string, string>): Promise<void> {
|
|
29
|
+
step('Resolving deployed config...');
|
|
30
|
+
let config: CliConfig;
|
|
31
|
+
try {
|
|
32
|
+
config = await resolveConfig(flags.stage);
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
fail(err.message);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
39
|
+
step('Probing pg_dump layer + server version...');
|
|
40
|
+
|
|
41
|
+
let result: any;
|
|
42
|
+
try {
|
|
43
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
|
|
44
|
+
} catch (err: any) {
|
|
45
|
+
fail(`Probe failed: ${err.message}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (result?.error) {
|
|
50
|
+
fail(result.error);
|
|
51
|
+
info('Build + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
info(`pg_dump: ${result.pgDump} (major ${result.pgDumpMajor})`);
|
|
56
|
+
info(`server: ${result.serverVersionNum} (major ${result.serverMajor})`);
|
|
57
|
+
if (result.compatible) {
|
|
58
|
+
success('Compatible — pg_dump can back up and restore this server. db:backup is ready.');
|
|
59
|
+
} else {
|
|
60
|
+
warn('INCOMPATIBLE — pg_dump is older than the server major; it will refuse to dump (no bypass exists).');
|
|
61
|
+
warn('Rebuild the layer with a pg_dump >= the server major.');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
|
|
67
|
+
export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
|
|
68
|
+
step('Resolving deployed config...');
|
|
69
|
+
let config: CliConfig;
|
|
70
|
+
try {
|
|
71
|
+
config = await resolveConfig(flags.stage);
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
fail(err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
78
|
+
step('Running pg_dump → S3 (this may take a while for large databases)...');
|
|
79
|
+
|
|
80
|
+
let result: any;
|
|
81
|
+
try {
|
|
82
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
fail(`Backup failed: ${err.message}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
if (result?.error) {
|
|
88
|
+
fail(`Backup failed: ${result.error}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
success(`Backup ${result.id} (${fmtBytes(result.bytes)}). Restore with: everystack db:restore --from ${result.id} --confirm`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** db:backups — list a stage's logical backups. */
|
|
95
|
+
export async function dbBackupsCommand(flags: Record<string, string>): Promise<void> {
|
|
96
|
+
step('Resolving deployed config...');
|
|
97
|
+
let config: CliConfig;
|
|
98
|
+
try {
|
|
99
|
+
config = await resolveConfig(flags.stage);
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
fail(err.message);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
step('Listing backups...');
|
|
106
|
+
let result: any;
|
|
107
|
+
try {
|
|
108
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:backups', { stage: flags.stage });
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
fail(`List failed: ${err.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
if (result?.error) {
|
|
114
|
+
fail(`List failed: ${result.error}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const backups: any[] = result.backups ?? [];
|
|
118
|
+
if (backups.length === 0) {
|
|
119
|
+
info('No backups. Create one with `everystack db:backup`.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const b of backups) {
|
|
123
|
+
info(`${b.id} ${fmtBytes(b.bytes).padEnd(9)} ${b.lastModified ?? ''}`);
|
|
124
|
+
}
|
|
125
|
+
success(`${backups.length} backup${backups.length === 1 ? '' : 's'}.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* db:restore — pg_restore a backup INTO a stage's existing DB. Destructive (drops + replaces), so
|
|
130
|
+
* it requires --confirm; restoring production-tier data downward or into a production target raises
|
|
131
|
+
* a louder warning (the S0 guards). The id encodes the source stage.
|
|
132
|
+
*/
|
|
133
|
+
export async function dbRestoreCommand(flags: Record<string, string>): Promise<void> {
|
|
134
|
+
const id = flags.from;
|
|
135
|
+
if (!id) {
|
|
136
|
+
fail('db:restore requires --from <backup-id> (see `everystack db:backups`).');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
const ref = parseBackupRef(id);
|
|
140
|
+
if (!ref || !keyForId(id)) {
|
|
141
|
+
fail(`Malformed backup id: ${id}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
const fromStage = ref.stage;
|
|
145
|
+
const toStage = flags.stage ?? fromStage;
|
|
146
|
+
|
|
147
|
+
// Destructive — always require --confirm. Production-tier source/target escalate the warning.
|
|
148
|
+
if (!flags.confirm) {
|
|
149
|
+
warn(`Restore is DESTRUCTIVE — it drops and replaces ${toStage}'s database with backup ${id}.`);
|
|
150
|
+
for (const g of [crossStageGuard(fromStage, toStage), restoreTargetGuard(toStage)]) {
|
|
151
|
+
if (g.requiresConfirm && g.warning) warn(g.warning);
|
|
152
|
+
}
|
|
153
|
+
info('Re-run with --confirm to proceed.');
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
step('Resolving deployed config...');
|
|
158
|
+
let config: CliConfig;
|
|
159
|
+
try {
|
|
160
|
+
config = await resolveConfig(flags.stage);
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
fail(err.message);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
167
|
+
step(`Restoring ${id} into ${toStage} (pg_restore)...`);
|
|
168
|
+
let result: any;
|
|
169
|
+
try {
|
|
170
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:restore', { id, confirm: true, stage: flags.stage });
|
|
171
|
+
} catch (err: any) {
|
|
172
|
+
fail(`Restore failed: ${err.message}`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
if (result?.error) {
|
|
176
|
+
fail(`Restore failed: ${result.error}`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
success(`Restored ${id} into ${toStage}.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** db:backup:download — print (or save) a presigned URL to a backup's dump for local download. */
|
|
183
|
+
export async function dbBackupDownloadCommand(flags: Record<string, string>, id?: string): Promise<void> {
|
|
184
|
+
const backupId = id ?? flags.from;
|
|
185
|
+
if (!backupId) {
|
|
186
|
+
fail('db:backup:download requires a backup id (e.g. `everystack db:backup:download dev/20260628T120000Z-ab12cd`).');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
const key = keyForId(backupId);
|
|
190
|
+
if (!key) {
|
|
191
|
+
fail(`Malformed backup id: ${backupId}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
step('Resolving deployed config...');
|
|
196
|
+
let config: CliConfig;
|
|
197
|
+
try {
|
|
198
|
+
config = await resolveConfig(flags.stage);
|
|
199
|
+
} catch (err: any) {
|
|
200
|
+
fail(err.message);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
if (!config.backupsBucket) {
|
|
204
|
+
fail('No backupsBucket in the deployed config. Add `backupsBucket: backups.name` to sst.config outputs and redeploy.');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let url: string;
|
|
209
|
+
try {
|
|
210
|
+
url = await presignGet(config.region, config.backupsBucket, key, 3600);
|
|
211
|
+
} catch (err: any) {
|
|
212
|
+
fail(`Presign failed: ${err.message}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
info('Presigned URL (valid 1 hour) — the dump is custom-format gzip; restore with pg_restore or `everystack db:restore`:');
|
|
216
|
+
// The URL itself is the command output — print it raw so it can be piped/copied.
|
|
217
|
+
console.log(url);
|
|
218
|
+
}
|