@everystack/cli 0.4.36 → 0.4.39
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/bin/everystack.mjs +52 -0
- package/package.json +5 -4
- package/src/cli/authz-compile.ts +71 -7
- package/src/cli/authz-derive.ts +210 -0
- package/src/cli/authz-redteam.ts +132 -9
- package/src/cli/commands/db-authz.ts +112 -40
- package/src/cli/commands/db-fingerprint.ts +19 -2
- package/src/cli/commands/db-pull.ts +30 -5
- package/src/cli/commands/db-swap.ts +122 -21
- package/src/cli/commands/db.ts +11 -0
- package/src/cli/db-source.ts +67 -16
- package/src/cli/derived-apply.ts +15 -1
- package/src/cli/derived-compile.ts +11 -1
- package/src/cli/derived-introspect.ts +36 -8
- package/src/cli/derived-plan.ts +53 -0
- package/src/cli/derived-render.ts +65 -5
- package/src/cli/index.ts +6 -5
- package/src/cli/model-render.ts +26 -5
- package/src/cli/pg-argtypes.ts +52 -0
- package/src/cli/swap-heartbeat.ts +106 -17
- package/src/cli/swap-snapshot.ts +260 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pg-argtypes — function identity is name + ARGUMENT TYPES.
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL identifies a function by `schema.name(argtypes)`, not by name. The derived
|
|
5
|
+
* layer keyed on `schema.name` alone, so two overloads collapsed onto one identity: the
|
|
6
|
+
* generated `DROP FUNCTION IF EXISTS api.get_user` errored ("function name is not
|
|
7
|
+
* unique"), `derived_provenance` (identity is its PRIMARY KEY) held one row for two live
|
|
8
|
+
* objects so drift could never see the second, and db:pull rendered duplicate consts.
|
|
9
|
+
*
|
|
10
|
+
* The identity spelling is the CATALOG's: `format_type(oid, NULL)` over `proargtypes` —
|
|
11
|
+
* `integer`, `character varying`, `double precision`, `text[]`. The live side reads it
|
|
12
|
+
* straight from pg_proc; the DECLARED side has only what the author typed, so
|
|
13
|
+
* `@everystack/model`'s `normalizeArgType` maps the SQL aliases (`int`, `varchar`,
|
|
14
|
+
* `timestamptz`) onto the same spelling and drops typmods (a function argument carries
|
|
15
|
+
* none — PostgreSQL discards length/precision, so `varchar(20)` and `varchar` are ONE
|
|
16
|
+
* type here).
|
|
17
|
+
*
|
|
18
|
+
* Known limit: a custom type outside the search_path prints QUALIFIED (`api.my_enum`)
|
|
19
|
+
* while a hand-written `arg('x', 'my_enum')` does not, and the two read as different
|
|
20
|
+
* functions. db:pull-rendered args are already catalog-spelled, so this only reaches a
|
|
21
|
+
* hand-authored declaration; the reconciler names the pair loudly rather than diffing it
|
|
22
|
+
* in silence (see planReconcile's spelling-mismatch warning).
|
|
23
|
+
*
|
|
24
|
+
* Pure and database-free — the identity must be computable in db:diff's CI-pure path.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { normalizeArgType } from '@everystack/model';
|
|
28
|
+
|
|
29
|
+
// The spelling itself lives in @everystack/model, next to `arg()` — it is part of the
|
|
30
|
+
// DECLARED vocabulary, and defineModule needs it to tell an overload from a duplicate.
|
|
31
|
+
export { normalizeArgType };
|
|
32
|
+
|
|
33
|
+
/** `api.get_user(integer, text)` — the identity, from the DECLARED argument types. */
|
|
34
|
+
export function functionIdentity(schema: string, name: string, argTypes: readonly string[]): string {
|
|
35
|
+
return `${schema}.${name}(${argTypes.map(normalizeArgType).join(', ')})`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split a function identity into its qualified name and its argument list.
|
|
40
|
+
* `args` is null for a LEGACY identity that carries no signature (a pre-signature
|
|
41
|
+
* provenance row) — callers keep the old rendering for those.
|
|
42
|
+
*/
|
|
43
|
+
export function splitFunctionIdentity(identity: string): { qualified: string; args: string | null } {
|
|
44
|
+
const open = identity.indexOf('(');
|
|
45
|
+
if (open === -1 || !identity.endsWith(')')) return { qualified: identity, args: null };
|
|
46
|
+
return { qualified: identity.slice(0, open), args: identity.slice(open + 1, -1) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The pre-signature identity of a function — `schema.name`. The provenance upgrade's lookup key. */
|
|
50
|
+
export function legacyFunctionIdentity(identity: string): string {
|
|
51
|
+
return splitFunctionIdentity(identity).qualified;
|
|
52
|
+
}
|
|
@@ -78,6 +78,24 @@ export interface HeartbeatSample {
|
|
|
78
78
|
blockedBy: string | null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* What the CLIENT has sent, to sit alongside what the server reports.
|
|
83
|
+
*
|
|
84
|
+
* The server side alone cannot tell a stalled load from a finished one. Both look like
|
|
85
|
+
* `Client/ClientRead` with flat progress: the server is waiting for the next command. The difference
|
|
86
|
+
* is whether there is anything left to send, and only the client knows that. Without this the
|
|
87
|
+
* detector called the deadlock signature on a run that had landed all 5,984,956 rows and was one
|
|
88
|
+
* second from completing.
|
|
89
|
+
*/
|
|
90
|
+
export interface ClientFeed {
|
|
91
|
+
/** Bytes handed to psql's stdin so far. */
|
|
92
|
+
fedBytes: number;
|
|
93
|
+
/** Bytes the restore has to feed in total (the rewritten SQL file's size). */
|
|
94
|
+
totalBytes: number;
|
|
95
|
+
/** The local reader has written everything — nothing more is coming from our side. */
|
|
96
|
+
done: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
export type Liveness =
|
|
82
100
|
/** Rows or COPY bytes grew since the last poll. */
|
|
83
101
|
| 'progressing'
|
|
@@ -85,6 +103,12 @@ export type Liveness =
|
|
|
85
103
|
| 'blocked'
|
|
86
104
|
/** Flat progress AND the server is waiting on the client: the deadlock signature. */
|
|
87
105
|
| 'client-stall'
|
|
106
|
+
/**
|
|
107
|
+
* The client has sent everything and the server is waiting on it. That is not a stall — it is the
|
|
108
|
+
* tail of a successful load (the final commit, the connection winding down). Distinguishing this
|
|
109
|
+
* from `client-stall` is the whole reason ClientFeed exists.
|
|
110
|
+
*/
|
|
111
|
+
| 'finishing'
|
|
88
112
|
/** Connected and working, but nothing measurable moved this poll (DDL, index build). */
|
|
89
113
|
| 'busy'
|
|
90
114
|
/** No psql backend connected — not started yet, or already gone. */
|
|
@@ -218,9 +242,14 @@ function movedForward(prev: HeartbeatSample, cur: HeartbeatSample): boolean {
|
|
|
218
242
|
* 3. no predecessor → busy (cannot claim progress OR a stall on the first poll)
|
|
219
243
|
* 4. rows/bytes grew → progressing, EVEN in ClientRead (the healthy high-latency case)
|
|
220
244
|
* 5. schema not created → busy (DDL phase, nothing to measure)
|
|
221
|
-
* 6. flat + ClientRead
|
|
245
|
+
* 6. flat + ClientRead + the client still has bytes to send → client-stall (the deadlock signature)
|
|
246
|
+
* 7. flat + ClientRead + the client has sent everything → finishing (the successful tail)
|
|
247
|
+
*
|
|
248
|
+
* Step 7 is the B5 fix. "Nothing is moving" was treated as sufficient evidence of a stall, and it is
|
|
249
|
+
* not: at the end of a healthy load nothing moves either. A stall claim now requires UNLANDED WORK —
|
|
250
|
+
* bytes the client still owes the server — which is why `feed` is threaded this far down.
|
|
222
251
|
*/
|
|
223
|
-
export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Liveness {
|
|
252
|
+
export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample, feed?: ClientFeed): Liveness {
|
|
224
253
|
if (cur.state === null) return 'absent';
|
|
225
254
|
if (!prev) return 'busy';
|
|
226
255
|
// PROGRESS OUTRANKS EVERY SCARY WAIT STATE, including Lock. A restore takes relation locks
|
|
@@ -231,7 +260,7 @@ export function classify(prev: HeartbeatSample | null, cur: HeartbeatSample): Li
|
|
|
231
260
|
if (movedForward(prev, cur)) return 'progressing';
|
|
232
261
|
if (cur.waitEventType === 'Lock') return 'blocked';
|
|
233
262
|
if (!cur.schemaExists) return 'busy';
|
|
234
|
-
if (cur.waitEvent === 'ClientRead') return 'client-stall';
|
|
263
|
+
if (cur.waitEvent === 'ClientRead') return feed?.done ? 'finishing' : 'client-stall';
|
|
235
264
|
return 'busy';
|
|
236
265
|
}
|
|
237
266
|
|
|
@@ -251,16 +280,70 @@ function groupNum(n: number): string {
|
|
|
251
280
|
return n.toLocaleString('en-US');
|
|
252
281
|
}
|
|
253
282
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
283
|
+
/** `1.9 GB`, `512 KB` — bytes at poll-line resolution. */
|
|
284
|
+
function fmtBytes(n: number): string {
|
|
285
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
286
|
+
let v = n;
|
|
287
|
+
let u = 0;
|
|
288
|
+
while (v >= 1024 && u < units.length - 1) { v /= 1024; u += 1; }
|
|
289
|
+
return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* WHICH signal moved between two samples, named honestly.
|
|
294
|
+
*
|
|
295
|
+
* The bug (B6): the progressing line said "COPY advancing" whenever the row delta was zero,
|
|
296
|
+
* regardless of what had actually moved. So a restore in its index phase — relations growing, no
|
|
297
|
+
* COPY anywhere — printed "COPY advancing", and so did the poll right after the final COPY finished.
|
|
298
|
+
* An idle tail read as a live load. Each branch below reports only what it actually observed.
|
|
299
|
+
*/
|
|
300
|
+
export function progressLabel(prev: HeartbeatSample, cur: HeartbeatSample): string {
|
|
301
|
+
const dRows = (cur.rows ?? 0) - (prev.rows ?? 0);
|
|
302
|
+
if (dRows > 0) {
|
|
303
|
+
const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
|
|
304
|
+
return `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`;
|
|
305
|
+
}
|
|
306
|
+
const copyMoved = cur.copyBytes > prev.copyBytes || cur.copyRows > prev.copyRows;
|
|
307
|
+
// "Advancing" is a claim about NOW, so it needs a COPY running now — not merely one that moved.
|
|
308
|
+
if (copyMoved && cur.copies > 0) return 'COPY advancing';
|
|
309
|
+
const dRel = cur.relations - prev.relations;
|
|
310
|
+
if (dRel > 0) return `+${groupNum(dRel)} relation(s) created`;
|
|
311
|
+
if (copyMoved) return 'a COPY completed since the last poll';
|
|
312
|
+
return 'something moved';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The client-side position clause — `client fed 1.9 GB/2.0 GB (95%)`. Empty when unknown. */
|
|
316
|
+
function clientClause(feed?: ClientFeed): string {
|
|
317
|
+
if (!feed) return '';
|
|
318
|
+
const pct = feed.totalBytes > 0 ? Math.floor((feed.fedBytes / feed.totalBytes) * 100) : 0;
|
|
319
|
+
const sent = feed.done ? 'client has fed ALL' : 'client fed';
|
|
320
|
+
return `, ${sent} ${fmtBytes(feed.fedBytes)}/${fmtBytes(feed.totalBytes)} (${pct}%)`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One operator-readable line per poll. Says what moved, what it is waiting on, how long in — and, when
|
|
325
|
+
* known, what the CLIENT has sent.
|
|
326
|
+
*
|
|
327
|
+
* That last part is B7. A `Client/ClientRead` wait with the client 20% through the file and the same
|
|
328
|
+
* wait with the file fully fed are completely different situations, and they used to print
|
|
329
|
+
* identically: two debugging sessions went down the wrong path because "the client is not sending"
|
|
330
|
+
* and "the instance has no I/O left" were indistinguishable in the log.
|
|
331
|
+
*/
|
|
332
|
+
export function formatSample(
|
|
333
|
+
prev: HeartbeatSample | null,
|
|
334
|
+
cur: HeartbeatSample,
|
|
335
|
+
liveness: Liveness,
|
|
336
|
+
feed?: ClientFeed,
|
|
337
|
+
): string {
|
|
256
338
|
const at = `t+${humanElapsed(cur.elapsedMs)}`;
|
|
257
339
|
const wait = cur.waitEventType ? `${cur.waitEventType}/${cur.waitEvent ?? '?'}` : (cur.state ?? 'idle');
|
|
340
|
+
const client = clientClause(feed);
|
|
258
341
|
|
|
259
342
|
if (liveness === 'absent') {
|
|
260
343
|
return `restore: no psql backend connected to the target (${at}) — the loader has not started yet, or has already exited.`;
|
|
261
344
|
}
|
|
262
345
|
if (!cur.schemaExists) {
|
|
263
|
-
return `restore: schema not created yet, no tables yet — DDL phase, ${wait} (${at}).`;
|
|
346
|
+
return `restore: schema not created yet, no tables yet — DDL phase, ${wait}${client} (${at}).`;
|
|
264
347
|
}
|
|
265
348
|
|
|
266
349
|
const rows = cur.rows === null ? 'unknown' : groupNum(cur.rows);
|
|
@@ -275,20 +358,18 @@ export function formatSample(prev: HeartbeatSample | null, cur: HeartbeatSample,
|
|
|
275
358
|
if (liveness === 'blocked') {
|
|
276
359
|
// Naming the holder is the difference between an actionable report and a shrug.
|
|
277
360
|
const by = cur.blockedBy ? ` HELD BY ${cur.blockedBy}` : ' (holder not visible — it may belong to another role)';
|
|
278
|
-
return `restore: waiting on ${wait} with no progress this poll${by} — ${scope} (${at}).`;
|
|
361
|
+
return `restore: waiting on ${wait} with no progress this poll${by} — ${scope}${client} (${at}).`;
|
|
362
|
+
}
|
|
363
|
+
if (liveness === 'finishing') {
|
|
364
|
+
return `restore: ${scope}${client} — FINISHING: everything has been sent, the server is winding the load down (${wait}), ${at}.`;
|
|
279
365
|
}
|
|
280
366
|
if (liveness === 'client-stall') {
|
|
281
|
-
return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope} (${at}).`;
|
|
367
|
+
return `restore: nothing landed since the last poll and the server is waiting on the client (${wait}) — ${scope}${client} (${at}).`;
|
|
282
368
|
}
|
|
283
369
|
if (liveness === 'progressing' && prev) {
|
|
284
|
-
|
|
285
|
-
const secs = Math.max(1, (cur.elapsedMs - prev.elapsedMs) / 1000);
|
|
286
|
-
const moved = dRows > 0
|
|
287
|
-
? `+${groupNum(dRows)} row(s) (${groupNum(Math.round(dRows / secs))} rows/s)`
|
|
288
|
-
: 'COPY advancing';
|
|
289
|
-
return `restore: ${scope}, ${moved} — ${wait} (${at}).`;
|
|
370
|
+
return `restore: ${scope}, ${progressLabel(prev, cur)} — ${wait}${client} (${at}).`;
|
|
290
371
|
}
|
|
291
|
-
return `restore: ${scope}, nothing new this poll — ${wait} (${at}).`;
|
|
372
|
+
return `restore: ${scope}, nothing new this poll — ${wait}${client} (${at}).`;
|
|
292
373
|
}
|
|
293
374
|
|
|
294
375
|
/**
|
|
@@ -335,6 +416,12 @@ export interface HeartbeatOptions {
|
|
|
335
416
|
* backend for. Must not throw; a sink that does is ignored so it cannot kill the heartbeat.
|
|
336
417
|
*/
|
|
337
418
|
onSample?: (sample: HeartbeatSample) => void;
|
|
419
|
+
/**
|
|
420
|
+
* The client's current write position, read fresh each poll. Supplying it is what lets the
|
|
421
|
+
* heartbeat tell a stalled load from a finishing one (see ClientFeed) — without it the detector
|
|
422
|
+
* falls back to server-side-only reasoning and will call a quiet tail a stall.
|
|
423
|
+
*/
|
|
424
|
+
clientFeed?: () => ClientFeed;
|
|
338
425
|
}
|
|
339
426
|
|
|
340
427
|
/**
|
|
@@ -363,8 +450,10 @@ export function startHeartbeat(runner: QueryRunner, opts: HeartbeatOptions): ()
|
|
|
363
450
|
// Raw sample first: a caller acting on the probe (the dead-backend watchdog) must see every
|
|
364
451
|
// sample, and must never be able to break the heartbeat by throwing.
|
|
365
452
|
try { opts.onSample?.(cur); } catch { /* a sink's failure is not the probe's problem */ }
|
|
366
|
-
|
|
367
|
-
|
|
453
|
+
let feed: ClientFeed | undefined;
|
|
454
|
+
try { feed = opts.clientFeed?.(); } catch { /* same: a bad reader must not kill the probe */ }
|
|
455
|
+
const liveness = classify(prev, cur, feed);
|
|
456
|
+
const line = formatSample(prev, cur, liveness, feed);
|
|
368
457
|
|
|
369
458
|
if (liveness === 'blocked') {
|
|
370
459
|
warn(line);
|
|
@@ -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
|
+
}
|