@everystack/cli 0.4.35 → 0.4.38

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.
@@ -0,0 +1,260 @@
1
+ /**
2
+ * db:swap's pre-swap rollback point — which snapshot to take, and proof that it exists.
3
+ *
4
+ * The swap's step 2 is "SNAPSHOT — the rollback point, before anything destructive". It was taking a
5
+ * LOGICAL backup (`db:backup` via the ops Lambda) and that turned out to be wrong twice over:
6
+ *
7
+ * - It never waited. The ops action DISPATCHES a Fargate task and returns the run id; the swap
8
+ * printed "snapshot on record" and started restoring immediately. So the pg_dump ran alongside
9
+ * the restore — a consumer measured the restore blocked ~5 minutes on
10
+ * `Lock/relation HELD BY pid [pg_dump] COPY <schema>.<table>`, the swap contending with its own
11
+ * backup. On a small instance that makes the swap's duration a coin flip unrelated to data volume.
12
+ * - Worse than slow: unproven. A task that failed to start, or died on its credential, left the
13
+ * swap proceeding into a destructive rename believing it had a rollback point it did not have.
14
+ *
15
+ * A PHYSICAL RDS snapshot is the better rollback point on every axis that matters here: it is a
16
+ * control-plane call, so it holds no relation locks, reads nothing through the buffer cache, and
17
+ * needs no client connection. It is also RDS-only, hence the mode selection below rather than a
18
+ * straight replacement.
19
+ *
20
+ * The decision is pure and the confirmation takes its IO injected, so both are provable without an
21
+ * AWS account. See docs/schema-swap.md.
22
+ */
23
+
24
+ /** The values `--snapshot` accepts. `none` is the explicit opt-out; there is no implicit one. */
25
+ export const SNAPSHOT_MODES = ['physical', 'logical', 'none'] as const;
26
+ export type SnapshotModeRequest = (typeof SNAPSHOT_MODES)[number];
27
+
28
+ /** What db:swap should do about a rollback point before it touches anything. */
29
+ export type SnapshotDecision =
30
+ /** Take an RDS snapshot of this instance (no locks, no pg_dump, no connection). */
31
+ | { mode: 'physical'; instanceId: string }
32
+ /** Dispatch db:backup and WAIT for the task to finish before restoring. */
33
+ | { mode: 'logical' }
34
+ /** The operator already took one and named it — nothing to do but record it. */
35
+ | { mode: 'attested'; ref: string }
36
+ /** The operator explicitly accepted no rollback point. */
37
+ | { mode: 'none' }
38
+ /** Nothing safe is available and no consent was given — refuse before anything moves. */
39
+ | { mode: 'refuse'; reason: string };
40
+
41
+ export interface SnapshotDecisionInput {
42
+ /**
43
+ * `stage` = `--stage <name> --direct`, which has an ops Lambda and deployed outputs behind it.
44
+ * `url` = a bare `--database-url`, which has neither: no stage config to read an instance id from
45
+ * and no ops function to dispatch a backup to.
46
+ */
47
+ venue: 'stage' | 'url';
48
+ /** `config.databaseInstanceId`. `placeholder` is what a non-RDS or dev-mode stage carries. */
49
+ instanceId?: string;
50
+ /** `--snapshot-ref <id>` — a snapshot the operator took themselves (db:apply's precedent). */
51
+ snapshotRef?: string;
52
+ /** `--snapshot <physical|logical|none>`. */
53
+ requested?: SnapshotModeRequest;
54
+ }
55
+
56
+ /** Is this instance id something we can actually snapshot, or an absent/placeholder output? */
57
+ function usableInstanceId(id?: string): id is string {
58
+ return !!id && id !== 'placeholder';
59
+ }
60
+
61
+ /**
62
+ * Choose the rollback point.
63
+ *
64
+ * Precedence, and the reasoning for it:
65
+ * 1. `--snapshot-ref` — the operator attests one exists. Taking a second is waste, and this is the
66
+ * shape db:apply's direct lane already uses, so the two destructive verbs read the same.
67
+ * 2. `--snapshot none` — informed consent to have no rollback point. Explicit only.
68
+ * 3. `--snapshot physical|logical` — an explicit choice, honoured or refused with the reason.
69
+ * 4. The default: physical on an RDS stage, logical on any other stage, refuse on a bare URL.
70
+ *
71
+ * The bare-URL default is a REFUSAL rather than a warning. It used to warn and carry on, which is
72
+ * the same class of hole as the `--stage` bypass 0.4.36 closed: a destructive verb whose safety step
73
+ * is optional in practice.
74
+ */
75
+ export function decideSnapshotMode(input: SnapshotDecisionInput): SnapshotDecision {
76
+ const { venue, instanceId, snapshotRef, requested } = input;
77
+
78
+ if (requested !== undefined && !SNAPSHOT_MODES.includes(requested)) {
79
+ return {
80
+ mode: 'refuse',
81
+ reason: `--snapshot ${requested} is not a snapshot mode. Pass one of: ${SNAPSHOT_MODES.join(', ')} `
82
+ + `(physical = an RDS snapshot, logical = a db:backup pg_dump, none = explicitly no rollback point).`,
83
+ };
84
+ }
85
+
86
+ if (snapshotRef) return { mode: 'attested', ref: snapshotRef };
87
+ if (requested === 'none') return { mode: 'none' };
88
+
89
+ if (requested === 'physical') {
90
+ // A physical snapshot is an RDS control-plane call, which needs a REGION as well as an instance
91
+ // id — and the only source of a region here is the stage's deployed config. A bare
92
+ // --database-url has none, so honouring the request would mean calling AWS with an undefined
93
+ // region and reporting a confusing SDK error instead of the real problem.
94
+ if (venue === 'url') {
95
+ return {
96
+ mode: 'refuse',
97
+ reason: 'A physical snapshot needs the stage\'s region and instance id, and a bare --database-url carries neither. '
98
+ + 'Take one against the stage (everystack db:snapshot --stage <name>) and attest it here: --snapshot-ref <id>. '
99
+ + 'Or accept the risk explicitly with --snapshot none.',
100
+ };
101
+ }
102
+ if (!usableInstanceId(instanceId)) {
103
+ return {
104
+ mode: 'refuse',
105
+ reason: 'A physical snapshot needs the RDS instance id, and this stage does not expose one. '
106
+ + 'Add `databaseInstanceId: database.id` to the outputs return block in sst.config.ts and redeploy '
107
+ + '(run db:swap from the app directory so .sst/outputs.json is readable), or pass --snapshot logical '
108
+ + 'for a pg_dump rollback point instead.',
109
+ };
110
+ }
111
+ return { mode: 'physical', instanceId };
112
+ }
113
+
114
+ if (requested === 'logical') {
115
+ if (venue === 'url') {
116
+ return {
117
+ mode: 'refuse',
118
+ reason: 'A logical snapshot runs in the stage\'s Task lane via the ops Lambda, and a bare --database-url has no stage behind it. '
119
+ + 'Take one yourself and name it: --snapshot-ref <id> (everystack db:backup --database-url … or db:snapshot), '
120
+ + 'or accept the risk explicitly with --snapshot none.',
121
+ };
122
+ }
123
+ return { mode: 'logical' };
124
+ }
125
+
126
+ // No explicit request — the default per venue.
127
+ if (venue === 'stage') {
128
+ return usableInstanceId(instanceId) ? { mode: 'physical', instanceId } : { mode: 'logical' };
129
+ }
130
+ return {
131
+ mode: 'refuse',
132
+ reason: 'db:swap over a bare --database-url does not take a snapshot for you, and it will not run destructively without one. '
133
+ + 'Take a rollback point and name it: --snapshot-ref <id> (everystack db:snapshot, or db:backup --database-url …). '
134
+ + 'If you genuinely want no rollback point, say so: --snapshot none.',
135
+ };
136
+ }
137
+
138
+ /**
139
+ * A finished `pollTaskUntilStopped` result, read as "is there a rollback point or not".
140
+ *
141
+ * Kept pure and separate because this is the judgement the old code never made: it treated the
142
+ * DISPATCH as the answer. Every non-success outcome here has to abort the swap, and each one needs
143
+ * different advice, so the wording is worth pinning in a test.
144
+ */
145
+ export function interpretBackupPoll(
146
+ poll:
147
+ | { outcome: 'timeout'; lastStatus: string }
148
+ | { outcome: 'error'; status: { error?: string } }
149
+ | { outcome: 'stopped'; status: { exitCode?: number | null; stoppedReason?: string | null } },
150
+ ids: { runId: string; id: string },
151
+ ): { ok: true } | { ok: false; reason: string } {
152
+ const untouched = 'so the swap was NOT applied and live is untouched.';
153
+ if (poll.outcome === 'timeout') {
154
+ return {
155
+ ok: false,
156
+ reason: `the pre-swap backup did not finish in time (last status: ${poll.lastStatus}), ${untouched} `
157
+ + `Run id ${ids.runId} — check everystack.task_log / ECS, then re-run with --snapshot-ref ${ids.id} once the backup is on record.`,
158
+ };
159
+ }
160
+ if (poll.outcome === 'error') {
161
+ return {
162
+ ok: false,
163
+ reason: `the pre-swap backup's status could not be read (${poll.status.error}), so the swap was NOT applied. `
164
+ + `It may still be running — reconcile run id ${ids.runId} before retrying.`,
165
+ };
166
+ }
167
+ if (poll.status.exitCode !== 0) {
168
+ return {
169
+ ok: false,
170
+ reason: `the pre-swap backup FAILED (exit ${poll.status.exitCode ?? 'unknown'})`
171
+ + `${poll.status.stoppedReason ? ` — ${poll.status.stoppedReason}` : ''}, ${untouched} `
172
+ + `Read the task logs (CloudWatch) before retrying.`,
173
+ };
174
+ }
175
+ return { ok: true };
176
+ }
177
+
178
+ /** The RDS control-plane calls confirmPhysicalSnapshot needs, injected so the wait is testable. */
179
+ export interface PhysicalSnapshotIO {
180
+ /** CreateDBSnapshot — returns the new snapshot's identifier and initial status. */
181
+ create: (snapshotId: string) => Promise<{ identifier: string; status: string }>;
182
+ /** DescribeDBSnapshots for the instance (manual snapshots). */
183
+ describe: () => Promise<Array<{ identifier: string; status: string }>>;
184
+ log: (msg: string) => void;
185
+ sleep: (ms: number) => Promise<void>;
186
+ now: () => number;
187
+ }
188
+
189
+ export interface ConfirmPhysicalOptions {
190
+ instanceId: string;
191
+ snapshotId: string;
192
+ /** How long to wait for `available` before refusing. */
193
+ deadlineMs?: number;
194
+ /** How often to re-read the status. */
195
+ pollIntervalMs?: number;
196
+ }
197
+
198
+ /**
199
+ * A snapshot's status is polled to a TERMINAL state before the swap is allowed to continue. 15
200
+ * minutes is generous for a dev-sized instance and short enough that a stuck snapshot surfaces as a
201
+ * refusal rather than an hour of silence.
202
+ */
203
+ const DEFAULT_SNAPSHOT_DEADLINE_MS = 15 * 60_000;
204
+ const DEFAULT_SNAPSHOT_POLL_MS = 10_000;
205
+
206
+ /**
207
+ * Take an RDS snapshot and do not return until RDS says it is `available`.
208
+ *
209
+ * Why wait at all, when the snapshot's consistency point is fixed the moment CreateDBSnapshot is
210
+ * accepted: because "accepted" is not "exists". A snapshot can go to `failed` (instance state,
211
+ * storage), and the entire value of this step is that the operator can get back. Proceeding into a
212
+ * destructive rename on an unconfirmed rollback point is the defect this module was written to
213
+ * remove — waiting is the only thing that turns the printed id into a fact.
214
+ *
215
+ * On the deadline it THROWS naming the snapshot id, because the re-run is one flag: the snapshot is
216
+ * still coming, so `--snapshot-ref <id>` reuses it instead of starting another.
217
+ */
218
+ export async function confirmPhysicalSnapshot(
219
+ io: PhysicalSnapshotIO,
220
+ opts: ConfirmPhysicalOptions,
221
+ ): Promise<{ id: string }> {
222
+ const deadlineMs = opts.deadlineMs ?? DEFAULT_SNAPSHOT_DEADLINE_MS;
223
+ const pollMs = opts.pollIntervalMs ?? DEFAULT_SNAPSHOT_POLL_MS;
224
+ const created = await io.create(opts.snapshotId);
225
+ const id = created.identifier;
226
+ io.log(`physical snapshot ${id} of ${opts.instanceId} — status ${created.status}.`);
227
+ if (created.status === 'available') return { id };
228
+ if (created.status === 'failed') {
229
+ throw new Error(`the pre-swap RDS snapshot ${id} failed immediately, so the swap was NOT applied and live is untouched.`);
230
+ }
231
+
232
+ const start = io.now();
233
+ let lastStatus = created.status;
234
+ for (;;) {
235
+ if (io.now() - start >= deadlineMs) {
236
+ throw new Error(
237
+ `the pre-swap RDS snapshot ${id} is still "${lastStatus}" after ${Math.round(deadlineMs / 60_000)} minutes, so the swap was NOT applied and live is untouched. `
238
+ + `The snapshot is still being taken — it is a real rollback point once it reaches available. `
239
+ + `Watch it with \`everystack db:snapshots\`, then re-run this swap with --snapshot-ref ${id} to reuse it instead of taking another.`,
240
+ );
241
+ }
242
+ await io.sleep(pollMs);
243
+ const snaps = await io.describe();
244
+ const mine = snaps.find((s) => s.identifier === id);
245
+ if (!mine) {
246
+ throw new Error(
247
+ `the pre-swap RDS snapshot ${id} is no longer listed on ${opts.instanceId} — it was deleted or never registered, so there is no rollback point. `
248
+ + `The swap was NOT applied and live is untouched.`,
249
+ );
250
+ }
251
+ if (mine.status !== lastStatus) {
252
+ lastStatus = mine.status;
253
+ io.log(`physical snapshot ${id}: ${lastStatus}`);
254
+ }
255
+ if (mine.status === 'available') return { id };
256
+ if (mine.status === 'failed') {
257
+ throw new Error(`the pre-swap RDS snapshot ${id} FAILED, so the swap was NOT applied and live is untouched. Check the instance's state and storage, then retry.`);
258
+ }
259
+ }
260
+ }