@everystack/cli 0.4.30 → 0.4.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 +9 -1
- package/src/cli/apply-execute.ts +36 -14
- package/src/cli/commands/db-apply.ts +21 -32
- package/src/cli/commands/db-backup.ts +56 -86
- package/src/cli/commands/db-exec.ts +132 -0
- package/src/cli/commands/db-export.ts +27 -16
- package/src/cli/commands/db-fork.ts +20 -6
- package/src/cli/commands/deploy.ts +3 -4
- package/src/cli/commands/task-probe.ts +66 -0
- package/src/cli/exec-digest.ts +79 -0
- package/src/cli/exec-execute.ts +115 -0
- package/src/cli/exec-log.ts +63 -0
- package/src/cli/exec-run.ts +109 -0
- package/src/cli/index.ts +14 -5
- package/src/cli/task-poll.ts +67 -0
- package/src/exec.ts +20 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.31",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/cli/apply-execute.ts",
|
|
38
38
|
"default": "./src/cli/apply-execute.ts"
|
|
39
39
|
},
|
|
40
|
+
"./db-source": {
|
|
41
|
+
"types": "./src/cli/db-source.ts",
|
|
42
|
+
"default": "./src/cli/db-source.ts"
|
|
43
|
+
},
|
|
40
44
|
"./reconcile": {
|
|
41
45
|
"types": "./src/reconcile.ts",
|
|
42
46
|
"default": "./src/reconcile.ts"
|
|
@@ -49,6 +53,10 @@
|
|
|
49
53
|
"types": "./src/backfill.ts",
|
|
50
54
|
"default": "./src/backfill.ts"
|
|
51
55
|
},
|
|
56
|
+
"./exec": {
|
|
57
|
+
"types": "./src/exec.ts",
|
|
58
|
+
"default": "./src/exec.ts"
|
|
59
|
+
},
|
|
52
60
|
"./audit/source": {
|
|
53
61
|
"types": "./src/cli/audit-source-api.ts",
|
|
54
62
|
"default": "./src/cli/audit-source-api.ts"
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -62,18 +62,20 @@ export interface ApplyPlanOptions {
|
|
|
62
62
|
*/
|
|
63
63
|
verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* The destructive-apply safety gate (B3): called only for destructive plans, after the lock,
|
|
66
|
+
* descent, and authority pass and BEFORE the edge executes. The apply NO LONGER takes its own
|
|
67
|
+
* snapshot — the caller must be covered by a verified backup. Return `ok: false` to refuse (the
|
|
68
|
+
* refusal is recorded in schema_log, like the others). `planFrom` is handed in so the caller can
|
|
69
|
+
* assert `backup.fingerprint == planFrom` server-side. Omitted = no gate (bare connections that
|
|
70
|
+
* attest their own --snapshot-ref keep the ceremony the command shell enforces).
|
|
69
71
|
*/
|
|
70
|
-
|
|
72
|
+
verifySnapshot?: (ctx: { planFrom: string }) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
71
73
|
/** Injectable clock for tests. */
|
|
72
74
|
now?: () => number;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
export interface ApplyPlanResult {
|
|
76
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
|
|
78
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'verify-failed';
|
|
77
79
|
liveFingerprint: string;
|
|
78
80
|
reason?: string;
|
|
79
81
|
logId?: number;
|
|
@@ -110,15 +112,33 @@ async function recordRefusal(
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/** The live base-schema fingerprint + the two introspections it's built from. */
|
|
116
|
+
export interface LiveBaseFingerprint {
|
|
117
|
+
hash: string;
|
|
118
|
+
snapshot: SchemaSnapshot;
|
|
119
|
+
contract: AuthzContract;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The live base-schema fingerprint over a runner — the exact value db:plan stamps as plan.from and
|
|
124
|
+
* the apply's concurrency lock recomputes. Extracted so EVERY producer computes one identical hash:
|
|
125
|
+
* the apply lock (here), db:fingerprint, and the db:backup Task that stamps its manifest for the
|
|
126
|
+
* destructive-apply safety gate (backup.fp == plan.from). A second copy would silently drift the
|
|
127
|
+
* fingerprint chain — the whole point of the gate.
|
|
128
|
+
*/
|
|
129
|
+
export async function liveBaseFingerprint(runner: QueryRunner): Promise<LiveBaseFingerprint> {
|
|
130
|
+
const snapshot = await introspectSchema(runner);
|
|
131
|
+
const contract = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
132
|
+
return { hash: fingerprintLive(snapshot, contract).hash, snapshot, contract };
|
|
133
|
+
}
|
|
134
|
+
|
|
113
135
|
/** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
|
|
114
136
|
export async function executeApplyPlan(
|
|
115
137
|
runner: QueryRunner,
|
|
116
138
|
plan: EdgePlan,
|
|
117
139
|
options: ApplyPlanOptions = {},
|
|
118
140
|
): Promise<ApplyPlanResult> {
|
|
119
|
-
const before = await
|
|
120
|
-
const beforeAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
121
|
-
const live = fingerprintLive(before, beforeAuthz).hash;
|
|
141
|
+
const { hash: live, snapshot: before, contract: beforeAuthz } = await liveBaseFingerprint(runner);
|
|
122
142
|
|
|
123
143
|
if (live === plan.toFingerprint) {
|
|
124
144
|
return { status: 'already-applied', liveFingerprint: live };
|
|
@@ -145,8 +165,12 @@ export async function executeApplyPlan(
|
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
if (plan.destructive > 0 && options.
|
|
149
|
-
await options.
|
|
168
|
+
if (plan.destructive > 0 && options.verifySnapshot) {
|
|
169
|
+
const snapshot = await options.verifySnapshot({ planFrom: live });
|
|
170
|
+
if (!snapshot.ok) {
|
|
171
|
+
await recordRefusal(runner, plan, live, 'snapshot gate', snapshot.reason, options);
|
|
172
|
+
return { status: 'snapshot-refused', liveFingerprint: live, reason: snapshot.reason };
|
|
173
|
+
}
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
const result = await applyGeneratedStatements(runner, plan.statements, {
|
|
@@ -157,9 +181,7 @@ export async function executeApplyPlan(
|
|
|
157
181
|
now: options.now,
|
|
158
182
|
});
|
|
159
183
|
|
|
160
|
-
const
|
|
161
|
-
const afterAuthz = await introspectContract(runner, contractFunctionRow, FUNCTIONS_SQL);
|
|
162
|
-
const landed = fingerprintLive(after, afterAuthz).hash;
|
|
184
|
+
const { hash: landed } = await liveBaseFingerprint(runner);
|
|
163
185
|
if (result.logId !== undefined) {
|
|
164
186
|
await runner(renderSchemaLogFingerprintUpdate(result.logId, landed));
|
|
165
187
|
}
|
|
@@ -38,7 +38,6 @@ import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
|
|
|
38
38
|
import { estimateOpsRuntimeFit } from '../ops-fit.js';
|
|
39
39
|
import { resolveOperatorUrlViaStage } from '../direct-venue.js';
|
|
40
40
|
import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
|
|
41
|
-
import { pgDumpPreflightError } from './db-backup.js';
|
|
42
41
|
import { step, success, fail, info, warn } from '../output.js';
|
|
43
42
|
|
|
44
43
|
// The apply core lives in apply-execute.ts (shared with the ops Lambda's
|
|
@@ -90,6 +89,10 @@ function reportApplyResult(result: ApplyPlanResult, plan: EdgePlan): void {
|
|
|
90
89
|
fail(`REFUSED (destructive authority): ${result.reason}`);
|
|
91
90
|
process.exit(1);
|
|
92
91
|
break;
|
|
92
|
+
case 'snapshot-refused':
|
|
93
|
+
fail(`REFUSED (snapshot gate): ${result.reason}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
break;
|
|
93
96
|
case 'verify-failed':
|
|
94
97
|
fail(`Verify FAILED: ${result.reason}`);
|
|
95
98
|
process.exit(1);
|
|
@@ -120,19 +123,6 @@ async function applyPlanViaStage(
|
|
|
120
123
|
|
|
121
124
|
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` : ''})`);
|
|
122
125
|
|
|
123
|
-
// A destructive staged apply snapshots via db:backup INSIDE the Lambda, before the DDL. Preflight
|
|
124
|
-
// that capability HERE so a missing/incompatible pg_dump layer fails fast with the install remedy —
|
|
125
|
-
// not deep in the ceremony after the authority check, the way it once surfaced mid-apply.
|
|
126
|
-
if (plan.destructive > 0) {
|
|
127
|
-
step('Preflighting the pg_dump layer (the destructive apply snapshots before the DDL)...');
|
|
128
|
-
const probe: any = await invokeAction(region, fn, 'db:backup:probe', {}).catch((err: any) => ({ error: err?.message ?? String(err) }));
|
|
129
|
-
const remedy = pgDumpPreflightError(probe);
|
|
130
|
-
if (remedy) {
|
|
131
|
-
fail(`Cannot take the pre-apply snapshot — the destructive plan was NOT applied.\n${remedy}`);
|
|
132
|
-
process.exit(1);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
126
|
try {
|
|
137
127
|
// Descent — the fast-forward rule needs the git checkout, so the CLI
|
|
138
128
|
// decides it (the Lambda has no checkout). Read the stage's state read-only
|
|
@@ -168,8 +158,9 @@ async function applyPlanViaStage(
|
|
|
168
158
|
|
|
169
159
|
// Destructive ceremony — the approver check is against the caller's REAL
|
|
170
160
|
// IAM identity (STS), which the Lambda cannot see, so the CLI resolves it
|
|
171
|
-
// and checks it against the stage's declared approver set (SSM). The
|
|
172
|
-
//
|
|
161
|
+
// and checks it against the stage's declared approver set (SSM). The safety
|
|
162
|
+
// net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
|
|
163
|
+
// the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
|
|
173
164
|
let authorityVerdict: AuthorityVerdict | undefined;
|
|
174
165
|
if (plan.destructive > 0) {
|
|
175
166
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
@@ -198,6 +189,7 @@ async function applyPlanViaStage(
|
|
|
198
189
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
199
190
|
descentVerdict,
|
|
200
191
|
...(authorityVerdict ? { authorityVerdict } : {}),
|
|
192
|
+
...(flags['snapshot-ref'] ? { snapshotRef: flags['snapshot-ref'] } : {}),
|
|
201
193
|
stage: flags.stage,
|
|
202
194
|
});
|
|
203
195
|
if (result?.error) {
|
|
@@ -258,8 +250,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
258
250
|
|
|
259
251
|
// `--stage --direct` (lane 1): resolve the stage's OPERATOR connection from its ops
|
|
260
252
|
// Lambda, then execute CLI-side with an unbounded clock — the same ceremony as the
|
|
261
|
-
// direct path below (which is stage-aware: approver check +
|
|
262
|
-
//
|
|
253
|
+
// direct path below (which is stage-aware: approver check + an attested --snapshot-ref
|
|
254
|
+
// for destructive plans), just a longer clock. The operator never holds a URL; it lives in memory only.
|
|
263
255
|
if (dbSource.kind === 'stage' && flags.direct === 'true') {
|
|
264
256
|
try {
|
|
265
257
|
step('Resolving the operator connection from the stage (--direct)...');
|
|
@@ -292,13 +284,21 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
292
284
|
// set — identity-approved (decision 12).
|
|
293
285
|
const isDestructive = plan.destructive > 0;
|
|
294
286
|
let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
|
|
295
|
-
let beforeDestructive: (() => Promise<void>) | undefined;
|
|
296
287
|
if (isDestructive) {
|
|
297
288
|
const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
|
|
298
289
|
if (flags.confirm !== 'true') {
|
|
299
290
|
fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
|
|
300
291
|
process.exit(1);
|
|
301
292
|
}
|
|
293
|
+
// A DIRECT apply is operator-attested: the operator holds the URL, so the safety net is a backup
|
|
294
|
+
// they took and NAME here. (The credential-free path — db:apply --plan … --stage <name>, WITHOUT
|
|
295
|
+
// --direct — auto-resolves and verifies the stage's latest backup server-side; a direct/bare
|
|
296
|
+
// connection has no ops Lambda to verify against, so the ref is required and attested.)
|
|
297
|
+
if (!flags['snapshot-ref']) {
|
|
298
|
+
const takeIt = `everystack db:backup${flags.stage ? ` --stage ${flags.stage}` : ''}`;
|
|
299
|
+
fail(`This plan is DESTRUCTIVE (${shape}) over a direct connection — the apply does not snapshot for you. Take a safety point (${takeIt}) and name it: --snapshot-ref <id>. Or drop --direct and let the credential-free --stage apply auto-verify your latest backup.`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
302
|
if (flags.stage) {
|
|
303
303
|
const config = await resolveConfig(flags.stage);
|
|
304
304
|
const { parseAppName } = await import('../discover.js');
|
|
@@ -316,19 +316,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
316
316
|
}
|
|
317
317
|
return { ok: true };
|
|
318
318
|
};
|
|
319
|
-
|
|
320
|
-
step('Destructive apply — taking a snapshot first (db:backup)...');
|
|
321
|
-
const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
|
|
322
|
-
if (result?.error) {
|
|
323
|
-
throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
|
|
324
|
-
}
|
|
325
|
-
info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
|
|
326
|
-
};
|
|
327
|
-
} else if (!flags['snapshot-ref']) {
|
|
328
|
-
fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
|
|
329
|
-
process.exit(1);
|
|
319
|
+
warn(`DESTRUCTIVE direct apply — proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
|
|
330
320
|
} else {
|
|
331
|
-
warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
|
|
321
|
+
warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + attested snapshot ${flags['snapshot-ref']}.`);
|
|
332
322
|
}
|
|
333
323
|
}
|
|
334
324
|
|
|
@@ -351,7 +341,6 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
|
|
|
351
341
|
actor: process.env.USER ?? null,
|
|
352
342
|
gitRef: currentGitRef() ?? plan.gitRef,
|
|
353
343
|
...(verifyAuthority ? { verifyAuthority } : {}),
|
|
354
|
-
...(beforeDestructive ? { beforeDestructive } : {}),
|
|
355
344
|
...(forceDescent === undefined ? {
|
|
356
345
|
verifyDescent: async (live: LiveState & { fingerprint: string }) => {
|
|
357
346
|
step('Descent: searching git for the commit that declares the target\'s state...');
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* db:backup family — logical pg_dump/pg_restore backups (the postgres-native, DB-agnostic path).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* db:backup/db:export run in the ephemeral Task lane (the ops Lambda holds no pg binaries); the CLI
|
|
5
|
+
* dispatches and polls. db:backups/db:restore/download are thin invokeAction wrappers. The image's
|
|
6
|
+
* version handshake is the compatibility gate — verify a deploy with `everystack task:probe`.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
10
10
|
import { invokeAction, presignGet } from '../aws.js';
|
|
11
11
|
import { parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard } from '../backup.js';
|
|
12
|
+
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
12
13
|
import { step, success, fail, info, warn } from '../output.js';
|
|
13
14
|
|
|
14
15
|
function fmtBytes(n?: number): string {
|
|
@@ -21,65 +22,10 @@ function fmtBytes(n?: number): string {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* db:backup
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* db:backup — pg_dump the stage's DB to S3, in the ephemeral Task lane (the ops Lambda holds no pg
|
|
26
|
+
* binaries). The CLI dispatches the task and polls until it stops; the task's version handshake is
|
|
27
|
+
* the compatibility gate (a too-old image fails the run loudly — no separate layer pre-flight).
|
|
27
28
|
*/
|
|
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('Install @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.');
|
|
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
|
-
/**
|
|
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}\nInstall @everystack/pg-tools and redeploy — the layer attaches automatically. For a bring-your-own layer, pass pgDumpLayer(..., { layerArn }) in sst.config.ts.`;
|
|
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
|
-
|
|
82
|
-
/** db:backup — pg_dump the stage's DB to S3 (runs in the ops Lambda). Prints the new backup id. */
|
|
83
29
|
export async function dbBackupCommand(flags: Record<string, string>): Promise<void> {
|
|
84
30
|
step('Resolving deployed config...');
|
|
85
31
|
let config: CliConfig;
|
|
@@ -91,38 +37,41 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
|
|
|
91
37
|
}
|
|
92
38
|
|
|
93
39
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
40
|
+
const fn = opsFunction(config);
|
|
94
41
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// crash, when the layer is absent).
|
|
98
|
-
step('Checking the pg_dump layer...');
|
|
99
|
-
let probe: any;
|
|
42
|
+
step('Dispatching the backup task (credential-free, via the ops Lambda)...');
|
|
43
|
+
let dispatched: any;
|
|
100
44
|
try {
|
|
101
|
-
|
|
45
|
+
dispatched = await invokeAction(config.region, fn, 'db:backup', { stage: flags.stage, actor: process.env.USER ?? null });
|
|
102
46
|
} catch (err: any) {
|
|
103
|
-
fail(`Backup
|
|
47
|
+
fail(`Backup failed to dispatch: ${err.message}`);
|
|
104
48
|
process.exit(1);
|
|
105
49
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
fail(preflightError);
|
|
50
|
+
if (dispatched?.error) {
|
|
51
|
+
fail(`Backup failed: ${dispatched.error}`);
|
|
109
52
|
process.exit(1);
|
|
110
53
|
}
|
|
54
|
+
const { runId, taskArn, id, warning } = dispatched as { runId: string; taskArn: string; id: string; warning?: string };
|
|
55
|
+
info(`backup ${id} — task ${taskArn}`);
|
|
56
|
+
info(`run id: ${runId} (everystack.task_log)`);
|
|
57
|
+
if (warning) info(`note: ${warning}`);
|
|
111
58
|
|
|
112
59
|
step('Running pg_dump → S3 (this may take a while for large databases)...');
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
result = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
|
|
117
|
-
} catch (err: any) {
|
|
118
|
-
fail(`Backup failed: ${err.message}`);
|
|
60
|
+
const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
|
|
61
|
+
if (poll.outcome === 'timeout') {
|
|
62
|
+
fail(`db:backup timed out (last status: ${poll.lastStatus}). Run id ${runId} — reconcile via ECS/everystack.task_log.`);
|
|
119
63
|
process.exit(1);
|
|
120
64
|
}
|
|
121
|
-
if (
|
|
122
|
-
fail(`
|
|
65
|
+
if (poll.outcome === 'error') {
|
|
66
|
+
fail(`task:status failed repeatedly: ${poll.status.error}. The backup may still be running — reconcile run id ${runId}.`);
|
|
123
67
|
process.exit(1);
|
|
124
68
|
}
|
|
125
|
-
|
|
69
|
+
if (poll.status.exitCode !== 0) {
|
|
70
|
+
fail(`Backup failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const bytes = (poll.status.result?.bytes as number | undefined) ?? undefined;
|
|
74
|
+
success(`Backup ${id} (${fmtBytes(bytes)}). Restore with: everystack db:restore --from ${id} --confirm`);
|
|
126
75
|
}
|
|
127
76
|
|
|
128
77
|
/** db:backups — list a stage's logical backups. */
|
|
@@ -198,16 +147,37 @@ export async function dbRestoreCommand(flags: Record<string, string>): Promise<v
|
|
|
198
147
|
}
|
|
199
148
|
|
|
200
149
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
201
|
-
|
|
202
|
-
|
|
150
|
+
const fn = opsFunction(config);
|
|
151
|
+
|
|
152
|
+
step('Dispatching the restore task (credential-free, via the ops Lambda)...');
|
|
153
|
+
let dispatched: any;
|
|
203
154
|
try {
|
|
204
|
-
|
|
155
|
+
dispatched = await invokeAction(config.region, fn, 'db:restore', { id, confirm: true, stage: flags.stage, actor: process.env.USER ?? null });
|
|
205
156
|
} catch (err: any) {
|
|
206
|
-
fail(`Restore failed: ${err.message}`);
|
|
157
|
+
fail(`Restore failed to dispatch: ${err.message}`);
|
|
207
158
|
process.exit(1);
|
|
208
159
|
}
|
|
209
|
-
if (
|
|
210
|
-
fail(`Restore failed: ${
|
|
160
|
+
if (dispatched?.error) {
|
|
161
|
+
fail(`Restore failed: ${dispatched.error}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const { runId, taskArn, intentKey, warning } = dispatched as { runId: string; taskArn: string; intentKey: string; warning?: string };
|
|
165
|
+
info(`restore of ${id} → ${toStage} — task ${taskArn}`);
|
|
166
|
+
info(`intent: ${intentKey} (durable S3 record — survives the restore)`);
|
|
167
|
+
if (warning) info(`note: ${warning}`);
|
|
168
|
+
|
|
169
|
+
step(`Restoring ${id} into ${toStage} (pg_restore in the Task; this may take a while)...`);
|
|
170
|
+
const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
|
|
171
|
+
if (poll.outcome === 'timeout') {
|
|
172
|
+
fail(`db:restore timed out (last status: ${poll.lastStatus}). Intent ${intentKey} — reconcile via ECS/the S3 intent.`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
if (poll.outcome === 'error') {
|
|
176
|
+
fail(`task:status failed repeatedly: ${poll.status.error}. The restore may still be running — reconcile intent ${intentKey}.`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
if (poll.status.exitCode !== 0) {
|
|
180
|
+
fail(`Restore failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. The target DB may be partially restored — check the S3 intent ${intentKey} and the task logs.`);
|
|
211
181
|
process.exit(1);
|
|
212
182
|
}
|
|
213
183
|
success(`Restored ${id} into ${toStage}.`);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `everystack db:exec <file.sql>` — credential-free write SQL, the daily driver.
|
|
3
|
+
*
|
|
4
|
+
* db:exec seed.sql --stage dev # run in the ops Lambda (no URL held), DML-only
|
|
5
|
+
* db:exec --stage prod some.sql --confirm # prod-tier writes require --confirm
|
|
6
|
+
* cat some.sql | db:exec --stage dev # stdin also works (a `psql < file.sql` replacement)
|
|
7
|
+
* db:exec some.sql --database-url <url> # local-direct (dev), unbounded clock
|
|
8
|
+
*
|
|
9
|
+
* The file runs as ONE transaction, DML-only enforced SEMANTICALLY: a catalog digest taken before
|
|
10
|
+
* vs after the file rejects ANY schema change — however caused (a function, a DO block, dynamic
|
|
11
|
+
* SQL) — by rolling the whole transaction back. Schema changes go through db:apply / db:reconcile.
|
|
12
|
+
* Every run is recorded, crash-truthfully, in everystack.exec_log (intent before, outcome after).
|
|
13
|
+
*
|
|
14
|
+
* Mirrors db:backfill's two venues: `--stage` ships the SQL to the ops Lambda's db:exec action
|
|
15
|
+
* (the operator holds no URL); `--database-url` connects directly for local dev.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import { resolveDbSource, sslDefaults } from '../db-source.js';
|
|
20
|
+
import { resolveConfig, opsFunction } from '../config.js';
|
|
21
|
+
import { invokeAction } from '../aws.js';
|
|
22
|
+
import { isProductionTier } from '../backup.js';
|
|
23
|
+
import { executeExec, assertNoTxnControl } from '../exec-execute.js';
|
|
24
|
+
import { runExecInTx } from '../exec-run.js';
|
|
25
|
+
import { ENSURE_EXEC_LOG_SQL, renderExecIntentInsert, renderExecOutcomeUpdate } from '../exec-log.js';
|
|
26
|
+
import { step, success, fail, info } from '../output.js';
|
|
27
|
+
|
|
28
|
+
async function readStdin(): Promise<string> {
|
|
29
|
+
const chunks: Buffer[] = [];
|
|
30
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
31
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The app schemas the digest guard covers on the direct path (--schemas a,b; default public). */
|
|
35
|
+
function directSchemas(flags: Record<string, string>): string[] {
|
|
36
|
+
if (flags.schemas && flags.schemas !== 'true') {
|
|
37
|
+
return flags.schemas.split(',').map((s) => s.trim()).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
return ['public'];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function reportOk(res: { rowsAffected?: number[] }): void {
|
|
43
|
+
const rows = res.rowsAffected ?? [];
|
|
44
|
+
const total = rows.reduce((a, b) => a + b, 0);
|
|
45
|
+
success(`db:exec applied ${rows.length} statement(s), ${total} row(s) affected — recorded in everystack.exec_log.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function dbExecCommand(flags: Record<string, string>, file?: string): Promise<void> {
|
|
49
|
+
// Read the SQL: a positional file, `-`/absent for stdin (the `psql < file.sql` replacement).
|
|
50
|
+
let fileSql: string;
|
|
51
|
+
try {
|
|
52
|
+
fileSql = file && file !== '-' ? await fs.readFile(file, 'utf8') : await readStdin();
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
fail(`db:exec: cannot read SQL (${err.message}).`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!fileSql.trim()) {
|
|
58
|
+
fail('db:exec: no SQL to run — pass a file or pipe SQL on stdin.');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fail fast on a self-transacting file before any invoke/connection (the real DML-only guard is
|
|
63
|
+
// the catalog digest, server-side; this is the courtesy pre-check).
|
|
64
|
+
try {
|
|
65
|
+
assertNoTxnControl(fileSql);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
fail(err.message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const source = resolveDbSource(flags);
|
|
72
|
+
const actor = process.env.USER ?? null;
|
|
73
|
+
|
|
74
|
+
// --- Stage venue: ship the SQL to the ops Lambda's db:exec action (operator holds no URL). ---
|
|
75
|
+
if (source.kind === 'stage') {
|
|
76
|
+
const stage = flags.stage;
|
|
77
|
+
if (stage && isProductionTier(stage) && flags.confirm !== 'true') {
|
|
78
|
+
fail(`db:exec writes to production-tier "${stage}" — pass --confirm to proceed.`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
step('Resolving deployed config...');
|
|
82
|
+
const config = await resolveConfig(stage);
|
|
83
|
+
step('Running db:exec in the ops Lambda (credential-free, DML-only, one transaction)...');
|
|
84
|
+
const res: any = await invokeAction(config.region, opsFunction(config), 'db:exec', {
|
|
85
|
+
sql: fileSql, actor, stage: stage ?? null,
|
|
86
|
+
});
|
|
87
|
+
if (res?.error) {
|
|
88
|
+
fail(`db:exec failed: ${res.error}`);
|
|
89
|
+
if (/Unknown action/i.test(String(res.error))) {
|
|
90
|
+
info('The deployed handler predates the db:exec ops action. Upgrade @everystack/server, or run local: db:exec <file> --database-url <url>.');
|
|
91
|
+
}
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
reportOk(res);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Direct venue (local dev): the same ceremony, CLI-side, unbounded clock. ---
|
|
99
|
+
step('Connecting via ' + (source.from === 'flag' ? '--database-url' : source.from) + ' (DML-only, one transaction)...');
|
|
100
|
+
const postgres = (await import('postgres')).default;
|
|
101
|
+
const sqlc = postgres(source.url, { max: 1, onnotice: () => {}, ...sslDefaults(source.url) });
|
|
102
|
+
try {
|
|
103
|
+
const audit = async (query: string): Promise<any[]> => Array.from(await sqlc.unsafe(query)) as any[];
|
|
104
|
+
const res = await executeExec({
|
|
105
|
+
sql: fileSql,
|
|
106
|
+
actor: actor ?? 'unknown',
|
|
107
|
+
stage: flags.stage ?? 'local',
|
|
108
|
+
writeIntent: async (intent) => {
|
|
109
|
+
for (const ddl of ENSURE_EXEC_LOG_SQL) await audit(ddl);
|
|
110
|
+
const rows = await audit(renderExecIntentInsert(intent));
|
|
111
|
+
return String(rows[0].id);
|
|
112
|
+
},
|
|
113
|
+
run: () =>
|
|
114
|
+
sqlc.begin(async (tx: any) => {
|
|
115
|
+
const txRun = async (stmt: string) => {
|
|
116
|
+
const r = await tx.unsafe(stmt);
|
|
117
|
+
return { rows: Array.from(r) as any[], count: (r as any).count ?? 0 };
|
|
118
|
+
};
|
|
119
|
+
return runExecInTx(txRun, fileSql, { schemas: directSchemas(flags) });
|
|
120
|
+
}) as Promise<number[]>,
|
|
121
|
+
writeOutcome: async (id, outcome) => {
|
|
122
|
+
await audit(renderExecOutcomeUpdate(id, outcome));
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
reportOk(res);
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
fail(`db:exec failed: ${err.message}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
} finally {
|
|
130
|
+
await sqlc.end({ timeout: 5 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -26,9 +26,9 @@ import { fingerprintModels } from '../schema-fingerprint.js';
|
|
|
26
26
|
import { resolveModelsPath } from '../models-path.js';
|
|
27
27
|
import { loadModels } from './db-generate.js';
|
|
28
28
|
import { loadDeclaredDerived } from '../declared-derived.js';
|
|
29
|
-
import { pgDumpPreflightError } from './db-backup.js';
|
|
30
29
|
import { pgEnvFromUrl } from './db.js';
|
|
31
30
|
import { utcStamp } from '../backup.js';
|
|
31
|
+
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
32
32
|
import { step, success, fail, info } from '../output.js';
|
|
33
33
|
|
|
34
34
|
const fmtBytes = (b?: number): string =>
|
|
@@ -190,30 +190,41 @@ export async function dbExportCommand(flags: Record<string, string>): Promise<vo
|
|
|
190
190
|
process.exit(1);
|
|
191
191
|
}
|
|
192
192
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
193
|
+
const fn = opsFunction(config);
|
|
193
194
|
|
|
194
|
-
// The dump
|
|
195
|
-
//
|
|
196
|
-
step('
|
|
195
|
+
// The dump runs in the ephemeral Task lane (the ops Lambda holds no pg binaries). Dispatch the
|
|
196
|
+
// task and poll; the task's version handshake is the compatibility gate (no separate layer probe).
|
|
197
|
+
step('Dispatching the export task (credential-free, via the ops Lambda)...');
|
|
198
|
+
let dispatched: any;
|
|
197
199
|
try {
|
|
198
|
-
|
|
199
|
-
const preflightError = pgDumpPreflightError(probe);
|
|
200
|
-
if (preflightError) { fail(preflightError); process.exit(1); }
|
|
200
|
+
dispatched = await invokeAction(config.region, fn, 'db:export', { schema, stage: venue.stage, fingerprint, actor: process.env.USER ?? null });
|
|
201
201
|
} catch (err: any) {
|
|
202
|
-
fail(`Export
|
|
202
|
+
fail(`Export failed to dispatch: ${err.message}`);
|
|
203
203
|
process.exit(1);
|
|
204
204
|
}
|
|
205
|
+
if (dispatched?.error) {
|
|
206
|
+
fail(`Export failed: ${dispatched.error}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
const { runId, taskArn, id, warning } = dispatched as { runId: string; taskArn: string; id: string; warning?: string };
|
|
210
|
+
info(`artifact ${id} — task ${taskArn}`);
|
|
211
|
+
info(`run id: ${runId} (everystack.task_log)`);
|
|
212
|
+
if (warning) info(`note: ${warning}`);
|
|
205
213
|
|
|
206
214
|
step(`Running pg_dump --schema=${schema} → S3 (this may take a while for large schemas)...`);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
const poll = await pollTaskUntilStopped(config.region, fn, { runId, taskArn });
|
|
216
|
+
if (poll.outcome === 'timeout') {
|
|
217
|
+
fail(`db:export timed out (last status: ${poll.lastStatus}). Run id ${runId} — reconcile via ECS/everystack.task_log.`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
if (poll.outcome === 'error') {
|
|
221
|
+
fail(`task:status failed repeatedly: ${poll.status.error}. The export may still be running — reconcile run id ${runId}.`);
|
|
212
222
|
process.exit(1);
|
|
213
223
|
}
|
|
214
|
-
if (
|
|
215
|
-
fail(`Export failed
|
|
224
|
+
if (poll.status.exitCode !== 0) {
|
|
225
|
+
fail(`Export failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).`);
|
|
216
226
|
process.exit(1);
|
|
217
227
|
}
|
|
218
|
-
|
|
228
|
+
const bytes = poll.status.result?.bytes as number | undefined;
|
|
229
|
+
success(`Artifact ${id} (${fmtBytes(bytes)}, fingerprint ${fingerprint.slice(0, 12)}). Deploy with: everystack db:swap --schema ${schema} --from ${id} --stage <target> --direct --confirm`);
|
|
219
230
|
}
|