backupdrill 2.1.7 → 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 +23 -0
- package/dist/drill.js +105 -22
- package/dist/lib.d.ts +1 -1
- package/package.json +1 -1
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;
|
|
@@ -26,6 +40,13 @@ export interface DrillReport {
|
|
|
26
40
|
connString: string;
|
|
27
41
|
};
|
|
28
42
|
}
|
|
43
|
+
export interface SandboxTuning {
|
|
44
|
+
settings: string[];
|
|
45
|
+
/** docker --shm-size 的值;并行建索引走动态共享内存,要跟 maintenance_work_mem 一起长 */
|
|
46
|
+
shmSize: string;
|
|
47
|
+
}
|
|
48
|
+
export declare function sandboxTuning(totalMemBytes: number): SandboxTuning;
|
|
49
|
+
export declare function sandboxDataVolumeTarget(major: number): string;
|
|
29
50
|
/** post-data 第二遍恢复的结果:用户对象失败 vs Supabase 托管对象的沙箱预期跳过。 */
|
|
30
51
|
interface PostDataResult {
|
|
31
52
|
supabaseSkipped: number;
|
|
@@ -59,6 +80,7 @@ export declare function runAppCheck(command: string, connString: string, timeout
|
|
|
59
80
|
export declare function drillDump(dumpPath: string, manifest: Manifest, snapshot: string, preChecks?: DrillCheck[], opts?: {
|
|
60
81
|
appCheckCommand?: string;
|
|
61
82
|
keepSandboxOnFailure?: boolean;
|
|
83
|
+
supervision?: DrillSupervision;
|
|
62
84
|
}): Promise<DrillReport>;
|
|
63
85
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
64
86
|
export declare function runDrill(config: BackupConfig, opts?: {
|
|
@@ -67,5 +89,6 @@ export declare function runDrill(config: BackupConfig, opts?: {
|
|
|
67
89
|
/** CLI 专用。红线:托管 worker 绝不能传入(在 worker 上执行用户命令 = RCE)。 */
|
|
68
90
|
appCheckCommand?: string;
|
|
69
91
|
keepSandboxOnFailure?: boolean;
|
|
92
|
+
supervision?: DrillSupervision;
|
|
70
93
|
}): Promise<DrillReport>;
|
|
71
94
|
export {};
|
package/dist/drill.js
CHANGED
|
@@ -43,43 +43,116 @@ function sandboxImage(manifest) {
|
|
|
43
43
|
* 触因:105 表 / 12 GB dump 的演练跑了 2 小时,COPY 与建索引都在等 fsync、写整页 WAL、
|
|
44
44
|
* 64 MB 的排序内存反复溢磁盘。关掉持久性保证不影响演练结论(我们验证的是"能不能恢复出来",
|
|
45
45
|
* 不是"断电后还在不在"),但 COPY / 建索引通常快 2-5 倍,临时盘也少吃一半。
|
|
46
|
-
* wal_level=minimal 要求 max_wal_senders=0
|
|
46
|
+
* wal_level=minimal 要求 max_wal_senders=0。
|
|
47
|
+
*
|
|
48
|
+
* 内存相关的两项按**Docker 引擎能用的内存**推导,不写死:同一份 CLI 既跑在 8 GB 的 worker 上,
|
|
49
|
+
* 也跑在用户自己的笔记本上。比例取 Postgres 官方的经验值(shared_buffers 1/4、
|
|
50
|
+
* maintenance_work_mem 1/8),再夹在 [下限, 上限] 之间——下限保证 2 GB 小机器不比调参前差,
|
|
51
|
+
* 上限是因为超过 2 GB 的 shared_buffers 对一次性恢复没有边际收益,白占主机内存。
|
|
52
|
+
*
|
|
53
|
+
* 为什么问 Docker 而不是 os.totalmem():Mac / Windows 上的 Docker Desktop 跑在一台内存独立
|
|
54
|
+
* 配置的虚拟机里,宿主机 32 GB、虚拟机可能只有 2 GB;按宿主机算会把沙箱 Postgres 直接 OOM,
|
|
55
|
+
* 一份好备份被误判成演练失败(交叉审查 2026-09-12 用 1 GiB 限额复现)。`docker info` 的
|
|
56
|
+
* MemTotal 在 Linux 上就是宿主机内存,在 Docker Desktop 上是虚拟机内存,两边都是真正的预算。
|
|
47
57
|
*/
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
const MIB = 1024 * 1024;
|
|
59
|
+
function clampMib(bytes, minMib, maxMib) {
|
|
60
|
+
return Math.min(maxMib, Math.max(minMib, Math.floor(bytes / MIB)));
|
|
61
|
+
}
|
|
62
|
+
/** Docker 引擎可用内存(字节);问不到就返回 0,让 sandboxTuning 落到下限值,宁慢勿炸。 */
|
|
63
|
+
async function dockerMemoryBudget() {
|
|
64
|
+
try {
|
|
65
|
+
// 探测是可选的,绝不能卡住演练:守护进程假死时 5 秒后放弃,落到下限值继续跑
|
|
66
|
+
const { stdout } = await execFileAsync("docker", ["info", "--format", "{{.MemTotal}}"], {
|
|
67
|
+
timeout: 5_000,
|
|
68
|
+
});
|
|
69
|
+
const bytes = Number.parseInt(stdout.trim(), 10);
|
|
70
|
+
return Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function sandboxTuning(totalMemBytes) {
|
|
77
|
+
const sharedBuffersMib = clampMib(totalMemBytes / 4, 256, 2048);
|
|
78
|
+
const maintenanceWorkMemMib = clampMib(totalMemBytes / 8, 256, 1024);
|
|
79
|
+
return {
|
|
80
|
+
settings: [
|
|
81
|
+
"fsync=off",
|
|
82
|
+
"synchronous_commit=off",
|
|
83
|
+
"full_page_writes=off",
|
|
84
|
+
"wal_level=minimal",
|
|
85
|
+
"max_wal_senders=0",
|
|
86
|
+
`maintenance_work_mem=${maintenanceWorkMemMib}MB`,
|
|
87
|
+
"max_wal_size=4GB",
|
|
88
|
+
"checkpoint_timeout=30min",
|
|
89
|
+
`shared_buffers=${sharedBuffersMib}MB`,
|
|
90
|
+
],
|
|
91
|
+
// Docker 默认 /dev/shm 只有 64 MB;并行建索引(pgvector 的 HNSW 尤其)走动态共享内存,
|
|
92
|
+
// 64 MB 会撞 "No space left on device" 把好备份误判失败(交叉审查 2026-09-12 复现)。
|
|
93
|
+
// 给 maintenance_work_mem 的两倍、至少 1 GB;tmpfs 只按实际用量占内存,给大不吃亏。
|
|
94
|
+
shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2)}m`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
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
|
+
}
|
|
60
133
|
log.step(`Starting ephemeral ${image}…`);
|
|
134
|
+
const tuning = sandboxTuning(await dockerMemoryBudget());
|
|
61
135
|
const { stdout } = await execFileAsync("docker", [
|
|
62
136
|
"run",
|
|
63
137
|
"-d",
|
|
64
138
|
"--rm",
|
|
139
|
+
...(resourceName ? ["--name", resourceName, "--mount",
|
|
140
|
+
`type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []),
|
|
65
141
|
"-e",
|
|
66
142
|
"POSTGRES_PASSWORD=drill",
|
|
67
143
|
"-e",
|
|
68
144
|
"POSTGRES_DB=postgres",
|
|
69
145
|
"-p",
|
|
70
146
|
"127.0.0.1:0:5432", // 随机主机端口,避免撞端口
|
|
71
|
-
|
|
72
|
-
// 尤其)走动态共享内存,64 MB 会撞 "No space left on device" 把好备份误判失败(交叉审查
|
|
73
|
-
// 2026-09-12 复现)。pgvector 官方 docker 说明就要求调大 shm。
|
|
74
|
-
"--shm-size=1g",
|
|
147
|
+
`--shm-size=${tuning.shmSize}`,
|
|
75
148
|
image,
|
|
76
149
|
"postgres",
|
|
77
|
-
...
|
|
150
|
+
...tuning.settings.flatMap((kv) => ["-c", kv]),
|
|
78
151
|
]);
|
|
79
152
|
const containerId = stdout.trim();
|
|
80
153
|
// postgres 镜像把数据目录声明成 VOLUME → 每次 run 都建一个匿名卷。生产实测 `--rm` **没有**把它
|
|
81
154
|
// 带走(一次演练遗留 37.6 GB,三次失败把 59 GB 根盘灌满),所以记下卷名,销毁容器后显式删。
|
|
82
|
-
const volumeName = await execFileAsync("docker", [
|
|
155
|
+
const volumeName = resourceName ?? await execFileAsync("docker", [
|
|
83
156
|
"inspect",
|
|
84
157
|
"-f",
|
|
85
158
|
'{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}',
|
|
@@ -352,12 +425,14 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
|
|
|
352
425
|
export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
|
|
353
426
|
// 空/纯空白命令 = 危险的假配置(CI 里变量没展开的典型形态):跑了会以 exit 0
|
|
354
427
|
// 假通过,忽略会让用户以为检查在跑。拒绝,且要在起沙箱之前拒绝。
|
|
428
|
+
validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
|
|
355
429
|
const appCheckCommand = opts.appCheckCommand?.trim();
|
|
356
430
|
if (opts.appCheckCommand !== undefined && !appCheckCommand) {
|
|
357
431
|
throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to report a check that would never run");
|
|
358
432
|
}
|
|
359
433
|
const checks = [...preChecks];
|
|
360
|
-
|
|
434
|
+
await opts.supervision?.observe({ phase: "sandbox" });
|
|
435
|
+
const pg = await startEphemeralPostgres(sandboxImage(manifest), parseInt(manifest.database.serverVersion, 10), opts.supervision?.resourceName);
|
|
361
436
|
let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
|
|
362
437
|
try {
|
|
363
438
|
// 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
|
|
@@ -381,6 +456,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
381
456
|
: `installed ${extensions.map((e) => e.name).join(", ")}`,
|
|
382
457
|
});
|
|
383
458
|
}
|
|
459
|
+
await opts.supervision?.observe({ phase: "restore" });
|
|
384
460
|
const started = Date.now();
|
|
385
461
|
log.step("Restoring into ephemeral Postgres…");
|
|
386
462
|
let postData;
|
|
@@ -410,6 +486,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
410
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)`
|
|
411
487
|
: "all post-data objects restored",
|
|
412
488
|
});
|
|
489
|
+
await opts.supervision?.observe({ phase: "verify" });
|
|
413
490
|
const verify = await verifyRestored(pg.connString, manifest);
|
|
414
491
|
checks.push(...verify.checks);
|
|
415
492
|
// 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
|
|
@@ -449,6 +526,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
449
526
|
`(remove with: docker rm -f ${pg.containerId.slice(0, 12)})`);
|
|
450
527
|
}
|
|
451
528
|
else {
|
|
529
|
+
await observeCleanup(opts.supervision);
|
|
452
530
|
log.step("Tearing down ephemeral Postgres…");
|
|
453
531
|
await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
|
|
454
532
|
// 显式删匿名卷(--rm 不可靠,见 startEphemeralPostgres);幂等,已被 --rm 带走也不报错
|
|
@@ -459,14 +537,16 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
459
537
|
}
|
|
460
538
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
461
539
|
export async function runDrill(config, opts = {}) {
|
|
540
|
+
validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
|
|
462
541
|
const s3 = targetClient(config);
|
|
463
542
|
const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
|
|
464
543
|
const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
|
|
465
544
|
log.step(`Drilling snapshot ${snapshot}`);
|
|
466
545
|
const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
|
|
467
|
-
const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
|
|
546
|
+
const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
|
|
468
547
|
const dumpPath = join(workdir, "dump.pgcustom");
|
|
469
548
|
try {
|
|
549
|
+
await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
|
|
470
550
|
log.step("Downloading dump…");
|
|
471
551
|
const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
|
|
472
552
|
const integrityPass = sha === manifest.dump.sha256;
|
|
@@ -492,15 +572,18 @@ export async function runDrill(config, opts = {}) {
|
|
|
492
572
|
}
|
|
493
573
|
// Storage 文件完整性(备份含 Storage 时才校验)
|
|
494
574
|
if (manifest.storage && manifest.storage.files.length) {
|
|
575
|
+
await opts.supervision?.observe({ phase: "storage" });
|
|
495
576
|
log.step("Verifying Storage files…");
|
|
496
577
|
preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
|
|
497
578
|
}
|
|
498
579
|
return await drillDump(dumpPath, manifest, snapshot, preChecks, {
|
|
499
580
|
appCheckCommand: opts.appCheckCommand,
|
|
500
581
|
keepSandboxOnFailure: opts.keepSandboxOnFailure,
|
|
582
|
+
supervision: opts.supervision,
|
|
501
583
|
});
|
|
502
584
|
}
|
|
503
585
|
finally {
|
|
504
|
-
|
|
586
|
+
if (!opts.supervision)
|
|
587
|
+
await rm(workdir, { recursive: true, force: true }).catch(() => { });
|
|
505
588
|
}
|
|
506
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