backupdrill 2.1.8 → 2.2.0

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/dist/drill.d.ts CHANGED
@@ -8,6 +8,20 @@ import type { Manifest } from "./manifest.js";
8
8
  * 会原样列进报告,定位问题不依赖重放同一批样本。
9
9
  */
10
10
  export declare function sampleStorageFiles<T>(files: T[], n: number): T[];
11
+ /** @internal 托管 worker 专用钩子;事件不携带客户数据或凭据,不属于稳定 CLI 契约。 */
12
+ export type DrillPhase = "download" | "storage" | "sandbox" | "restore" | "verify" | "cleanup";
13
+ export interface DrillSupervision {
14
+ /** 仅 runDrill 使用:supervisor 独占的空临时目录,由 supervisor 清理。 */
15
+ workdir: string;
16
+ /** 每次尝试唯一;即使 docker run 被中断,supervisor 也能按名字清理。 */
17
+ resourceName: string;
18
+ /** 非 cleanup 阶段抛错 = 主动中止并清理;上报失败应由调用方自行吞掉,回调须自带超时(托管 worker 另有进程级执行上限)。
19
+ * cleanup 阶段同步/异步错误均忽略,最多等待 5 秒,绝不阻止 teardown。 */
20
+ observe: (event: {
21
+ phase: DrillPhase;
22
+ dumpBytes?: number;
23
+ }) => Promise<void>;
24
+ }
11
25
  export interface DrillCheck {
12
26
  name: string;
13
27
  pass: boolean;
@@ -32,6 +46,7 @@ export interface SandboxTuning {
32
46
  shmSize: string;
33
47
  }
34
48
  export declare function sandboxTuning(totalMemBytes: number): SandboxTuning;
49
+ export declare function sandboxDataVolumeTarget(major: number): string;
35
50
  /** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
36
51
  interface PostDataResult {
37
52
  supabaseSkipped: number;
@@ -65,6 +80,7 @@ export declare function runAppCheck(command: string, connString: string, timeout
65
80
  export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[], opts?: {
66
81
  appCheckCommand?: string;
67
82
  keepSandboxOnFailure?: boolean;
83
+ supervision?: DrillSupervision;
68
84
  }): Promise<DrillReport>;
69
85
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
70
86
  export declare function runDrill(config: BackupConfig, opts?: {
@@ -73,5 +89,6 @@ export declare function runDrill(config: BackupConfig, opts?: {
73
89
  /** CLI 专用。红线:托管 worker 绝不能传入(在 worker 上执行用户命令 = RCE)。 */
74
90
  appCheckCommand?: string;
75
91
  keepSandboxOnFailure?: boolean;
92
+ supervision?: DrillSupervision;
76
93
  }): Promise<DrillReport>;
77
94
  export {};
package/dist/drill.js CHANGED
@@ -94,13 +94,50 @@ export function sandboxTuning(totalMemBytes) {
94
94
  shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2)}m`,
95
95
  };
96
96
  }
97
- async function startEphemeralPostgres(image) {
97
+ function validateSupervision(supervision, appCheckCommand, keepSandboxOnFailure) {
98
+ if (supervision && (appCheckCommand !== undefined || keepSandboxOnFailure)) {
99
+ throw new Error("Supervised drills cannot execute app commands or keep sandboxes");
100
+ }
101
+ if (supervision && !/^bd-drill-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(supervision.resourceName)) {
102
+ throw new Error("Invalid supervised drill resource name");
103
+ }
104
+ }
105
+ async function observeCleanup(supervision) {
106
+ if (!supervision)
107
+ return;
108
+ let timer;
109
+ try {
110
+ await Promise.race([
111
+ Promise.resolve().then(() => supervision.observe({ phase: "cleanup" })),
112
+ new Promise(resolve => { timer = setTimeout(resolve, 5_000); }),
113
+ ]);
114
+ }
115
+ catch { /* 清理的优先级高于监控,同步抛错也不能挡住 teardown。 */ }
116
+ finally {
117
+ if (timer)
118
+ clearTimeout(timer);
119
+ }
120
+ }
121
+ export function sandboxDataVolumeTarget(major) {
122
+ // PG18 将镜像的 VOLUME 上移一层;挂错路径会额外生成无法按名字回收的匿名卷。
123
+ return major >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data";
124
+ }
125
+ async function startEphemeralPostgres(image, major, resourceName) {
126
+ const dataVolumeTarget = sandboxDataVolumeTarget(major);
127
+ if (resourceName) {
128
+ const existing = await execFileAsync("docker", ["volume", "ls", "--format", "{{.Name}}", "--filter", `name=^${resourceName}$`], { timeout: 10_000 });
129
+ if (existing.stdout.trim().split("\n").includes(resourceName)) {
130
+ throw new Error("Supervised drill volume already exists; refusing to restore into a reused sandbox");
131
+ }
132
+ }
98
133
  log.step(`Starting ephemeral ${image}…`);
99
134
  const tuning = sandboxTuning(await dockerMemoryBudget());
100
135
  const { stdout } = await execFileAsync("docker", [
101
136
  "run",
102
137
  "-d",
103
138
  "--rm",
139
+ ...(resourceName ? ["--name", resourceName, "--mount",
140
+ `type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []),
104
141
  "-e",
105
142
  "POSTGRES_PASSWORD=drill",
106
143
  "-e",
@@ -115,7 +152,7 @@ async function startEphemeralPostgres(image) {
115
152
  const containerId = stdout.trim();
116
153
  // postgres 镜像把数据目录声明成 VOLUME → 每次 run 都建一个匿名卷。生产实测 `--rm` **没有**把它
117
154
  // 带走(一次演练遗留 37.6 GB,三次失败把 59 GB 根盘灌满),所以记下卷名,销毁容器后显式删。
118
- const volumeName = await execFileAsync("docker", [
155
+ const volumeName = resourceName ?? await execFileAsync("docker", [
119
156
  "inspect",
120
157
  "-f",
121
158
  '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}',
@@ -388,12 +425,14 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
388
425
  export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
389
426
  // 空/纯空白命令 = 危险的假配置(CI 里变量没展开的典型形态):跑了会以 exit 0
390
427
  // 假通过,忽略会让用户以为检查在跑。拒绝,且要在起沙箱之前拒绝。
428
+ validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
391
429
  const appCheckCommand = opts.appCheckCommand?.trim();
392
430
  if (opts.appCheckCommand !== undefined && !appCheckCommand) {
393
431
  throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to report a check that would never run");
394
432
  }
395
433
  const checks = [...preChecks];
396
- const pg = await startEphemeralPostgres(sandboxImage(manifest));
434
+ await opts.supervision?.observe({ phase: "sandbox" });
435
+ const pg = await startEphemeralPostgres(sandboxImage(manifest), parseInt(manifest.database.serverVersion, 10), opts.supervision?.resourceName);
397
436
  let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
398
437
  try {
399
438
  // 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
@@ -417,6 +456,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
417
456
  : `installed ${extensions.map((e) => e.name).join(", ")}`,
418
457
  });
419
458
  }
459
+ await opts.supervision?.observe({ phase: "restore" });
420
460
  const started = Date.now();
421
461
  log.step("Restoring into ephemeral Postgres…");
422
462
  let postData;
@@ -446,6 +486,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
446
486
  ? `user objects restored; ${postData.supabaseSkipped} Supabase-managed object(s) skipped (Supabase-managed tables such as auth.users do not exist in the drill sandbox)`
447
487
  : "all post-data objects restored",
448
488
  });
489
+ await opts.supervision?.observe({ phase: "verify" });
449
490
  const verify = await verifyRestored(pg.connString, manifest);
450
491
  checks.push(...verify.checks);
451
492
  // 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
@@ -485,6 +526,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
485
526
  `(remove with: docker rm -f ${pg.containerId.slice(0, 12)})`);
486
527
  }
487
528
  else {
529
+ await observeCleanup(opts.supervision);
488
530
  log.step("Tearing down ephemeral Postgres…");
489
531
  await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
490
532
  // 显式删匿名卷(--rm 不可靠,见 startEphemeralPostgres);幂等,已被 --rm 带走也不报错
@@ -495,14 +537,16 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
495
537
  }
496
538
  /** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
497
539
  export async function runDrill(config, opts = {}) {
540
+ validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
498
541
  const s3 = targetClient(config);
499
542
  const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
500
543
  const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
501
544
  log.step(`Drilling snapshot ${snapshot}`);
502
545
  const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
503
- const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
546
+ const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
504
547
  const dumpPath = join(workdir, "dump.pgcustom");
505
548
  try {
549
+ await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
506
550
  log.step("Downloading dump…");
507
551
  const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
508
552
  const integrityPass = sha === manifest.dump.sha256;
@@ -528,15 +572,18 @@ export async function runDrill(config, opts = {}) {
528
572
  }
529
573
  // Storage 文件完整性(备份含 Storage 时才校验)
530
574
  if (manifest.storage && manifest.storage.files.length) {
575
+ await opts.supervision?.observe({ phase: "storage" });
531
576
  log.step("Verifying Storage files…");
532
577
  preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
533
578
  }
534
579
  return await drillDump(dumpPath, manifest, snapshot, preChecks, {
535
580
  appCheckCommand: opts.appCheckCommand,
536
581
  keepSandboxOnFailure: opts.keepSandboxOnFailure,
582
+ supervision: opts.supervision,
537
583
  });
538
584
  }
539
585
  finally {
540
- await rm(workdir, { recursive: true, force: true }).catch(() => { });
586
+ if (!opts.supervision)
587
+ await rm(workdir, { recursive: true, force: true }).catch(() => { });
541
588
  }
542
589
  }
package/dist/lib.d.ts CHANGED
@@ -9,6 +9,6 @@ export { loadConfig } from "./config.js";
9
9
  export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
10
10
  export type { BackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
11
11
  export type { BucketAttrs, ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
12
- export type { DrillReport, DrillCheck } from "./drill.js";
12
+ export type { DrillReport, DrillCheck, DrillSupervision, DrillPhase } from "./drill.js";
13
13
  export type { RestoreResult } from "./restore.js";
14
14
  export type { EgressPricing, EgressProjection } from "./estimate.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.1.8",
3
+ "version": "2.2.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {