@everystack/cli 0.4.59 → 0.4.60
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 +2 -2
- package/src/cli/commands/db-backup.ts +19 -30
- package/src/cli/commands/db-fork.ts +10 -10
- package/src/cli/commands/db-swap.ts +24 -12
- package/src/cli/stage-backup.ts +229 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.60",
|
|
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>",
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"jest": "29.7.0",
|
|
161
161
|
"react": "19.2.0",
|
|
162
162
|
"ts-jest": "29.4.9",
|
|
163
|
-
"@everystack/server": "0.4.
|
|
163
|
+
"@everystack/server": "0.4.21"
|
|
164
164
|
},
|
|
165
165
|
"scripts": {
|
|
166
166
|
"test": "jest",
|
|
@@ -21,6 +21,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
23
23
|
import { invokeAction, presignGet, getS3 } from '../aws.js';
|
|
24
|
+
import { runStageBackup, stageBackupDeps } from '../stage-backup.js';
|
|
24
25
|
import {
|
|
25
26
|
parseBackupRef, keyForId, crossStageGuard, restoreTargetGuard,
|
|
26
27
|
backupKey, backupId, metaKey, utcStamp,
|
|
@@ -226,39 +227,27 @@ export async function dbBackupCommand(flags: Record<string, string>): Promise<vo
|
|
|
226
227
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
227
228
|
const fn = opsFunction(config);
|
|
228
229
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
if (dispatched?.error) {
|
|
238
|
-
fail(`Backup failed: ${dispatched.error}`);
|
|
239
|
-
process.exit(1);
|
|
240
|
-
}
|
|
241
|
-
const { runId, taskArn, id, warning } = dispatched as { runId: string; taskArn: string; id: string; warning?: string };
|
|
242
|
-
info(`backup ${id} — task ${taskArn}`);
|
|
243
|
-
info(`run id: ${runId} (everystack.task_log)`);
|
|
244
|
-
if (warning) info(`note: ${warning}`);
|
|
230
|
+
const result = await runStageBackup(stageBackupDeps(config, fn, flags.stage, {
|
|
231
|
+
onDispatch: (d) => {
|
|
232
|
+
info(`backup ${d.id} — task ${d.taskArn}`);
|
|
233
|
+
info(`run id: ${d.runId} (everystack.task_log)`);
|
|
234
|
+
// The dispatch warning is folded into result.warning and printed at the end, so printing it
|
|
235
|
+
// here too says the same thing twice.
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
245
238
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
if (poll.outcome === 'error') {
|
|
253
|
-
fail(`task:status failed repeatedly: ${poll.status.error}. The backup may still be running — reconcile run id ${runId}.`);
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
if (poll.status.exitCode !== 0) {
|
|
257
|
-
fail(`Backup failed (exit ${poll.status.exitCode ?? 'unknown'})${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).`);
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
fail(`Backup failed: ${result.reason}`);
|
|
241
|
+
if (result.orphanKey) {
|
|
242
|
+
info(`The artifact is still in the bucket: s3://${config.backupsBucket ?? '<backups-bucket>'}/${result.orphanKey}`);
|
|
243
|
+
}
|
|
258
244
|
process.exit(1);
|
|
259
245
|
}
|
|
260
|
-
|
|
261
|
-
|
|
246
|
+
|
|
247
|
+
// Render it. The helper produced this and nothing printed it, so the one signal that an artifact
|
|
248
|
+
// was not verified against what the operator sent was invisible at the only moment it mattered.
|
|
249
|
+
if (result.warning) warn(result.warning);
|
|
250
|
+
success(`Backup ${result.id} (${fmtBytes(result.bytes)}). Restore with: everystack db:restore --from ${result.id} --confirm`);
|
|
262
251
|
}
|
|
263
252
|
|
|
264
253
|
/** db:backups — list a stage's logical backups. */
|
|
@@ -23,6 +23,7 @@ import { resolveConfig, opsFunction, type CliConfig } from '../config.js';
|
|
|
23
23
|
import { invokeAction, presignGet } from '../aws.js';
|
|
24
24
|
import { keyForId, parseBackupRef, isProductionTier, crossStageGuard } from '../backup.js';
|
|
25
25
|
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
26
|
+
import { runStageBackup, stageBackupDeps } from '../stage-backup.js';
|
|
26
27
|
import { step, success, fail, info, warn } from '../output.js';
|
|
27
28
|
|
|
28
29
|
export type ForkGuardVerdict =
|
|
@@ -94,18 +95,17 @@ export async function dbForkCommand(flags: Record<string, string>): Promise<void
|
|
|
94
95
|
info(`Reusing backup ${backupId}.`);
|
|
95
96
|
} else {
|
|
96
97
|
step(`Backing up ${from} (pg_dump → S3 in the Task; may take a while for large databases)...`);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (poll.outcome !== 'stopped' || poll.status.exitCode !== 0) {
|
|
105
|
-
const why = poll.outcome === 'stopped' ? `exit ${poll.status.exitCode}${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}` : poll.outcome;
|
|
106
|
-
fail(`Source backup failed (${why}). Reconcile run ${dispatched.runId}.`);
|
|
98
|
+
// The shared sandwich: a fork built from a dump that spans a migration copies a schema that
|
|
99
|
+
// never existed. Same helper db:backup and db:swap use, so all three agree on what a proven
|
|
100
|
+
// artifact is.
|
|
101
|
+
const backup = await runStageBackup(stageBackupDeps(source, opsFunction(source), from, { onStep: step }));
|
|
102
|
+
if (!backup.ok) {
|
|
103
|
+
fail(`Source backup failed: ${backup.reason} The fork was NOT created.`);
|
|
104
|
+
if (backup.orphanKey) info(`The artifact is still in the bucket: s3://${source.backupsBucket}/${backup.orphanKey}`);
|
|
107
105
|
process.exit(1);
|
|
108
106
|
}
|
|
107
|
+
backupId = backup.id;
|
|
108
|
+
if (backup.warning) warn(backup.warning);
|
|
109
109
|
info(`Backup ${backupId}.`);
|
|
110
110
|
}
|
|
111
111
|
|
|
@@ -45,6 +45,7 @@ import { keyForArtifactId, metaKey, utcStamp } from '../backup.js';
|
|
|
45
45
|
import { rdsSnapshotIdentifier } from '../rds-snapshot.js';
|
|
46
46
|
import { pollTaskUntilStopped } from '../task-poll.js';
|
|
47
47
|
import { decideSnapshotMode, confirmPhysicalSnapshot, interpretBackupPoll, type SnapshotModeRequest } from '../swap-snapshot.js';
|
|
48
|
+
import { runStageBackup, stageBackupDeps } from '../stage-backup.js';
|
|
48
49
|
import { pgEnvFromUrl, pgKeepaliveConninfo } from './db.js';
|
|
49
50
|
import { step, success, fail, warn, info } from '../output.js';
|
|
50
51
|
|
|
@@ -475,19 +476,30 @@ async function takePreSwapSnapshot(
|
|
|
475
476
|
// Logical: dispatch the Task and WAIT. The dispatch returning is not the backup existing — that
|
|
476
477
|
// conflation is what let a pg_dump run concurrently with the restore it was supposed to precede.
|
|
477
478
|
step('Snapshotting the stage before the swap (db:backup — waiting for the dump to finish)...');
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
479
|
+
// The shared sandwich. A rollback point taken across a concurrent migration holds a schema that
|
|
480
|
+
// matches neither endpoint — restoring from it would not return the stage to where it was, which
|
|
481
|
+
// is the one thing a pre-swap snapshot has to guarantee. An unproven artifact fails the swap.
|
|
482
|
+
const backup = await runStageBackup({
|
|
483
|
+
...stageBackupDeps({ region: ctx.region! }, ctx.opsFn!, ctx.stage, {
|
|
484
|
+
onStep: step,
|
|
485
|
+
onDispatch: (d: { id: string; runId: string }) => info(`backup ${d.id} dispatched (run ${d.runId}) — waiting for the task to stop before the restore starts.`),
|
|
486
|
+
}),
|
|
487
|
+
// Swap keeps its OWN reading of a finished poll: it is the only caller that can tell the
|
|
488
|
+
// operator to re-run with `--snapshot-ref <id>` once the dump lands, and each outcome needs
|
|
489
|
+
// different advice. The shared helper takes injected deps for exactly this.
|
|
490
|
+
awaitTask: async (ids) => {
|
|
491
|
+
const poll = await pollTaskUntilStopped(ctx.region!, ctx.opsFn!, { runId: ids.runId, taskArn: ids.taskArn });
|
|
492
|
+
const verdict = interpretBackupPoll(poll, { runId: ids.runId, id: ids.id });
|
|
493
|
+
if (!verdict.ok) return verdict;
|
|
494
|
+
// Carry the stamp through, or this caller silently opts out of the image-skew check.
|
|
495
|
+
return { ok: true, stampedFingerprint: (poll as any).status?.result?.fingerprint as string | undefined };
|
|
496
|
+
},
|
|
481
497
|
});
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
{ runId, id },
|
|
488
|
-
);
|
|
489
|
-
if (!verdict.ok) throw new Error(verdict.reason);
|
|
490
|
-
info(`rollback point CONFIRMED: backup ${id} complete — restore with db:restore --from ${id} --confirm.`);
|
|
498
|
+
if (!backup.ok) {
|
|
499
|
+
throw new Error(`the pre-swap backup is not a usable rollback point, so the swap was NOT applied and live is untouched: ${backup.reason}`);
|
|
500
|
+
}
|
|
501
|
+
if (backup.warning) warn(backup.warning);
|
|
502
|
+
info(`rollback point CONFIRMED: backup ${backup.id} complete — restore with db:restore --from ${backup.id} --confirm.`);
|
|
491
503
|
}
|
|
492
504
|
|
|
493
505
|
export async function dbSwapCommand(flags: Record<string, string>): Promise<void> {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fingerprint sandwich around a stage backup — ONE implementation, three callers.
|
|
3
|
+
*
|
|
4
|
+
* A `db:backup` artifact carries a live base-schema fingerprint in its `.meta.json`, and the
|
|
5
|
+
* destructive-apply gate compares it against `plan.from`. That comparison is only meaningful if the
|
|
6
|
+
* fingerprint describes the schema the dump ACTUALLY holds. A dump taken across a concurrent
|
|
7
|
+
* migration holds a schema matching NEITHER endpoint, so its stamped fingerprint is a lie the gate
|
|
8
|
+
* would later trust. The sandwich reads the schema before the dump and again after it, and keeps
|
|
9
|
+
* the artifact only if the two agree.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS LIVES IN THE CLI: it used to run inside the task image, which imported
|
|
12
|
+
* `@everystack/cli/apply` to do it. The image is published and digest-pinned, so that froze a copy
|
|
13
|
+
* of the very CLI internals the gate depends on while the operator's checkout kept moving — a
|
|
14
|
+
* silent version skew on a safety-critical input. Here it runs from the operator's own checkout,
|
|
15
|
+
* the same code `db:apply` uses, so the two can never disagree about what a fingerprint means.
|
|
16
|
+
*
|
|
17
|
+
* WHY IT IS SHARED: `fingerprint` is a REQUIRED field on the backup job. Three commands dispatch
|
|
18
|
+
* backups (db:backup, db:fork, db:swap --snapshot logical), and when the field was added to one of
|
|
19
|
+
* them the other two silently broke. One implementation, one contract.
|
|
20
|
+
*
|
|
21
|
+
* The dispatch/poll/retract wiring is dependency-injected so the whole decision table is testable
|
|
22
|
+
* without AWS.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { invokeAction, lambdaSessionRunner } from './aws.js';
|
|
26
|
+
import { pollTaskUntilStopped } from './task-poll.js';
|
|
27
|
+
|
|
28
|
+
/** The verdict on a completed dump's fingerprint pair. Pure — the heart of the sandwich. */
|
|
29
|
+
export type SandwichVerdict = { ok: true } | { ok: false; reason: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Did the schema hold still across the dump window?
|
|
33
|
+
*
|
|
34
|
+
* Agreement proves no DDL landed ANYWHERE between the two reads, so the fingerprint genuinely
|
|
35
|
+
* describes the dump's schema. Disagreement means the artifact is unusable as a safety net —
|
|
36
|
+
* better a refused backup than a false one.
|
|
37
|
+
*/
|
|
38
|
+
export function judgeSandwich(fpBefore: string, fpAfter: string): SandwichVerdict {
|
|
39
|
+
if (fpBefore === fpAfter) return { ok: true };
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
reason:
|
|
43
|
+
`the schema changed during the dump (${fpBefore.slice(0, 12)} → ${fpAfter.slice(0, 12)}), `
|
|
44
|
+
+ 'so the backup spans a migration and its fingerprint cannot be proven. Re-run when no migration is concurrent.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StageBackupDeps {
|
|
49
|
+
/** Read the live base-schema fingerprint. Called twice, bracketing the dump. */
|
|
50
|
+
readFingerprint: () => Promise<string>;
|
|
51
|
+
/** Dispatch `db:backup` with the operator-computed fingerprint. */
|
|
52
|
+
dispatch: (fingerprint: string) => Promise<{ runId: string; taskArn: string; id: string; key: string; warning?: string }>;
|
|
53
|
+
/**
|
|
54
|
+
* Wait for the task to stop; resolve ok, or a human reason it did not.
|
|
55
|
+
*
|
|
56
|
+
* `stampedFingerprint` is what the task reported writing into the artifact's `.meta.json`. It is
|
|
57
|
+
* the evidence the image honored the job — see the skew check in runStageBackup.
|
|
58
|
+
*/
|
|
59
|
+
awaitTask: (ids: { runId: string; taskArn: string; id: string }) => Promise<{ ok: true; bytes?: number; stampedFingerprint?: string } | { ok: false; reason: string }>;
|
|
60
|
+
/** Delete a retracted artifact (the ops Lambda's db:backup:retract). */
|
|
61
|
+
retract: (key: string) => Promise<void>;
|
|
62
|
+
/** Progress output. Optional so tests stay quiet. */
|
|
63
|
+
onStep?: (message: string) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type StageBackupResult =
|
|
67
|
+
| { ok: true; id: string; key: string; bytes?: number; fingerprint: string; warning?: string }
|
|
68
|
+
| { ok: false; reason: string; retracted?: boolean; orphanKey?: string };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Take a stage backup whose fingerprint is PROVEN stable, or refuse.
|
|
72
|
+
*
|
|
73
|
+
* Never throws for an expected failure — every outcome is a verdict the caller renders, so the
|
|
74
|
+
* three commands can word their own consequences ("the swap was NOT applied", "the fork stopped")
|
|
75
|
+
* without re-deriving the logic.
|
|
76
|
+
*/
|
|
77
|
+
export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackupResult> {
|
|
78
|
+
const step = deps.onStep ?? (() => {});
|
|
79
|
+
|
|
80
|
+
step('Reading the live schema fingerprint (before the dump)...');
|
|
81
|
+
let fpBefore: string;
|
|
82
|
+
try {
|
|
83
|
+
fpBefore = await deps.readFingerprint();
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
// Refuse BEFORE dispatching: a dump whose fingerprint cannot be proven is not a safety net, and
|
|
86
|
+
// taking one anyway would burn the dump window and still leave nothing trustworthy.
|
|
87
|
+
return { ok: false, reason: `the schema fingerprint could not be read, so no backup was dispatched: ${err?.message ?? String(err)}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let dispatched: { runId: string; taskArn: string; id: string; key: string; warning?: string };
|
|
91
|
+
try {
|
|
92
|
+
dispatched = await deps.dispatch(fpBefore);
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
return { ok: false, reason: `the backup would not dispatch: ${err?.message ?? String(err)}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const finished = await deps.awaitTask(dispatched);
|
|
98
|
+
if (!finished.ok) return { ok: false, reason: finished.reason };
|
|
99
|
+
|
|
100
|
+
// Did the image actually USE the fingerprint we sent?
|
|
101
|
+
//
|
|
102
|
+
// This is the version-skew guard, and it deliberately does not rely on the image cooperating. An
|
|
103
|
+
// older task image ignores the job's `fingerprint` field entirely and computes its own, stamping
|
|
104
|
+
// the manifest with a value this CLI never produced — the exact stale-internals problem the
|
|
105
|
+
// operator-side move exists to remove, arriving through a pinned digest nobody re-published. A
|
|
106
|
+
// protocol handshake cannot catch that, because an old image does not know to check it. Comparing
|
|
107
|
+
// what came back against what we sent works against ANY image, old or new.
|
|
108
|
+
if (finished.stampedFingerprint !== undefined && finished.stampedFingerprint !== fpBefore) {
|
|
109
|
+
step('Retracting the backup (the task stamped a fingerprint we did not send)...');
|
|
110
|
+
const mismatch =
|
|
111
|
+
`the task stamped fingerprint ${finished.stampedFingerprint.slice(0, 12)} but the operator sent `
|
|
112
|
+
+ `${fpBefore.slice(0, 12)} — the task image computed its own instead of using the job's, which means it `
|
|
113
|
+
+ 'predates the operator-side fingerprint contract. Re-publish the Task image (or pin a newer digest).';
|
|
114
|
+
try {
|
|
115
|
+
await deps.retract(dispatched.key);
|
|
116
|
+
return { ok: false, reason: `${mismatch} Backup ${dispatched.id} was retracted.`, retracted: true };
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
reason: `${mismatch} Backup ${dispatched.id} could ALSO not be retracted (${err?.message ?? String(err)}) — delete it by hand.`,
|
|
121
|
+
retracted: false,
|
|
122
|
+
orphanKey: dispatched.key,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
step('Re-reading the schema fingerprint (after the dump)...');
|
|
128
|
+
let fpAfter: string;
|
|
129
|
+
try {
|
|
130
|
+
fpAfter = await deps.readFingerprint();
|
|
131
|
+
} catch (err: any) {
|
|
132
|
+
// The dump exists but is unproven. Do NOT retract — an unreadable fingerprint is not evidence
|
|
133
|
+
// the schema moved, and silently destroying a good backup is the worse error.
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
reason: `the dump finished but the schema fingerprint could not be re-read (${err?.message ?? String(err)}), so backup ${dispatched.id} is NOT proven stable. Verify no migration ran during the dump, or re-run.`,
|
|
137
|
+
orphanKey: dispatched.key,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const verdict = judgeSandwich(fpBefore, fpAfter);
|
|
142
|
+
if (verdict.ok) {
|
|
143
|
+
// A task that reported no fingerprint at all is not the skew case above — the result-ledger
|
|
144
|
+
// write is explicitly best-effort, so a blip there loses the whole result row. Failing the
|
|
145
|
+
// backup for that would turn a cosmetic outage into a lost safety net, so say it and continue.
|
|
146
|
+
const unverified = finished.stampedFingerprint === undefined
|
|
147
|
+
? 'the task did not report the fingerprint it stamped, so the artifact could not be verified against what the operator sent (the result-ledger write is best-effort)'
|
|
148
|
+
: undefined;
|
|
149
|
+
const warning = [dispatched.warning, unverified].filter(Boolean).join('; ') || undefined;
|
|
150
|
+
return { ok: true, id: dispatched.id, key: dispatched.key, bytes: finished.bytes, fingerprint: fpBefore, warning };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
step('Retracting the backup (its fingerprint cannot be proven)...');
|
|
154
|
+
try {
|
|
155
|
+
await deps.retract(dispatched.key);
|
|
156
|
+
return { ok: false, reason: `${verdict.reason} Backup ${dispatched.id} was retracted.`, retracted: true };
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
// The artifact outlived the failure. Say so loudly with the key — an unproven backup left in
|
|
159
|
+
// the bucket is exactly what the destructive-apply gate must never pick up.
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
reason: `${verdict.reason} Backup ${dispatched.id} could ALSO not be retracted (${err?.message ?? String(err)}) — delete it by hand so the destructive-apply gate never trusts it.`,
|
|
163
|
+
retracted: false,
|
|
164
|
+
orphanKey: dispatched.key,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Bind the sandwich to a real stage: the ops Lambda supplies the session the fingerprint is read
|
|
171
|
+
* over, dispatches the task, reports status, and performs the retraction — so the operator never
|
|
172
|
+
* holds a database URL or an S3 credential for any of it.
|
|
173
|
+
*
|
|
174
|
+
* The ONE place the wiring is written. A caller that hand-rolled this is how `fingerprint` became
|
|
175
|
+
* required in db:backup while db:fork and db:swap kept dispatching without it.
|
|
176
|
+
*/
|
|
177
|
+
export function stageBackupDeps(
|
|
178
|
+
config: { region: string },
|
|
179
|
+
opsFn: string,
|
|
180
|
+
stage: string | undefined,
|
|
181
|
+
hooks: { onStep?: (message: string) => void; onDispatch?: (d: { runId: string; taskArn: string; id: string; warning?: string }) => void } = {},
|
|
182
|
+
): StageBackupDeps {
|
|
183
|
+
return {
|
|
184
|
+
// BOTH ends of the sandwich read over this ONE closure, so they cannot land on different lanes.
|
|
185
|
+
// That matters: a before-read over a direct session and an after-read over the two-invoke
|
|
186
|
+
// ops-Lambda blend could manufacture a false mismatch — or, worse, a false match. The symmetry
|
|
187
|
+
// is structural here rather than asserted; if a caller ever needs to override one end, it has to
|
|
188
|
+
// override both.
|
|
189
|
+
readFingerprint: async () => {
|
|
190
|
+
// The SAME helper db:apply's concurrency lock uses — that shared definition is the point.
|
|
191
|
+
const { liveBaseFingerprint } = await import('./apply-execute.js');
|
|
192
|
+
return (await liveBaseFingerprint(lambdaSessionRunner(config.region, opsFn))).hash;
|
|
193
|
+
},
|
|
194
|
+
dispatch: async (fingerprint) => {
|
|
195
|
+
const dispatched: any = await invokeAction(config.region, opsFn, 'db:backup', {
|
|
196
|
+
stage, actor: process.env.USER ?? null, fingerprint,
|
|
197
|
+
});
|
|
198
|
+
if (dispatched?.error) throw new Error(dispatched.error);
|
|
199
|
+
hooks.onDispatch?.(dispatched);
|
|
200
|
+
return dispatched;
|
|
201
|
+
},
|
|
202
|
+
awaitTask: async (ids) => {
|
|
203
|
+
const poll = await pollTaskUntilStopped(config.region, opsFn, { runId: ids.runId, taskArn: ids.taskArn });
|
|
204
|
+
if (poll.outcome === 'timeout') {
|
|
205
|
+
return { ok: false, reason: `the dump did not finish in time (last status: ${poll.lastStatus}). Run id ${ids.runId} — reconcile via ECS / everystack.task_log.` };
|
|
206
|
+
}
|
|
207
|
+
if (poll.outcome === 'error') {
|
|
208
|
+
return { ok: false, reason: `the task status could not be read (${poll.status.error}). The dump may still be running — reconcile run id ${ids.runId}.` };
|
|
209
|
+
}
|
|
210
|
+
if (poll.status.exitCode !== 0) {
|
|
211
|
+
return { ok: false, reason: `the dump exited ${poll.status.exitCode ?? 'unknown'}${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}. Read the task logs (CloudWatch).` };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
bytes: poll.status.result?.bytes as number | undefined,
|
|
216
|
+
// What the task says it wrote into the artifact's .meta.json — the skew evidence.
|
|
217
|
+
stampedFingerprint: poll.status.result?.fingerprint as string | undefined,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
retract: async (key) => {
|
|
221
|
+
// `stage` scopes what the action is willing to delete — it refuses any key outside this
|
|
222
|
+
// stage's backup prefix, so a retraction cannot reach an export artifact or a restore intent.
|
|
223
|
+
const res: any = await invokeAction(config.region, opsFn, 'db:backup:retract', { key, stage });
|
|
224
|
+
if (res?.error) throw new Error(res.error);
|
|
225
|
+
if (!res?.retracted) throw new Error(`the ops function did not confirm the retraction (returned ${JSON.stringify(res)})`);
|
|
226
|
+
},
|
|
227
|
+
onStep: hooks.onStep,
|
|
228
|
+
};
|
|
229
|
+
}
|