backupdrill 0.1.3 → 0.2.1
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/README.md +14 -0
- package/dist/drill.d.ts +23 -1
- package/dist/drill.js +114 -6
- package/dist/index.js +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Backups stream through the CLI on your machine (or your CI) straight to your buc
|
|
|
6
6
|
|
|
7
7
|
> Want this on a schedule, with **restore drills that prove your backup actually restores**, plus alerts and reports? That's the hosted service → **[backupdrill.com](https://backupdrill.com)**. This CLI is the DIY layer; the cloud adds scheduling, drills, alerting, and reports on top.
|
|
8
8
|
|
|
9
|
+
🎥 Prefer watching? [Where do your backups actually go — explained in about a minute](https://backupdrill.com/guides/test-supabase-backup-restore#explainer-video).
|
|
10
|
+
|
|
9
11
|
## Why
|
|
10
12
|
|
|
11
13
|
- Supabase's own database restore only brings back `storage.objects` **metadata**, not your actual Storage files.
|
|
@@ -146,6 +148,18 @@ backupdrill drill --verify-all-files # also checksum every Storage file, not jus
|
|
|
146
148
|
|
|
147
149
|
Backups record your installed Postgres extensions in the manifest, and the drill pre-installs them in the sandbox before restoring — including switching to a pgvector-enabled sandbox image when your database uses `vector` columns.
|
|
148
150
|
|
|
151
|
+
### Run your own checks against the restored copy
|
|
152
|
+
|
|
153
|
+
The drill's structural checks (tables, rows, checksums) can't know what "working" means for *your* app — business invariants only have answers in your code. `--check-cmd` hands you the restored sandbox before it's destroyed:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
backupdrill drill --check-cmd "npm run smoke" # BACKUPDRILL_SANDBOX_URL points at the restored copy
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_URL` set to the sandbox's connection string. Exit 0 means pass; the result shows up in the report as an `app checks` line, and a non-zero exit fails the drill (the report keeps structural and app-level failures clearly separate). Entirely optional — without the flag, nothing changes and the report has no `app checks` line. There's a 10-minute timeout (the whole process tree is killed), and `--keep` leaves the sandbox container running when a drill fails so you can inspect it — the report JSON then includes a `keptSandbox` object with the container id and connection string (`docker rm -f <id>` when done).
|
|
160
|
+
|
|
161
|
+
**What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security — plus policies referencing Supabase-managed roles (`authenticated`, `anon`) are skipped during restore, since those roles don't exist outside the platform. So use `--check-cmd` for data and business invariants ("orders reference existing users", "the row count I care about is sane"). It is **not** an RLS behavior test — verifying RLS takes an authenticated client stack with real JWTs, which no restore sandbox can fake generically.
|
|
162
|
+
|
|
149
163
|
When you actually need to recover, `restore` puts the database back into a target you name and pulls the Storage files down to a local folder:
|
|
150
164
|
|
|
151
165
|
```bash
|
package/dist/drill.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export interface DrillReport {
|
|
|
20
20
|
restoreSeconds: number;
|
|
21
21
|
restoredTableCount: number;
|
|
22
22
|
restoredRowTotal: number;
|
|
23
|
+
/** --keep 且演练失败时:被保留的沙箱(供排查/脚本消费);其余情况不存在 */
|
|
24
|
+
keptSandbox?: {
|
|
25
|
+
containerId: string;
|
|
26
|
+
connString: string;
|
|
27
|
+
};
|
|
23
28
|
}
|
|
24
29
|
/** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
|
|
25
30
|
interface PostDataResult {
|
|
@@ -33,14 +38,31 @@ export declare function classifyPostDataErrors(stderr: string): PostDataResult;
|
|
|
33
38
|
* 归档器致命错误等)绝不能谎报"全部恢复"——记为通用失败。
|
|
34
39
|
*/
|
|
35
40
|
export declare function postDataResult(code: number | null, stderr: string): PostDataResult;
|
|
41
|
+
export declare const APP_CHECK_TIMEOUT_MS: number;
|
|
42
|
+
/**
|
|
43
|
+
* 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
|
|
44
|
+
* 交给用户命令执行,校验数据/业务不变量(判据只存在于应用侧,引擎不解释命令输出,
|
|
45
|
+
* 只裁决退出码:0 = pass)。注意这不是 RLS 行为测试:连接身份是沙箱超级用户
|
|
46
|
+
* (绕过 RLS),且引用 Supabase 托管角色的策略在恢复时已被归类跳过。
|
|
47
|
+
* 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
|
|
48
|
+
* 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
|
|
49
|
+
* 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function runAppCheck(command: string, connString: string, timeoutMs?: number): Promise<DrillCheck>;
|
|
36
52
|
/**
|
|
37
53
|
* 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
|
|
38
54
|
* 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
|
|
39
55
|
*/
|
|
40
|
-
export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[]
|
|
56
|
+
export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[], opts?: {
|
|
57
|
+
appCheckCommand?: string;
|
|
58
|
+
keepSandboxOnFailure?: boolean;
|
|
59
|
+
}): Promise<DrillReport>;
|
|
41
60
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
42
61
|
export declare function runDrill(config: BackupConfig, opts?: {
|
|
43
62
|
snapshot?: string;
|
|
44
63
|
verifyAllFiles?: boolean;
|
|
64
|
+
/** CLI 专用。红线:托管 worker 绝不能传入(在 worker 上执行用户命令 = RCE)。 */
|
|
65
|
+
appCheckCommand?: string;
|
|
66
|
+
keepSandboxOnFailure?: boolean;
|
|
45
67
|
}): Promise<DrillReport>;
|
|
46
68
|
export {};
|
package/dist/drill.js
CHANGED
|
@@ -308,14 +308,91 @@ async function verifyStorage(s3, config, manifest, verifyAll) {
|
|
|
308
308
|
: `${mismatches.length} failed in ${scope}: ${mismatches.slice(0, 5).join(", ")}`,
|
|
309
309
|
};
|
|
310
310
|
}
|
|
311
|
+
// ── 应用自检钩子(语义层)────────────────────────────────────────────
|
|
312
|
+
// 挂死的 smoke test 不能永远挡住沙箱销毁;10 分钟对"冒烟"级检查绰绰有余
|
|
313
|
+
export const APP_CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
314
|
+
/**
|
|
315
|
+
* 语义层钩子:结构检查全绿后、销毁沙箱前,把沙箱连接串经 BACKUPDRILL_SANDBOX_URL
|
|
316
|
+
* 交给用户命令执行,校验数据/业务不变量(判据只存在于应用侧,引擎不解释命令输出,
|
|
317
|
+
* 只裁决退出码:0 = pass)。注意这不是 RLS 行为测试:连接身份是沙箱超级用户
|
|
318
|
+
* (绕过 RLS),且引用 Supabase 托管角色的策略在恢复时已被归类跳过。
|
|
319
|
+
* 子进程输出定向到 stderr:stdout 是 CLI 的机器可读 JSON 通道,不能被污染。
|
|
320
|
+
* 红线:托管 worker 绝不能把用户输入传到这里(任意代码执行);此钩子只属于
|
|
321
|
+
* 用户在自己机器上运行的 CLI(见 PRD §1.4 演练后应用自检钩子)。
|
|
322
|
+
*/
|
|
323
|
+
export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_MS) {
|
|
324
|
+
const started = Date.now();
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
const proc = spawn(command, {
|
|
327
|
+
shell: true,
|
|
328
|
+
stdio: ["ignore", 2, 2],
|
|
329
|
+
env: { ...process.env, BACKUPDRILL_SANDBOX_URL: connString },
|
|
330
|
+
// 独立进程组:超时必须杀整棵进程树。只杀 shell 的话,npm/测试运行器这类
|
|
331
|
+
// 孙进程会在沙箱销毁后继续跑(xreview 抓出的资源泄漏)。
|
|
332
|
+
detached: true,
|
|
333
|
+
});
|
|
334
|
+
let timedOut = false;
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
timedOut = true;
|
|
337
|
+
try {
|
|
338
|
+
process.kill(-proc.pid, "SIGKILL"); // 负 pid = 杀整个进程组
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
proc.kill("SIGKILL");
|
|
342
|
+
}
|
|
343
|
+
}, timeoutMs);
|
|
344
|
+
proc.on("error", (error) => {
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
resolve({
|
|
347
|
+
name: "app checks",
|
|
348
|
+
pass: false,
|
|
349
|
+
detail: `command failed to start: ${error.message}`,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
proc.on("close", (code, signal) => {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
const seconds = Math.round((Date.now() - started) / 100) / 10;
|
|
355
|
+
if (timedOut) {
|
|
356
|
+
resolve({
|
|
357
|
+
name: "app checks",
|
|
358
|
+
pass: false,
|
|
359
|
+
detail: `timed out after ${timeoutMs / 1000}s — process tree killed`,
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (signal) {
|
|
364
|
+
resolve({
|
|
365
|
+
name: "app checks",
|
|
366
|
+
pass: false,
|
|
367
|
+
detail: `terminated by ${signal} after ${seconds}s`,
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
resolve({
|
|
372
|
+
name: "app checks",
|
|
373
|
+
pass: code === 0,
|
|
374
|
+
detail: code === 0
|
|
375
|
+
? `command exited 0 in ${seconds}s`
|
|
376
|
+
: `command exited ${code} after ${seconds}s`,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
311
381
|
// ── 主流程 ──────────────────────────────────────────────────────────
|
|
312
382
|
/**
|
|
313
383
|
* 演练核心:把一份本地 dump 恢复进临时 Postgres 并对照 manifest 校验,然后销毁容器。
|
|
314
384
|
* 与 S3 解耦,便于离线测试。`preChecks` 用于把下载阶段的校验(如归档校验和)带进报告。
|
|
315
385
|
*/
|
|
316
|
-
export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
|
|
386
|
+
export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
|
|
387
|
+
// 空/纯空白命令 = 危险的假配置(CI 里变量没展开的典型形态):跑了会以 exit 0
|
|
388
|
+
// 假通过,忽略会让用户以为检查在跑。拒绝,且要在起沙箱之前拒绝。
|
|
389
|
+
const appCheckCommand = opts.appCheckCommand?.trim();
|
|
390
|
+
if (opts.appCheckCommand !== undefined && !appCheckCommand) {
|
|
391
|
+
throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to report a check that would never run");
|
|
392
|
+
}
|
|
317
393
|
const checks = [...preChecks];
|
|
318
394
|
const pg = await startEphemeralPostgres(sandboxImage(manifest));
|
|
395
|
+
let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
|
|
319
396
|
try {
|
|
320
397
|
// 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
|
|
321
398
|
const extensions = manifest.database.extensions ?? [];
|
|
@@ -363,18 +440,46 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = []) {
|
|
|
363
440
|
});
|
|
364
441
|
const verify = await verifyRestored(pg.connString, manifest);
|
|
365
442
|
checks.push(...verify.checks);
|
|
366
|
-
|
|
443
|
+
// 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
|
|
444
|
+
// 一次失败报成两层失败,误导排查方向。没配置则不跑、报告里不出现该行。
|
|
445
|
+
if (appCheckCommand && checks.every((c) => c.pass)) {
|
|
446
|
+
log.step("Running app checks…");
|
|
447
|
+
checks.push(await runAppCheck(appCheckCommand, pg.connString));
|
|
448
|
+
}
|
|
449
|
+
pass = checks.every((c) => c.pass);
|
|
450
|
+
const report = {
|
|
367
451
|
snapshot,
|
|
368
|
-
pass
|
|
452
|
+
pass,
|
|
369
453
|
checks,
|
|
370
454
|
restoreSeconds,
|
|
371
455
|
restoredTableCount: verify.tableCount,
|
|
372
456
|
restoredRowTotal: verify.rowTotal,
|
|
373
457
|
};
|
|
458
|
+
if (opts.keepSandboxOnFailure && !pass) {
|
|
459
|
+
// 写进报告让 --keep 可被脚本消费(拿连接串排查),不只靠人眼看日志
|
|
460
|
+
report.keptSandbox = { containerId: pg.containerId, connString: pg.connString };
|
|
461
|
+
}
|
|
462
|
+
return report;
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
// 异常路径(恢复/校验抛出)没有报告对象可挂 keptSandbox;把沙箱坐标并入
|
|
466
|
+
// 错误信息,让脚本和人都拿得到被保留容器的清理线索
|
|
467
|
+
if (opts.keepSandboxOnFailure) {
|
|
468
|
+
error.message +=
|
|
469
|
+
` [sandbox kept: ${pg.connString} — remove with: docker rm -f ${pg.containerId.slice(0, 12)}]`;
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
374
472
|
}
|
|
375
473
|
finally {
|
|
376
|
-
|
|
377
|
-
|
|
474
|
+
if (opts.keepSandboxOnFailure && !pass) {
|
|
475
|
+
// --rm 容器不 docker rm 就一直活着;用户排查完 stop/rm 时 --rm 自动清理
|
|
476
|
+
log.warn(`--keep: sandbox left running for inspection — ${pg.connString} ` +
|
|
477
|
+
`(remove with: docker rm -f ${pg.containerId.slice(0, 12)})`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
log.step("Tearing down ephemeral Postgres…");
|
|
481
|
+
await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
|
|
482
|
+
}
|
|
378
483
|
}
|
|
379
484
|
}
|
|
380
485
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
@@ -415,7 +520,10 @@ export async function runDrill(config, opts = {}) {
|
|
|
415
520
|
log.step("Verifying Storage files…");
|
|
416
521
|
preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
|
|
417
522
|
}
|
|
418
|
-
return await drillDump(dumpPath, manifest, snapshot, preChecks
|
|
523
|
+
return await drillDump(dumpPath, manifest, snapshot, preChecks, {
|
|
524
|
+
appCheckCommand: opts.appCheckCommand,
|
|
525
|
+
keepSandboxOnFailure: opts.keepSandboxOnFailure,
|
|
526
|
+
});
|
|
419
527
|
}
|
|
420
528
|
finally {
|
|
421
529
|
await rm(workdir, { recursive: true, force: true }).catch(() => { });
|
package/dist/index.js
CHANGED
|
@@ -75,8 +75,17 @@ program
|
|
|
75
75
|
.option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
|
|
76
76
|
.option("--project-name <name>", "project name used in the object key")
|
|
77
77
|
.option("--verify-all-files", "checksum every Storage file, not just a sample")
|
|
78
|
+
.option("--check-cmd <command>", "after structural checks pass, run your own smoke test against the restored sandbox " +
|
|
79
|
+
"(connection string in BACKUPDRILL_SANDBOX_URL; exit 0 = pass, reported as 'app checks'). " +
|
|
80
|
+
"Note: connects as the sandbox superuser, so RLS policies are NOT enforced — " +
|
|
81
|
+
"verify data and business invariants here, not RLS behavior")
|
|
82
|
+
.option("--keep", "if the drill fails, keep the sandbox container running for inspection")
|
|
78
83
|
.action(async (opts) => {
|
|
79
84
|
try {
|
|
85
|
+
if (opts.checkCmd !== undefined && !String(opts.checkCmd).trim()) {
|
|
86
|
+
throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to run a drill " +
|
|
87
|
+
"that would report a check that never executes");
|
|
88
|
+
}
|
|
80
89
|
const config = await loadConfig({
|
|
81
90
|
configPath: opts.config,
|
|
82
91
|
overrides: {
|
|
@@ -94,6 +103,8 @@ program
|
|
|
94
103
|
const report = await runDrill(config, {
|
|
95
104
|
snapshot: opts.snapshot,
|
|
96
105
|
verifyAllFiles: opts.verifyAllFiles,
|
|
106
|
+
appCheckCommand: opts.checkCmd,
|
|
107
|
+
keepSandboxOnFailure: opts.keep,
|
|
97
108
|
});
|
|
98
109
|
console.error("");
|
|
99
110
|
for (const c of report.checks) {
|
package/package.json
CHANGED