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 +17 -0
- package/dist/drill.js +52 -5
- 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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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