@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,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:generate` — the brownfield migration generator.
|
|
3
|
+
*
|
|
4
|
+
* db:generate --stage <name> [--name <label>] introspect the live DB → diff the Models
|
|
5
|
+
* → write the next drizzle migration file
|
|
6
|
+
*
|
|
7
|
+
* The one generator — both layers in one ordered migration. It compiles the app's Models,
|
|
8
|
+
* introspects the deployed database through the ops Lambda `db:query` action (the same
|
|
9
|
+
* read-only path security:audit and db:authz use) for BOTH the structure (columns +
|
|
10
|
+
* constraints) and the authorization (rls + grants + policies), diffs them, and writes a
|
|
11
|
+
* reviewable `NNNN_<name>.sql` migration plus its journal entry: data DDL first, then the
|
|
12
|
+
* authz reconcile the Models' `abilities` declare. It never touches the database — you
|
|
13
|
+
* review the file, then `db:migrate` applies it. (`db:authz` remains the read-only
|
|
14
|
+
* pull/diff/audit surface for the authz layer alone.)
|
|
15
|
+
*
|
|
16
|
+
* The pure core lives in migration-generate.ts; this shell is the IO: load models, run the
|
|
17
|
+
* introspection query, write the file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import type { ModelDescriptor, Module } from '@everystack/model';
|
|
24
|
+
import { introspectSchema } from '../schema-introspect.js';
|
|
25
|
+
import { compileDrizzleSource } from '../schema-source.js';
|
|
26
|
+
import { compileModuleMigration } from '../migration-compile.js';
|
|
27
|
+
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
28
|
+
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
|
+
import { FUNCTIONS_SQL, catalogFunctionToDescriptor } from '../security-catalog.js';
|
|
30
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
31
|
+
import { invokeAction } from '../aws.js';
|
|
32
|
+
import { step, success, fail, info, warn } from '../output.js';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MODELS = 'models/index.ts';
|
|
35
|
+
const DEFAULT_MIGRATIONS = 'drizzle';
|
|
36
|
+
const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
37
|
+
|
|
38
|
+
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
39
|
+
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
40
|
+
return async (sql: string) => {
|
|
41
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
42
|
+
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
43
|
+
return result?.rows ?? [];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map a FUNCTIONS_SQL row to the minimal shape the authz contract records. */
|
|
48
|
+
const mapFunctionRow = (row: any) => {
|
|
49
|
+
const d = catalogFunctionToDescriptor(row);
|
|
50
|
+
return { schema: d.schema, name: d.name, securityDefiner: d.securityDefiner, hasSearchPath: d.hasSearchPath, owner: d.owner, ownerBypassesRls: d.ownerBypassesRls };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Import the app's Model barrel and return its `models` array (runs under tsx, so TS imports work). */
|
|
54
|
+
async function loadModels(modelsPath: string): Promise<ModelDescriptor[]> {
|
|
55
|
+
const abs = path.resolve(modelsPath);
|
|
56
|
+
let mod: any;
|
|
57
|
+
try {
|
|
58
|
+
mod = await import(pathToFileURL(abs).href);
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
throw new Error(`Could not load models from ${modelsPath}: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
const models = mod.models ?? mod.default;
|
|
63
|
+
if (!Array.isArray(models)) throw new Error(`${modelsPath} must export a \`models\` array (got ${typeof models}).`);
|
|
64
|
+
return models;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Import the app's barrel and return its `modules` array — the greenfield (`--init`) input. A
|
|
69
|
+
* Module carries the package's extensions + functions on top of its models, so it can emit the
|
|
70
|
+
* COMPLETE migration. Falls back to wrapping a bare `models` array in one module (an app on the
|
|
71
|
+
* older models-only barrel still gets a schema+authz init, just no package functions).
|
|
72
|
+
*/
|
|
73
|
+
async function loadModules(modelsPath: string): Promise<Module[]> {
|
|
74
|
+
const abs = path.resolve(modelsPath);
|
|
75
|
+
let mod: any;
|
|
76
|
+
try {
|
|
77
|
+
mod = await import(pathToFileURL(abs).href);
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
throw new Error(`Could not load modules from ${modelsPath}: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(mod.modules)) return mod.modules;
|
|
82
|
+
const models = mod.models ?? mod.default;
|
|
83
|
+
if (Array.isArray(models)) return [{ models, extensions: [], functions: [] }];
|
|
84
|
+
throw new Error(`${modelsPath} must export a \`modules\` (or \`models\`) array.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `db:generate --init` — the greenfield generator. Compiles the app's Modules into ONE
|
|
89
|
+
* complete migration (extensions + schema + authz + package functions/triggers) with NO live
|
|
90
|
+
* database — the model IS the source. Writes it as the next migration in a (typically empty)
|
|
91
|
+
* `drizzle/`. Roles are excluded by design (they are `db:provision`). Use it to (re)initialize
|
|
92
|
+
* a clean migration history: clear `drizzle/`, then `db:generate --init`.
|
|
93
|
+
*/
|
|
94
|
+
async function dbGenerateInit(modelsPath: string, migrationsDir: string, schemaOut: string, name: string): Promise<void> {
|
|
95
|
+
step(`Loading modules from ${modelsPath}...`);
|
|
96
|
+
const modules = await loadModules(modelsPath);
|
|
97
|
+
const models = modules.flatMap((m) => m.models);
|
|
98
|
+
info(`${modules.length} module(s), ${models.length} model(s).`);
|
|
99
|
+
|
|
100
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
101
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
102
|
+
|
|
103
|
+
const statements = compileModuleMigration(modules);
|
|
104
|
+
|
|
105
|
+
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
106
|
+
let journal: Journal;
|
|
107
|
+
try {
|
|
108
|
+
journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
|
|
109
|
+
} catch {
|
|
110
|
+
journal = { version: '7', dialect: 'postgresql', entries: [] };
|
|
111
|
+
}
|
|
112
|
+
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
113
|
+
const filePath = path.join(migrationsDir, filename);
|
|
114
|
+
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|
|
115
|
+
await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
|
|
116
|
+
await fs.writeFile(journalPath, JSON.stringify(nextJournal, null, 2) + '\n', 'utf8');
|
|
117
|
+
|
|
118
|
+
console.log('');
|
|
119
|
+
success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)) — the complete greenfield migration.`);
|
|
120
|
+
warn('Roles are NOT in this migration — run `everystack db:provision` (the role chain) before `db:migrate`.');
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function dbGenerateCommand(flags: Record<string, string>): Promise<void> {
|
|
125
|
+
const modelsPath = flags.models || DEFAULT_MODELS;
|
|
126
|
+
const migrationsDir = path.resolve(flags.dir || DEFAULT_MIGRATIONS);
|
|
127
|
+
const schemaOut = path.resolve(flags['schema-out'] || DEFAULT_SCHEMA_OUT);
|
|
128
|
+
const name = flags.name || 'generated';
|
|
129
|
+
const allowDrops = flags['allow-drops'] === 'true';
|
|
130
|
+
|
|
131
|
+
// --init: greenfield, no live DB — the model emits the whole migration (extensions + schema +
|
|
132
|
+
// authz + package functions). The brownfield path below introspects the deployed DB and diffs.
|
|
133
|
+
if (flags.init === 'true') {
|
|
134
|
+
try {
|
|
135
|
+
await dbGenerateInit(modelsPath, migrationsDir, schemaOut, flags.name || 'init');
|
|
136
|
+
} catch (err: any) {
|
|
137
|
+
fail(err.message);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let models: ModelDescriptor[];
|
|
144
|
+
let current;
|
|
145
|
+
let liveAuthz;
|
|
146
|
+
try {
|
|
147
|
+
step(`Loading models from ${modelsPath}...`);
|
|
148
|
+
models = await loadModels(modelsPath);
|
|
149
|
+
info(`${models.length} model(s).`);
|
|
150
|
+
// The drizzle runtime schema is a derived artifact — refresh it from the models
|
|
151
|
+
// every run (it needs no DB), so a relation-only change with no DDL diff still
|
|
152
|
+
// updates it. The drift-guard test asserts this file matches the models.
|
|
153
|
+
step(`Writing the drizzle schema → ${path.relative(process.cwd(), schemaOut)}...`);
|
|
154
|
+
await fs.writeFile(schemaOut, compileDrizzleSource(models), 'utf8');
|
|
155
|
+
step('Resolving deployed config...');
|
|
156
|
+
const config = await resolveConfig(flags.stage);
|
|
157
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
158
|
+
const runner = lambdaRunner(config.region, opsFunction(config));
|
|
159
|
+
step('Introspecting live database (columns + constraints)...');
|
|
160
|
+
current = await introspectSchema(runner);
|
|
161
|
+
step('Introspecting authorization (rls + grants + policies)...');
|
|
162
|
+
liveAuthz = await introspectContract(runner, mapFunctionRow, FUNCTIONS_SQL);
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
fail(err.message);
|
|
165
|
+
info('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One ordered migration carries both layers: data DDL first, then the authz reconcile
|
|
170
|
+
// (RLS/policies/grants) the Models' abilities declare, diffed against the live contract.
|
|
171
|
+
const statements = generateMigrationSql(models, current, { allowDrops, liveAuthz });
|
|
172
|
+
const unmodeled = unmodeledTables(models, current);
|
|
173
|
+
if (unmodeled.length) {
|
|
174
|
+
info(`${unmodeled.length} table(s) in the database are not declared by any model — left untouched (db:generate manages only declared tables).`);
|
|
175
|
+
}
|
|
176
|
+
console.log('');
|
|
177
|
+
if (statements.length === 0) {
|
|
178
|
+
success('db:generate — the live database already matches the models. No migration written.');
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const journalPath = path.join(migrationsDir, 'meta', '_journal.json');
|
|
183
|
+
let journal: Journal;
|
|
184
|
+
try {
|
|
185
|
+
journal = JSON.parse(await fs.readFile(journalPath, 'utf8'));
|
|
186
|
+
} catch {
|
|
187
|
+
journal = { version: '7', dialect: 'postgresql', entries: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { filename, journal: nextJournal } = planMigrationFile(journal, name, Date.now());
|
|
191
|
+
const filePath = path.join(migrationsDir, filename);
|
|
192
|
+
try {
|
|
193
|
+
await fs.mkdir(path.join(migrationsDir, 'meta'), { recursive: true });
|
|
194
|
+
await fs.writeFile(filePath, formatMigrationFile(statements), 'utf8');
|
|
195
|
+
await fs.writeFile(journalPath, JSON.stringify(nextJournal, null, 2) + '\n', 'utf8');
|
|
196
|
+
} catch (err: any) {
|
|
197
|
+
fail(`Could not write the migration: ${err.message}`);
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
success(`Wrote ${path.relative(process.cwd(), filePath)} (${statements.length} statement(s)).`);
|
|
202
|
+
const warnings = statements.filter((s) => s.includes('-- WARNING')).length;
|
|
203
|
+
const notices = statements.filter((s) => s.startsWith('-- NOTICE')).length;
|
|
204
|
+
const held = statements.filter((s) => s.startsWith(HELD_DROP_PREFIX)).length;
|
|
205
|
+
if (warnings) warn(`${warnings} statement(s) carry a -- WARNING (possible data loss) — read them before db:migrate.`);
|
|
206
|
+
if (notices) info(`${notices} -- NOTICE comment(s) (renames / manual follow-ups) — review and edit if needed.`);
|
|
207
|
+
if (held) warn(`${held} destructive change(s) (DROP) held back as comments — re-run with --allow-drops to emit them, or apply the shown statements by hand.`);
|
|
208
|
+
console.log('');
|
|
209
|
+
warn('db:generate did NOT touch the database. Review the migration, then `everystack db:migrate --stage <name>` to apply it.');
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:pull` — generate `field()` Models from a live database (the brownfield on-ramp).
|
|
3
|
+
*
|
|
4
|
+
* db:pull --stage <name> [--schema public] [--out models/index.ts]
|
|
5
|
+
*
|
|
6
|
+
* The reverse of db:generate: that writes migrations FROM Models; this writes Models FROM the
|
|
7
|
+
* database. It introspects the deployed schema through the read-only ops Lambda `db:query`
|
|
8
|
+
* action and renders the TypeScript a Model is written in — a starting point you review and
|
|
9
|
+
* refine (it flags, as inline comments, anything the field builder can't yet express).
|
|
10
|
+
*
|
|
11
|
+
* The rendered source is the artifact: it goes to stdout (so `db:pull > models.ts` works) or
|
|
12
|
+
* to `--out`. Every progress/diagnostic line goes to stderr, so the stdout pipe stays pure
|
|
13
|
+
* source. The renderer (model-render.ts) is pure; this shell is the IO.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { introspectSchema } from '../schema-introspect.js';
|
|
19
|
+
import { renderModelSource } from '../model-render.js';
|
|
20
|
+
import type { QueryRunner } from '../authz-contract.js';
|
|
21
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
22
|
+
import { invokeAction } from '../aws.js';
|
|
23
|
+
import { fail } from '../output.js';
|
|
24
|
+
|
|
25
|
+
// Progress → stderr, so stdout carries only the rendered source (this command is codegen,
|
|
26
|
+
// not a report — the shared output helpers write to stdout, which would corrupt a redirect).
|
|
27
|
+
const note = (m: string) => console.error(` > ${m}`);
|
|
28
|
+
const ok = (m: string) => console.error(` ✓ ${m}`);
|
|
29
|
+
const detail = (m: string) => console.error(` ${m}`);
|
|
30
|
+
const caution = (m: string) => console.error(` ! ${m}`);
|
|
31
|
+
|
|
32
|
+
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
33
|
+
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
34
|
+
return async (sql: string) => {
|
|
35
|
+
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
36
|
+
if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
|
|
37
|
+
return result?.rows ?? [];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function dbPullCommand(flags: Record<string, string>): Promise<void> {
|
|
42
|
+
const schema = flags.schema || 'public';
|
|
43
|
+
|
|
44
|
+
let current;
|
|
45
|
+
try {
|
|
46
|
+
note('Resolving deployed config...');
|
|
47
|
+
const config = await resolveConfig(flags.stage);
|
|
48
|
+
detail(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
49
|
+
note(`Introspecting live database (schema: ${schema})...`);
|
|
50
|
+
current = await introspectSchema(lambdaRunner(config.region, opsFunction(config)));
|
|
51
|
+
} catch (err: any) {
|
|
52
|
+
fail(err.message);
|
|
53
|
+
detail('Ensure your IAM user/role has lambda:InvokeFunction and the stage is deployed.');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pulled = current.tables.filter((t) => t.table.startsWith(`${schema}.`));
|
|
58
|
+
if (pulled.length === 0) {
|
|
59
|
+
fail(`No tables found in schema "${schema}".`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const source = renderModelSource(current, { schema });
|
|
64
|
+
|
|
65
|
+
if (flags.out) {
|
|
66
|
+
const outPath = path.resolve(flags.out);
|
|
67
|
+
try {
|
|
68
|
+
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
69
|
+
await fs.writeFile(outPath, source, 'utf8');
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
fail(`Could not write ${flags.out}: ${err.message}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
ok(`Wrote ${path.relative(process.cwd(), outPath)} — ${pulled.length} model(s).`);
|
|
75
|
+
} else {
|
|
76
|
+
process.stdout.write(source);
|
|
77
|
+
ok(`Rendered ${pulled.length} model(s) from schema "${schema}" (stdout — redirect or pass --out to save).`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const flagged = (source.match(/\/\/ (FIXME|TODO|composite|CHECK|FK →)/g) ?? []).length;
|
|
81
|
+
if (flagged) caution(`${flagged} inline comment(s) flag things to review (unmapped types, checks, cross-schema FKs).`);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db:snapshot / db:snapshots — RDS *physical* snapshots, the thin `aws rds` control-plane wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Unlike db:backup (logical pg_dump, runs in the ops Lambda), these are plain RDS management calls
|
|
5
|
+
* made directly from the CLI with the operator's IAM — no Lambda, no VPC, no pg_dump layer. They
|
|
6
|
+
* only apply when the DB is an RDS instance; for any other Postgres, use db:backup. See
|
|
7
|
+
* docs/plans/db-backup-restore.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { resolveConfig, type CliConfig } from '../config.js';
|
|
11
|
+
import { createRdsSnapshot, describeRdsSnapshots, type RdsSnapshotSummary } from '../aws.js';
|
|
12
|
+
import { utcStamp } from '../backup.js';
|
|
13
|
+
import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
|
|
14
|
+
import { step, success, fail, info } from '../output.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the RDS DB instance identifier: explicit `--instance` wins, else the deployed
|
|
18
|
+
* `databaseInstanceId` stack output. A missing or `placeholder` value means the DB isn't RDS (or
|
|
19
|
+
* the stage is in dev mode) — fail with a pointer to db:backup rather than calling AWS with junk.
|
|
20
|
+
*/
|
|
21
|
+
function resolveInstanceId(config: CliConfig, flags: Record<string, string>): string {
|
|
22
|
+
const id = flags.instance ?? config.databaseInstanceId;
|
|
23
|
+
if (!id || id === 'placeholder') {
|
|
24
|
+
fail(
|
|
25
|
+
'No RDS instance id available. db:snapshot needs an RDS instance.\n' +
|
|
26
|
+
' - If your DB is RDS: redeploy so the `databaseInstanceId` output is present, or pass --instance <id>.\n' +
|
|
27
|
+
' - If your DB is not RDS (Aurora, a container, self-hosted): use `everystack db:backup` for a portable logical backup.',
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Only an authorization failure warrants the IAM hint; other RDS errors (state, quota) don't. */
|
|
35
|
+
function isAccessDenied(err: any): boolean {
|
|
36
|
+
const name = String(err?.name ?? '');
|
|
37
|
+
return /AccessDenied|Unauthorized|NotAuthorized/i.test(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtSize(gib?: number): string {
|
|
41
|
+
return gib == null ? '-' : `${gib} GiB`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fmtDate(d?: Date): string {
|
|
45
|
+
return d ? d.toISOString().replace('.000Z', 'Z') : '(creating)';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function dbSnapshotCommand(flags: Record<string, string>): Promise<void> {
|
|
49
|
+
step('Resolving deployed config...');
|
|
50
|
+
let config: CliConfig;
|
|
51
|
+
try {
|
|
52
|
+
config = await resolveConfig(flags.stage);
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
fail(err.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const instanceId = resolveInstanceId(config, flags);
|
|
59
|
+
const snapshotId = rdsSnapshotIdentifier(flags.stage ?? 'manual', utcStamp(new Date()));
|
|
60
|
+
|
|
61
|
+
info(`Region: ${config.region}, Instance: ${instanceId}`);
|
|
62
|
+
step(`Creating RDS snapshot ${snapshotId}...`);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const snap = await createRdsSnapshot(config.region, instanceId, snapshotId);
|
|
66
|
+
success(`Snapshot ${snap.identifier} (${snap.status}) — physical RDS image of ${instanceId}.`);
|
|
67
|
+
info('It becomes restorable once status is "available". Track it with `everystack db:snapshots`.');
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
fail(`Snapshot failed: ${err.message}`);
|
|
70
|
+
if (isAccessDenied(err)) {
|
|
71
|
+
info('Ensure your IAM identity has rds:CreateDBSnapshot on this instance.');
|
|
72
|
+
} else if (/not currently in the available state|InvalidDBInstanceState/i.test(String(err?.message ?? err?.name))) {
|
|
73
|
+
info('The instance is busy (another snapshot may be in progress). Wait for it to finish, then retry.');
|
|
74
|
+
}
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function dbSnapshotsCommand(flags: Record<string, string>): Promise<void> {
|
|
80
|
+
step('Resolving deployed config...');
|
|
81
|
+
let config: CliConfig;
|
|
82
|
+
try {
|
|
83
|
+
config = await resolveConfig(flags.stage);
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
fail(err.message);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const instanceId = resolveInstanceId(config, flags);
|
|
90
|
+
step(`Listing manual RDS snapshots for ${instanceId}...`);
|
|
91
|
+
|
|
92
|
+
let snaps: RdsSnapshotSummary[];
|
|
93
|
+
try {
|
|
94
|
+
snaps = await describeRdsSnapshots(config.region, instanceId);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
fail(`List failed: ${err.message}`);
|
|
97
|
+
if (isAccessDenied(err)) {
|
|
98
|
+
info('Ensure your IAM identity has rds:DescribeDBSnapshots.');
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (snaps.length === 0) {
|
|
104
|
+
info('No manual snapshots. Create one with `everystack db:snapshot`.');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const s of snaps) {
|
|
109
|
+
const enc = s.encrypted ? ' [encrypted]' : '';
|
|
110
|
+
info(`${s.identifier} ${s.status.padEnd(10)} ${fmtSize(s.sizeGiB).padEnd(8)} pg${s.engineVersion ?? '?'} ${fmtDate(s.created)}${enc}`);
|
|
111
|
+
}
|
|
112
|
+
success(`${snaps.length} snapshot${snaps.length === 1 ? '' : 's'}.`);
|
|
113
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack deploy` — deploy the stack's infrastructure.
|
|
3
|
+
*
|
|
4
|
+
* A thin wrapper over `sst deploy` so a consumer never has to type or know about SST: the
|
|
5
|
+
* framework owns the infra tool, the consumer just runs `everystack deploy --stage <name>`.
|
|
6
|
+
* Infra only — migrations (`db:migrate`) and OTA (`update`) stay explicit, the same split
|
|
7
|
+
* `sst deploy` itself has.
|
|
8
|
+
*
|
|
9
|
+
* The arg build is pure (unit-tested); the command streams sst's output through and exits
|
|
10
|
+
* with its code so CI sees a failed deploy.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { step, success, fail, info } from '../output.js';
|
|
15
|
+
|
|
16
|
+
/** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
|
|
17
|
+
export function buildDeployArgs(flags: Record<string, string>): string[] {
|
|
18
|
+
const stage = flags.stage || 'dev';
|
|
19
|
+
return ['sst', 'deploy', '--stage', stage];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function deployCommand(flags: Record<string, string>): Promise<void> {
|
|
23
|
+
const stage = flags.stage || 'dev';
|
|
24
|
+
const args = buildDeployArgs(flags);
|
|
25
|
+
|
|
26
|
+
step(`Deploying stage "${stage}"...`);
|
|
27
|
+
await new Promise<void>((resolve) => {
|
|
28
|
+
// `npx` resolves the consumer's local sst (the same way the export step runs expo).
|
|
29
|
+
const child = spawn('npx', args, { stdio: 'inherit', env: { ...process.env, FORCE_COLOR: '1' } });
|
|
30
|
+
child.on('close', (code) => {
|
|
31
|
+
if (code === 0) {
|
|
32
|
+
resolve();
|
|
33
|
+
} else {
|
|
34
|
+
fail(`Deploy failed (exit ${code}).`);
|
|
35
|
+
process.exit(code ?? 1);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
child.on('error', (err) => {
|
|
39
|
+
fail(`Could not run sst: ${err.message}`);
|
|
40
|
+
info('Install it as a dev dependency: `pnpm add -D sst`.');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
success(`Deployed stage "${stage}".`);
|
|
45
|
+
info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
|
|
46
|
+
}
|
package/src/cli/config.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface CliConfig {
|
|
|
24
24
|
mediaBucket?: string;
|
|
25
25
|
kvsArn?: string;
|
|
26
26
|
distributionId?: string;
|
|
27
|
+
/** RDS DB instance identifier (for db:snapshot). Absent when the DB isn't RDS or in dev mode. */
|
|
28
|
+
databaseInstanceId?: string;
|
|
29
|
+
/** Private S3 bucket holding logical backups (for db:backup:download presign). */
|
|
30
|
+
backupsBucket?: string;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Operator invoke target — dedicated ops function when deployed, else the API function. */
|
|
@@ -44,6 +48,8 @@ interface SstOutputs {
|
|
|
44
48
|
mediaBucket?: string;
|
|
45
49
|
kvsArn?: string;
|
|
46
50
|
distributionId?: string;
|
|
51
|
+
databaseInstanceId?: string;
|
|
52
|
+
backupsBucket?: string;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
@@ -115,5 +121,7 @@ export async function resolveConfig(stage?: string): Promise<CliConfig> {
|
|
|
115
121
|
mediaBucket: outputs.mediaBucket,
|
|
116
122
|
kvsArn: outputs.kvsArn,
|
|
117
123
|
distributionId: outputs.distributionId,
|
|
124
|
+
databaseInstanceId: outputs.databaseInstanceId,
|
|
125
|
+
backupsBucket: outputs.backupsBucket,
|
|
118
126
|
};
|
|
119
127
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { updateCommand } from './commands/update.js';
|
|
6
|
+
import { deployCommand } from './commands/deploy.js';
|
|
6
7
|
import { certsCommand } from './commands/certs.js';
|
|
7
8
|
import { channelsCommand } from './commands/channels.js';
|
|
8
9
|
import { branchesCommand } from './commands/branches.js';
|
|
9
10
|
import { dbMigrateCommand, dbSeedCommand, dbResetCommand, dbPsqlCommand, dbDoctorCommand, dbProvisionCommand } from './commands/db.js';
|
|
11
|
+
import { dbAuthzPullCommand, dbAuthzDiffCommand, dbAuthzTestCommand, dbAuthzOwnerCommand, dbAuthzReportCommand } from './commands/db-authz.js';
|
|
12
|
+
import { dbGenerateCommand } from './commands/db-generate.js';
|
|
13
|
+
import { dbPullCommand } from './commands/db-pull.js';
|
|
14
|
+
import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
|
|
15
|
+
import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
|
|
10
16
|
import { consoleCommand } from './commands/console.js';
|
|
11
17
|
import { cachePurgeCommand } from './commands/cache.js';
|
|
12
18
|
import { statusCommand } from './commands/status.js';
|
|
@@ -91,6 +97,9 @@ async function main() {
|
|
|
91
97
|
case 'update':
|
|
92
98
|
await updateCommand(flags);
|
|
93
99
|
break;
|
|
100
|
+
case 'deploy':
|
|
101
|
+
await deployCommand(flags);
|
|
102
|
+
break;
|
|
94
103
|
case 'certs:generate':
|
|
95
104
|
await certsCommand('generate', flags);
|
|
96
105
|
break;
|
|
@@ -121,6 +130,50 @@ async function main() {
|
|
|
121
130
|
case 'db:provision':
|
|
122
131
|
await dbProvisionCommand(flags);
|
|
123
132
|
break;
|
|
133
|
+
case 'db:snapshot':
|
|
134
|
+
await dbSnapshotCommand(flags);
|
|
135
|
+
break;
|
|
136
|
+
case 'db:snapshots':
|
|
137
|
+
await dbSnapshotsCommand(flags);
|
|
138
|
+
break;
|
|
139
|
+
case 'db:backup:probe':
|
|
140
|
+
await dbBackupProbeCommand(flags);
|
|
141
|
+
break;
|
|
142
|
+
case 'db:backup':
|
|
143
|
+
await dbBackupCommand(flags);
|
|
144
|
+
break;
|
|
145
|
+
case 'db:backups':
|
|
146
|
+
await dbBackupsCommand(flags);
|
|
147
|
+
break;
|
|
148
|
+
case 'db:restore':
|
|
149
|
+
await dbRestoreCommand(flags);
|
|
150
|
+
break;
|
|
151
|
+
case 'db:backup:download': {
|
|
152
|
+
const backupId = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
153
|
+
await dbBackupDownloadCommand(flags, backupId);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case 'db:generate':
|
|
157
|
+
await dbGenerateCommand(flags);
|
|
158
|
+
break;
|
|
159
|
+
case 'db:pull':
|
|
160
|
+
await dbPullCommand(flags);
|
|
161
|
+
break;
|
|
162
|
+
case 'db:authz:pull':
|
|
163
|
+
await dbAuthzPullCommand(flags);
|
|
164
|
+
break;
|
|
165
|
+
case 'db:authz:diff':
|
|
166
|
+
await dbAuthzDiffCommand(flags);
|
|
167
|
+
break;
|
|
168
|
+
case 'db:authz:test':
|
|
169
|
+
await dbAuthzTestCommand(flags);
|
|
170
|
+
break;
|
|
171
|
+
case 'db:authz:owner':
|
|
172
|
+
await dbAuthzOwnerCommand(flags);
|
|
173
|
+
break;
|
|
174
|
+
case 'db:authz:report':
|
|
175
|
+
await dbAuthzReportCommand(flags);
|
|
176
|
+
break;
|
|
124
177
|
case 'console':
|
|
125
178
|
await consoleCommand(flags);
|
|
126
179
|
break;
|
|
@@ -181,6 +234,7 @@ function printHelp() {
|
|
|
181
234
|
everystack - CLI for Expo apps on everystack
|
|
182
235
|
|
|
183
236
|
Usage:
|
|
237
|
+
everystack deploy [--stage <name>] Deploy infrastructure (wraps sst deploy — no need to know SST)
|
|
184
238
|
everystack update --branch <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
185
239
|
everystack update --channel <name> --message <msg> [--platform ios|android|web|all] [--stage <name>] [--skip-export]
|
|
186
240
|
everystack db:migrate [--stage <name>] Run database migrations on deployed Lambda
|
|
@@ -190,6 +244,20 @@ Usage:
|
|
|
190
244
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
191
245
|
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
192
246
|
everystack db:provision [--stage <name>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB)
|
|
247
|
+
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
248
|
+
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
249
|
+
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|
|
250
|
+
everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id)
|
|
251
|
+
everystack db:backups [--stage <name>] List logical backups (id, size, created)
|
|
252
|
+
everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
|
|
253
|
+
everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
|
|
254
|
+
everystack db:generate [--stage <name>] [--name <label>] [--models models/index.ts] [--allow-drops] Diff models vs the live DB → write the next migration (never touches the DB; DROPs held back unless --allow-drops)
|
|
255
|
+
everystack db:pull [--stage <name>] [--schema public] [--out <file>] Introspect the live DB → render field() Models (the brownfield on-ramp; stdout unless --out)
|
|
256
|
+
everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
|
|
257
|
+
everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
|
|
258
|
+
everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
|
|
259
|
+
everystack db:authz:owner [--stage <name>] [--models models/index.ts] Red-team owner isolation: two JWT identities per owner-scoped table — catches IDOR (non-zero exit on a leak)
|
|
260
|
+
everystack db:authz:report [--dir authz] Render the committed contract as a human-readable authorization review (no DB)
|
|
193
261
|
everystack console --stage <name> [--sandbox] Interactive REPL on deployed Lambda
|
|
194
262
|
everystack status [--stage <name>] [--hours <n>] Platform health: CDN, Lambda, rollup summary
|
|
195
263
|
everystack security:audit [--dir <path>] [--config <file>] Static scan of migration SQL (pre-merge gate)
|