@everystack/cli 0.3.28 → 0.3.31
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 +5 -1
- package/src/cli/apply-execute.ts +185 -0
- package/src/cli/authz-lint.ts +48 -0
- package/src/cli/aws.ts +102 -4
- package/src/cli/commands/db-apply.ts +159 -169
- package/src/cli/commands/db-backfill.ts +2 -2
- package/src/cli/commands/db-backup.ts +34 -0
- package/src/cli/commands/db-check.ts +13 -1
- package/src/cli/commands/db-fingerprint.ts +2 -2
- package/src/cli/commands/db-generate.ts +2 -2
- package/src/cli/commands/db-plan.ts +7 -13
- package/src/cli/commands/db-pull.ts +2 -2
- package/src/cli/commands/db-reconcile.ts +93 -31
- package/src/cli/commands/db-sync.ts +4 -2
- package/src/cli/commands/deploy.ts +4 -0
- package/src/cli/commands/pipeline-run.ts +269 -0
- package/src/cli/db-source.ts +79 -7
- package/src/cli/derived-plan.ts +19 -3
- package/src/cli/discover.ts +16 -0
- package/src/cli/index.ts +22 -2
- package/src/cli/ops-diagnostics.ts +71 -0
- package/src/cli/pipeline-loader.ts +68 -0
- package/src/cli/pipeline-path.ts +25 -0
|
@@ -22,171 +22,181 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import fs from 'node:fs/promises';
|
|
25
|
-
import {
|
|
26
|
-
import { introspectSchema
|
|
25
|
+
import { type QueryRunner } from '../authz-contract.js';
|
|
26
|
+
import { introspectSchema } from '../schema-introspect.js';
|
|
27
|
+
import { introspectContract } from '../authz-contract.js';
|
|
27
28
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { verifyDescent, type LiveState } from '../git-descent.js';
|
|
33
|
-
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
|
|
29
|
+
import { currentGitRef } from '../state-apply.js';
|
|
30
|
+
import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
|
|
31
|
+
import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
|
|
32
|
+
import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity, type AuthorityVerdict } from '../apply-authority.js';
|
|
34
33
|
import { resolveModelsPath } from '../models-path.js';
|
|
35
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
34
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
36
35
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
37
|
-
import { invokeAction } from '../aws.js';
|
|
36
|
+
import { invokeAction, lambdaQueryRunner } from '../aws.js';
|
|
37
|
+
import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
|
|
38
38
|
import { step, success, fail, info, warn } from '../output.js';
|
|
39
39
|
|
|
40
|
+
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
41
|
+
// db:apply action). Re-exported so the existing integration suites — and any
|
|
42
|
+
// other in-repo caller — keep importing it from this command module.
|
|
43
|
+
export { executeApplyPlan } from '../apply-execute.js';
|
|
44
|
+
export type { ApplyPlanOptions, ApplyPlanResult } from '../apply-execute.js';
|
|
45
|
+
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
|
-
// The
|
|
47
|
+
// The CLI shell.
|
|
42
48
|
// ---------------------------------------------------------------------------
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
60
|
-
/**
|
|
61
|
-
* Destructive authority (brick 9, decisions 11+12): called only when the
|
|
62
|
-
* plan is destructive, after the lock and descent checks pass. Return
|
|
63
|
-
* `ok: false` to refuse — the caller is not in the stage's declared
|
|
64
|
-
* approver set. Omitted = no authority gate (non-staged targets keep the
|
|
65
|
-
* ceremony the command shell enforces).
|
|
66
|
-
*/
|
|
67
|
-
verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
68
|
-
/**
|
|
69
|
-
* Runs after every verification passes and BEFORE the edge executes, only
|
|
70
|
-
* for destructive plans — the auto-snapshot seam ("losing data should be
|
|
71
|
-
* hard": confirmed, approved, snapshotted, in that order). A throw here
|
|
72
|
-
* aborts the apply with nothing executed.
|
|
73
|
-
*/
|
|
74
|
-
beforeDestructive?: () => Promise<void>;
|
|
75
|
-
/** Injectable clock for tests. */
|
|
76
|
-
now?: () => number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface ApplyPlanResult {
|
|
80
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
81
|
-
liveFingerprint: string;
|
|
82
|
-
reason?: string;
|
|
83
|
-
logId?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Collapse a DescentVerdict into the ok/reason shape the apply core (and the
|
|
52
|
+
* ops Lambda's db:apply action) enforce. `no-git` / `fresh-target` pass — the
|
|
53
|
+
* rule can't be verified there — matching the direct-URL path's callback.
|
|
54
|
+
*/
|
|
55
|
+
export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } | { ok: false; reason: string } {
|
|
56
|
+
switch (verdict.status) {
|
|
57
|
+
case 'ok':
|
|
58
|
+
case 'fresh-target':
|
|
59
|
+
case 'no-git':
|
|
60
|
+
return { ok: true };
|
|
61
|
+
case 'diverged':
|
|
62
|
+
case 'drift':
|
|
63
|
+
return { ok: false, reason: verdict.reason };
|
|
64
|
+
}
|
|
84
65
|
}
|
|
85
66
|
|
|
86
67
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* bookkeeping write would have failed the apply's ENSURE identically).
|
|
68
|
+
* Render an ApplyPlanResult — shared by the direct (`--database-url`) and the
|
|
69
|
+
* credential-free staged (`--stage`) paths, so the two report identically.
|
|
70
|
+
* Every refusal and the verify-fault exit non-zero.
|
|
91
71
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
72
|
+
function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
73
|
+
switch (result.status) {
|
|
74
|
+
case 'already-applied':
|
|
75
|
+
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
76
|
+
break;
|
|
77
|
+
case 'refused':
|
|
78
|
+
fail(`REFUSED: ${result.reason}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
break;
|
|
81
|
+
case 'descent-refused':
|
|
82
|
+
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
break;
|
|
85
|
+
case 'authority-refused':
|
|
86
|
+
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
break;
|
|
89
|
+
case 'verify-failed':
|
|
90
|
+
fail(`Verify FAILED: ${result.reason}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
break;
|
|
93
|
+
case 'applied':
|
|
94
|
+
success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
|
|
95
|
+
break;
|
|
114
96
|
}
|
|
115
97
|
}
|
|
116
98
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
99
|
+
/**
|
|
100
|
+
* db:apply --stage: the credential-free staged write. The operator never holds
|
|
101
|
+
* a database URL — the WRITE runs in the ops Lambda's db:apply action against
|
|
102
|
+
* its resource-linked connection. The CLI keeps only what needs the checkout
|
|
103
|
+
* or the caller's identity: the fast-forward (descent) rule and the
|
|
104
|
+
* destructive-approver check. The Lambda re-verifies the concurrency lock,
|
|
105
|
+
* applies the DDL in one transaction, stamps schema_log, and verifies plan.to.
|
|
106
|
+
*/
|
|
107
|
+
async function applyPlanViaStage(
|
|
120
108
|
plan: EdgePlan,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
109
|
+
flags: Record<string, string>,
|
|
110
|
+
forceDescent: string | undefined,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
step('Resolving deployed config...');
|
|
113
|
+
const config = await resolveConfig(flags.stage);
|
|
114
|
+
const { region } = config;
|
|
115
|
+
const fn = opsFunction(config);
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
return { status: 'already-applied', liveFingerprint: live };
|
|
129
|
-
}
|
|
130
|
-
const pre = checkPlanPrecondition(plan, live);
|
|
131
|
-
if (!pre.ok) {
|
|
132
|
-
await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
|
|
133
|
-
return { status: 'refused', liveFingerprint: live, reason: pre.reason };
|
|
134
|
-
}
|
|
117
|
+
info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
|
|
135
118
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
119
|
+
try {
|
|
120
|
+
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
121
|
+
// decides it (the Lambda has no checkout). Read the stage's state read-only
|
|
122
|
+
// via the ops Lambda's db:query action, run the rule locally, and hand the
|
|
123
|
+
// verdict to the write action, which enforces + records it in order.
|
|
124
|
+
let descentVerdict: { ok: true } | { ok: false; reason: string };
|
|
125
|
+
if (forceDescent !== undefined) {
|
|
126
|
+
warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
|
|
127
|
+
descentVerdict = { ok: true };
|
|
128
|
+
} else {
|
|
129
|
+
step('Asking the stage its state (read-only via the ops Lambda)...');
|
|
130
|
+
const readRunner = lambdaQueryRunner(region, fn);
|
|
131
|
+
const snapshot = await introspectSchema(readRunner);
|
|
132
|
+
const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
|
|
133
|
+
step("Descent: searching git for the commit that declares the stage's state...");
|
|
134
|
+
const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
|
|
135
|
+
switch (verdict.status) {
|
|
136
|
+
case 'ok':
|
|
137
|
+
info(`descent: ${verdict.commit.slice(0, 12)} declares the stage's state and is an ancestor of HEAD — fast-forward.`);
|
|
138
|
+
break;
|
|
139
|
+
case 'fresh-target':
|
|
140
|
+
info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
|
|
141
|
+
break;
|
|
142
|
+
case 'no-git':
|
|
143
|
+
warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
|
|
144
|
+
break;
|
|
145
|
+
case 'diverged':
|
|
146
|
+
case 'drift':
|
|
147
|
+
break; // the reason travels to the action's refusal + the memoir
|
|
148
|
+
}
|
|
149
|
+
descentVerdict = descentVerdictForApply(verdict);
|
|
141
150
|
}
|
|
142
|
-
}
|
|
143
151
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
152
|
+
// Destructive ceremony — the approver check is against the caller's REAL
|
|
153
|
+
// IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
|
|
154
|
+
// and checks it against the stage's declared approver set (SSM). The
|
|
155
|
+
// snapshot itself runs IN the Lambda, before the DDL. --confirm always.
|
|
156
|
+
let authorityVerdict: AuthorityVerdict | undefined;
|
|
157
|
+
if (plan.destructive > 0) {
|
|
158
|
+
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
159
|
+
if (flags.confirm !== 'true') {
|
|
160
|
+
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
|
|
164
|
+
const { parseAppName } = await import('../discover.js');
|
|
165
|
+
const appName = await parseAppName();
|
|
166
|
+
const approvers = await readApproverParam(region, appName, flags.stage);
|
|
167
|
+
const identity = approvers === null ? null : await resolveCallerIdentity(region);
|
|
168
|
+
authorityVerdict = checkDestructiveAuthority({ approvers, identity });
|
|
169
|
+
if (authorityVerdict.ok && authorityVerdict.reason === 'approved') {
|
|
170
|
+
info(`authority: ${identity} is a declared approver.`);
|
|
171
|
+
} else if (authorityVerdict.ok) {
|
|
172
|
+
warn('authority: no approver set declared for this stage — proceeding on ceremony alone. Declare one: everystack db:approvers --set <arn-or-name,...> --stage ' + flags.stage);
|
|
173
|
+
}
|
|
174
|
+
// A refusal is enforced + recorded server-side; reportApplyResult renders it.
|
|
149
175
|
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (plan.destructive > 0 && options.beforeDestructive) {
|
|
153
|
-
await options.beforeDestructive();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
157
|
-
actor: options.actor,
|
|
158
|
-
gitRef: options.gitRef ?? plan.gitRef,
|
|
159
|
-
planRef: planHash(plan),
|
|
160
|
-
fromFingerprint: live,
|
|
161
|
-
now: options.now,
|
|
162
|
-
});
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
step('Applying in the ops Lambda (credential-free — the operator never holds a database URL)...');
|
|
178
|
+
const result: any = await invokeAction(region, fn, 'db:apply', {
|
|
179
|
+
plan,
|
|
180
|
+
actor: process.env.USER ?? null,
|
|
181
|
+
gitRef: currentGitRef() ?? plan.gitRef,
|
|
182
|
+
descentVerdict,
|
|
183
|
+
...(authorityVerdict ? { authorityVerdict } : {}),
|
|
184
|
+
stage: flags.stage,
|
|
185
|
+
});
|
|
186
|
+
if (result?.error) {
|
|
187
|
+
fail(`Apply failed: ${result.error}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
if (result?.snapshotId) {
|
|
191
|
+
info(`snapshot on record: ${result.snapshotId} — restore with db:restore --from ${result.snapshotId} --confirm.`);
|
|
192
|
+
}
|
|
193
|
+
reportApplyResult(result as ApplyPlanResult, plan);
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
fail(`Apply failed: ${err.message}`);
|
|
196
|
+
process.exit(1);
|
|
178
197
|
}
|
|
179
|
-
return {
|
|
180
|
-
status: 'applied',
|
|
181
|
-
liveFingerprint: landed,
|
|
182
|
-
...(result.logId !== undefined ? { logId: result.logId } : {}),
|
|
183
|
-
};
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
// ---------------------------------------------------------------------------
|
|
187
|
-
// The CLI shell.
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
|
-
|
|
190
200
|
export async function dbApplyCommand(flags: Record<string, string>): Promise<void> {
|
|
191
201
|
const planPath = flags.plan;
|
|
192
202
|
if (!planPath) {
|
|
@@ -213,10 +223,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
213
223
|
fail(err.message);
|
|
214
224
|
process.exit(1);
|
|
215
225
|
}
|
|
216
|
-
if (dbSource.kind !== 'url') {
|
|
217
|
-
fail('db:apply writes — it needs a direct connection (--database-url or DATABASE_URL).');
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
226
|
|
|
221
227
|
// The fast-forward rule's force ceremony (design decision 9): explicit
|
|
222
228
|
// (--force-descent), snapshotted (its value NAMES the snapshot you took),
|
|
@@ -233,6 +239,13 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
// Deployed stage, no direct URL: the write runs in the ops Lambda — the
|
|
243
|
+
// operator never holds a database URL. --database-url stays local-only.
|
|
244
|
+
if (dbSource.kind === 'stage') {
|
|
245
|
+
await applyPlanViaStage(plan, flags, forceDescent);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
236
249
|
// Losing data should be hard (decision 11): a destructive plan applies
|
|
237
250
|
// only confirmed (--confirm, always), snapshotted (automatic with --stage,
|
|
238
251
|
// named over a bare connection), and — when the stage declares an approver
|
|
@@ -279,7 +292,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
279
292
|
}
|
|
280
293
|
}
|
|
281
294
|
|
|
282
|
-
step(dbSource
|
|
295
|
+
step(connectingVia(dbSource));
|
|
283
296
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
284
297
|
|
|
285
298
|
try {
|
|
@@ -315,30 +328,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
315
328
|
} : {}),
|
|
316
329
|
});
|
|
317
330
|
|
|
318
|
-
|
|
319
|
-
case 'already-applied':
|
|
320
|
-
success(`Already at ${plan.toFingerprint.slice(0, 12)} — nothing to do (idempotent re-apply).`);
|
|
321
|
-
break;
|
|
322
|
-
case 'refused':
|
|
323
|
-
fail(`REFUSED: ${result.reason}`);
|
|
324
|
-
process.exit(1);
|
|
325
|
-
break;
|
|
326
|
-
case 'descent-refused':
|
|
327
|
-
fail(`REFUSED (fast-forward rule): ${result.reason}`);
|
|
328
|
-
process.exit(1);
|
|
329
|
-
break;
|
|
330
|
-
case 'authority-refused':
|
|
331
|
-
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
332
|
-
process.exit(1);
|
|
333
|
-
break;
|
|
334
|
-
case 'verify-failed':
|
|
335
|
-
fail(`Verify FAILED: ${result.reason}`);
|
|
336
|
-
process.exit(1);
|
|
337
|
-
break;
|
|
338
|
-
case 'applied':
|
|
339
|
-
success(`Applied and verified — the target is at ${result.liveFingerprint.slice(0, 12)}, exactly as the plan predicted. Recorded in everystack.schema_log (plan_ref ${planHash(plan).slice(0, 12)}).`);
|
|
340
|
-
break;
|
|
341
|
-
}
|
|
331
|
+
reportApplyResult(result, plan);
|
|
342
332
|
} catch (err: any) {
|
|
343
333
|
fail(`Apply failed and rolled back: ${err.message}`);
|
|
344
334
|
process.exit(1);
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
type BackfillPlan,
|
|
28
28
|
} from '../backfill.js';
|
|
29
29
|
import { currentGitRef } from '../state-apply.js';
|
|
30
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
31
31
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
32
32
|
import { step, success, fail, info, warn } from '../output.js';
|
|
33
33
|
|
|
@@ -69,7 +69,7 @@ export async function dbBackfillCommand(flags: Record<string, string>): Promise<
|
|
|
69
69
|
process.exit(1);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
step(dbSource
|
|
72
|
+
step(connectingVia(dbSource));
|
|
73
73
|
const { runner, end } = await createUrlRunner(dbSource.url);
|
|
74
74
|
try {
|
|
75
75
|
const opts = { actor: process.env.USER ?? null, gitRef: currentGitRef() };
|
|
@@ -63,6 +63,22 @@ export async function dbBackupProbeCommand(flags: Record<string, string>): Promi
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Interpret a `db:backup:probe` result into a fail-clean remedy, or `null` when a dump is safe to
|
|
68
|
+
* run. Pre-flighting this before the dump turns a missing or version-incompatible pg_dump layer
|
|
69
|
+
* into a one-line remedy instead of a cryptic runtime crash mid-dump.
|
|
70
|
+
*/
|
|
71
|
+
export function pgDumpPreflightError(probe: any): string | null {
|
|
72
|
+
if (!probe || probe.error) {
|
|
73
|
+
const detail = probe?.error ?? 'the pg_dump layer probe returned no result';
|
|
74
|
+
return `${detail}\nBuild + publish the layer with scripts/build-pgdump-layer.sh, export PGDUMP_LAYER_ARN, then redeploy.`;
|
|
75
|
+
}
|
|
76
|
+
if (probe.compatible === false) {
|
|
77
|
+
return `pg_dump ${probe.pgDump ?? '(unknown)'} (major ${probe.pgDumpMajor}) is older than the server (major ${probe.serverMajor}) — it will refuse to dump (no bypass exists). Rebuild the layer with pg_dump >= ${probe.serverMajor}.`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
66
82
|
/** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
|
|
67
83
|
export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
|
|
68
84
|
step('Resolving deployed config...');
|
|
@@ -75,6 +91,24 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
|
|
|
75
91
|
}
|
|
76
92
|
|
|
77
93
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
94
|
+
|
|
95
|
+
// Pre-flight the pg_dump layer before the dump — a missing/incompatible layer becomes a one-line
|
|
96
|
+
// remedy here instead of crashing the runtime mid-dump (the probe returns a clean error, never a
|
|
97
|
+
// crash, when the layer is absent).
|
|
98
|
+
step('Checking the pg_dump layer...');
|
|
99
|
+
let probe: any;
|
|
100
|
+
try {
|
|
101
|
+
probe = await invokeAction(config.region, opsFunction(config), 'db:backup:probe', {});
|
|
102
|
+
} catch (err: any) {
|
|
103
|
+
fail(`Backup pre-flight failed: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const preflightError = pgDumpPreflightError(probe);
|
|
107
|
+
if (preflightError) {
|
|
108
|
+
fail(preflightError);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
step('Running pg_dump → S3 (this may take a while for large databases)...');
|
|
79
113
|
|
|
80
114
|
let result: any;
|
|
@@ -38,6 +38,7 @@ import { createDatabase, dropDatabase, buildIntoDatabase, withDatabase } from '.
|
|
|
38
38
|
import { resolveModelsPath } from '../models-path.js';
|
|
39
39
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
40
40
|
import { loadModels } from './db-generate.js';
|
|
41
|
+
import { findReadAuthzGaps } from '../authz-lint.js';
|
|
41
42
|
import { step, success, fail, info, warn } from '../output.js';
|
|
42
43
|
|
|
43
44
|
// The role derivation moved to the shared builder (db-build) with brick
|
|
@@ -53,7 +54,7 @@ const DEFAULT_SCHEMA_OUT = 'db/schema.generated.ts';
|
|
|
53
54
|
|
|
54
55
|
export interface CheckFinding {
|
|
55
56
|
level: 'ok' | 'note' | 'fail';
|
|
56
|
-
area: 'models' | 'compose' | 'artifact' | 'compute';
|
|
57
|
+
area: 'models' | 'compose' | 'artifact' | 'compute' | 'authz';
|
|
57
58
|
message: string;
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -104,6 +105,17 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
|
|
|
104
105
|
return findings;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
// Force-RLS with no read authz: an exposed, RLS-enabled table that declares no read ability reads
|
|
109
|
+
// empty for every app role once it is RLS-subject (the superuser-drop landmine).
|
|
110
|
+
const authzGaps = findReadAuthzGaps(input.models);
|
|
111
|
+
if (authzGaps.length === 0) {
|
|
112
|
+
findings.push({ level: 'ok', area: 'authz', message: 'every exposed table declares a read path — none goes dark under RLS' });
|
|
113
|
+
} else {
|
|
114
|
+
for (const gap of authzGaps) {
|
|
115
|
+
findings.push({ level: 'fail', area: 'authz', message: gap.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
if (input.artifactSource === null) {
|
|
108
120
|
findings.push({
|
|
109
121
|
level: 'note',
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type UnfingerprintedObject,
|
|
26
26
|
} from '../schema-fingerprint.js';
|
|
27
27
|
import { resolveModelsPath } from '../models-path.js';
|
|
28
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
28
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
29
29
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
30
30
|
import { invokeAction } from '../aws.js';
|
|
31
31
|
import { step, success, fail, info, warn } from '../output.js';
|
|
@@ -80,7 +80,7 @@ export async function dbFingerprintCommand(flags: Record<string, string>): Promi
|
|
|
80
80
|
let runner: QueryRunner;
|
|
81
81
|
let end: (() => Promise<void>) | undefined;
|
|
82
82
|
if (dbSource.kind === 'url') {
|
|
83
|
-
step(dbSource
|
|
83
|
+
step(connectingVia(dbSource));
|
|
84
84
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
85
85
|
} else {
|
|
86
86
|
step('Resolving deployed config...');
|
|
@@ -27,7 +27,7 @@ import { compileModuleMigration } from '../migration-compile.js';
|
|
|
27
27
|
import { generateMigrationSql, unmodeledTables, formatMigrationFile, planMigrationFile, HELD_DROP_PREFIX, type Journal } from '../migration-generate.js';
|
|
28
28
|
import { introspectContract, type QueryRunner } from '../authz-contract.js';
|
|
29
29
|
import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
|
|
30
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
30
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
31
31
|
import { resolveModelsPath } from '../models-path.js';
|
|
32
32
|
import { applyStateAndVerify, classifyGeneratedStatements, currentGitRef, type StateSyncOutcome } from '../state-apply.js';
|
|
33
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
@@ -157,7 +157,7 @@ export async function dbGenerateCommand(flags: Record<string, string>): Promise<
|
|
|
157
157
|
process.exit(1);
|
|
158
158
|
}
|
|
159
159
|
if (dbSource.kind === 'url') {
|
|
160
|
-
step(dbSource
|
|
160
|
+
step(connectingVia(dbSource));
|
|
161
161
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
162
162
|
} else {
|
|
163
163
|
step('Resolving deployed config...');
|
|
@@ -28,24 +28,16 @@ import { verifyDescent } from '../git-descent.js';
|
|
|
28
28
|
import { planBackfills, readBackfillLog } from '../backfill.js';
|
|
29
29
|
import { readSqlDirIfPresent } from './db-sync.js';
|
|
30
30
|
import { currentGitRef } from '../state-apply.js';
|
|
31
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
31
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
32
32
|
import { resolveModelsPath } from '../models-path.js';
|
|
33
33
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
34
|
-
import {
|
|
34
|
+
import { lambdaQueryRunner } from '../aws.js';
|
|
35
35
|
import { loadModels } from './db-generate.js';
|
|
36
|
+
import { reportPipelineLastRun } from './pipeline-run.js';
|
|
36
37
|
import { step, success, fail, info, warn } from '../output.js';
|
|
37
38
|
|
|
38
39
|
const DEFAULT_OUT = 'db.plan.json';
|
|
39
40
|
|
|
40
|
-
/** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
|
|
41
|
-
function lambdaRunner(region: string, fn: string): QueryRunner {
|
|
42
|
-
return async (sql: string) => {
|
|
43
|
-
const result: any = await invokeAction(region, fn, 'db:query', { sql });
|
|
44
|
-
if (result?.error) throw new Error(`Query failed: ${result.error}`);
|
|
45
|
-
return result?.rows ?? [];
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
41
|
export async function dbPlanCommand(flags: Record<string, string>): Promise<void> {
|
|
50
42
|
let dbSource: DbSource;
|
|
51
43
|
try {
|
|
@@ -69,12 +61,12 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
69
61
|
let runner: QueryRunner;
|
|
70
62
|
let end: (() => Promise<void>) | undefined;
|
|
71
63
|
if (dbSource.kind === 'url') {
|
|
72
|
-
step(dbSource
|
|
64
|
+
step(connectingVia(dbSource));
|
|
73
65
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
74
66
|
} else {
|
|
75
67
|
step('Resolving deployed config...');
|
|
76
68
|
const config = await resolveConfig(flags.stage);
|
|
77
|
-
runner =
|
|
69
|
+
runner = lambdaQueryRunner(config.region, opsFunction(config));
|
|
78
70
|
}
|
|
79
71
|
|
|
80
72
|
try {
|
|
@@ -134,6 +126,8 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
|
|
|
134
126
|
warn(`backfill lane: could not read everystack.backfill_log (${err.message}) — pending-backfill visibility unavailable.`);
|
|
135
127
|
}
|
|
136
128
|
}
|
|
129
|
+
|
|
130
|
+
await reportPipelineLastRun(runner);
|
|
137
131
|
if (out !== '-') {
|
|
138
132
|
success(`Wrote ${out} — review it, then \`everystack db:apply --plan ${out}\`.`);
|
|
139
133
|
warn('Plans are ephemeral release artifacts — attach to the run, do NOT commit.');
|
|
@@ -25,7 +25,7 @@ import path from 'node:path';
|
|
|
25
25
|
import { introspectSchema } from '../schema-introspect.js';
|
|
26
26
|
import { renderModelSource, renderModelFiles, pullableTables } from '../model-render.js';
|
|
27
27
|
import type { QueryRunner } from '../authz-contract.js';
|
|
28
|
-
import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
|
|
28
|
+
import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
|
|
29
29
|
import { resolveConfig, opsFunction } from '../config.js';
|
|
30
30
|
import { invokeAction } from '../aws.js';
|
|
31
31
|
import { fail } from '../output.js';
|
|
@@ -62,7 +62,7 @@ export async function dbPullCommand(flags: Record<string, string>): Promise<void
|
|
|
62
62
|
let runner: QueryRunner;
|
|
63
63
|
let end: (() => Promise<void>) | undefined;
|
|
64
64
|
if (dbSource.kind === 'url') {
|
|
65
|
-
note(dbSource
|
|
65
|
+
note(connectingVia(dbSource));
|
|
66
66
|
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
67
67
|
} else {
|
|
68
68
|
note('Resolving deployed config...');
|