backupdrill 2.1.8 → 2.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/dist/backup.d.ts +2 -10
- package/dist/backup.js +3 -13
- package/dist/drill.d.ts +17 -0
- package/dist/drill.js +72 -8
- package/dist/lib.d.ts +1 -1
- package/dist/pgbin.d.ts +33 -0
- package/dist/pgbin.js +60 -0
- package/dist/restore.js +13 -14
- package/package.json +1 -1
package/dist/backup.d.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import type { BackupConfig } from "./config.js";
|
|
2
2
|
import type { Manifest } from "./manifest.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
|
|
7
|
-
* Debian/PGDG (what the deployed worker image uses) prints
|
|
8
|
-
* `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
|
|
9
|
-
* therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
|
|
10
|
-
* instead, falling back to the first dotted version token for non-standard builds.
|
|
11
|
-
*/
|
|
12
|
-
export declare function parsePgDumpMajor(raw: string): number | null;
|
|
3
|
+
import { parsePgDumpMajor } from "./pgbin.js";
|
|
4
|
+
export { parsePgDumpMajor };
|
|
13
5
|
export declare function runBackup(config: BackupConfig): Promise<Manifest>;
|
package/dist/backup.js
CHANGED
|
@@ -11,6 +11,9 @@ import { renderRecoveryDoc } from "./recovery-doc.js";
|
|
|
11
11
|
import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
|
|
12
12
|
import { log } from "./log.js";
|
|
13
13
|
import { TOOL_VERSION } from "./version.js";
|
|
14
|
+
import { parsePgDumpMajor } from "./pgbin.js";
|
|
15
|
+
// 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
|
|
16
|
+
export { parsePgDumpMajor };
|
|
14
17
|
import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
|
|
15
18
|
import { libpqChildEnv, urlSslModeOf } from "./restore-engine.js";
|
|
16
19
|
const execFileAsync = promisify(execFile);
|
|
@@ -18,19 +21,6 @@ function majorOf(serverVersionNum) {
|
|
|
18
21
|
// server_version_num: 150004 → 15, 170002 → 17
|
|
19
22
|
return Math.floor(serverVersionNum / 10000);
|
|
20
23
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Parse the major version out of `pg_dump --version` output.
|
|
23
|
-
*
|
|
24
|
-
* MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
|
|
25
|
-
* Debian/PGDG (what the deployed worker image uses) prints
|
|
26
|
-
* `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
|
|
27
|
-
* therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
|
|
28
|
-
* instead, falling back to the first dotted version token for non-standard builds.
|
|
29
|
-
*/
|
|
30
|
-
export function parsePgDumpMajor(raw) {
|
|
31
|
-
const match = raw.match(/\(PostgreSQL\)\s+(\d+)/) ?? raw.match(/(\d+)(?:\.\d+)+/);
|
|
32
|
-
return match ? Number(match[1]) : null;
|
|
33
|
-
}
|
|
34
24
|
async function pgDumpVersion(bin) {
|
|
35
25
|
try {
|
|
36
26
|
const { stdout } = await execFileAsync(bin, ["--version"]);
|
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
|
@@ -7,6 +7,7 @@ import { parseManifest } from "./manifest.js";
|
|
|
7
7
|
import { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
8
8
|
import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
|
|
9
9
|
import { connectPg } from "./supabase-ca.js";
|
|
10
|
+
import { localPgRestoreMajor, restoreToolBreaksOnTarget, sandboxMajorFor } from "./pgbin.js";
|
|
10
11
|
import { log } from "./log.js";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
// Storage 校验默认抽样上限(避免每次演练重下全部文件产生大量 egress);超限如实记录
|
|
@@ -31,10 +32,10 @@ export function sampleStorageFiles(files, n) {
|
|
|
31
32
|
/**
|
|
32
33
|
* 沙箱镜像选择:alpine 镜像只带 contrib 扩展,装不了 pgvector——含 vector 列的
|
|
33
34
|
* 备份在裸镜像里恢复必然硬崩("type vector does not exist")。manifest 记录了
|
|
34
|
-
* vector 时换用 pgvector 官方镜像(同 major),其余维持轻量 alpine。
|
|
35
|
+
* vector 时换用 pgvector 官方镜像(同 major),其余维持轻量 alpine。major 由调用方给出
|
|
36
|
+
* (sandboxMajorFor:源库与本机 pg_restore 取大者),不一定等于源库版本。
|
|
35
37
|
*/
|
|
36
|
-
function sandboxImage(manifest) {
|
|
37
|
-
const major = parseInt(manifest.database.serverVersion, 10);
|
|
38
|
+
function sandboxImage(manifest, major) {
|
|
38
39
|
const hasVector = (manifest.database.extensions ?? []).some((e) => e.name === "vector");
|
|
39
40
|
return hasVector ? `pgvector/pgvector:pg${major}` : `postgres:${major}-alpine`;
|
|
40
41
|
}
|
|
@@ -94,13 +95,50 @@ export function sandboxTuning(totalMemBytes) {
|
|
|
94
95
|
shmSize: `${Math.max(1024, maintenanceWorkMemMib * 2)}m`,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
function validateSupervision(supervision, appCheckCommand, keepSandboxOnFailure) {
|
|
99
|
+
if (supervision && (appCheckCommand !== undefined || keepSandboxOnFailure)) {
|
|
100
|
+
throw new Error("Supervised drills cannot execute app commands or keep sandboxes");
|
|
101
|
+
}
|
|
102
|
+
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)) {
|
|
103
|
+
throw new Error("Invalid supervised drill resource name");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function observeCleanup(supervision) {
|
|
107
|
+
if (!supervision)
|
|
108
|
+
return;
|
|
109
|
+
let timer;
|
|
110
|
+
try {
|
|
111
|
+
await Promise.race([
|
|
112
|
+
Promise.resolve().then(() => supervision.observe({ phase: "cleanup" })),
|
|
113
|
+
new Promise(resolve => { timer = setTimeout(resolve, 5_000); }),
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
catch { /* 清理的优先级高于监控,同步抛错也不能挡住 teardown。 */ }
|
|
117
|
+
finally {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function sandboxDataVolumeTarget(major) {
|
|
123
|
+
// PG18 将镜像的 VOLUME 上移一层;挂错路径会额外生成无法按名字回收的匿名卷。
|
|
124
|
+
return major >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data";
|
|
125
|
+
}
|
|
126
|
+
async function startEphemeralPostgres(image, major, resourceName) {
|
|
127
|
+
const dataVolumeTarget = sandboxDataVolumeTarget(major);
|
|
128
|
+
if (resourceName) {
|
|
129
|
+
const existing = await execFileAsync("docker", ["volume", "ls", "--format", "{{.Name}}", "--filter", `name=^${resourceName}$`], { timeout: 10_000 });
|
|
130
|
+
if (existing.stdout.trim().split("\n").includes(resourceName)) {
|
|
131
|
+
throw new Error("Supervised drill volume already exists; refusing to restore into a reused sandbox");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
98
134
|
log.step(`Starting ephemeral ${image}…`);
|
|
99
135
|
const tuning = sandboxTuning(await dockerMemoryBudget());
|
|
100
136
|
const { stdout } = await execFileAsync("docker", [
|
|
101
137
|
"run",
|
|
102
138
|
"-d",
|
|
103
139
|
"--rm",
|
|
140
|
+
...(resourceName ? ["--name", resourceName, "--mount",
|
|
141
|
+
`type=volume,source=${resourceName},target=${dataVolumeTarget}`] : []),
|
|
104
142
|
"-e",
|
|
105
143
|
"POSTGRES_PASSWORD=drill",
|
|
106
144
|
"-e",
|
|
@@ -115,7 +153,7 @@ async function startEphemeralPostgres(image) {
|
|
|
115
153
|
const containerId = stdout.trim();
|
|
116
154
|
// postgres 镜像把数据目录声明成 VOLUME → 每次 run 都建一个匿名卷。生产实测 `--rm` **没有**把它
|
|
117
155
|
// 带走(一次演练遗留 37.6 GB,三次失败把 59 GB 根盘灌满),所以记下卷名,销毁容器后显式删。
|
|
118
|
-
const volumeName = await execFileAsync("docker", [
|
|
156
|
+
const volumeName = resourceName ?? await execFileAsync("docker", [
|
|
119
157
|
"inspect",
|
|
120
158
|
"-f",
|
|
121
159
|
'{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}',
|
|
@@ -388,14 +426,32 @@ export function runAppCheck(command, connString, timeoutMs = APP_CHECK_TIMEOUT_M
|
|
|
388
426
|
export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], opts = {}) {
|
|
389
427
|
// 空/纯空白命令 = 危险的假配置(CI 里变量没展开的典型形态):跑了会以 exit 0
|
|
390
428
|
// 假通过,忽略会让用户以为检查在跑。拒绝,且要在起沙箱之前拒绝。
|
|
429
|
+
validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
|
|
391
430
|
const appCheckCommand = opts.appCheckCommand?.trim();
|
|
392
431
|
if (opts.appCheckCommand !== undefined && !appCheckCommand) {
|
|
393
432
|
throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to report a check that would never run");
|
|
394
433
|
}
|
|
395
434
|
const checks = [...preChecks];
|
|
396
|
-
|
|
435
|
+
await opts.supervision?.observe({ phase: "sandbox" });
|
|
436
|
+
const sourceMajor = parseInt(manifest.database.serverVersion, 10);
|
|
437
|
+
const toolMajor = await localPgRestoreMajor();
|
|
438
|
+
const sandboxMajor = sandboxMajorFor(sourceMajor, toolMajor);
|
|
439
|
+
const pg = await startEphemeralPostgres(sandboxImage(manifest, sandboxMajor), sandboxMajor, opts.supervision?.resourceName);
|
|
397
440
|
let pass = false; // 异常路径视为失败,供 finally 决定 --keep 是否保留沙箱
|
|
398
441
|
try {
|
|
442
|
+
if (sandboxMajor !== sourceMajor) {
|
|
443
|
+
// 报告里写明沙箱比源库新以及原因:读报告的人要知道这次证明的是
|
|
444
|
+
// "能恢复进 PostgreSQL N",而不是同版本
|
|
445
|
+
checks.push({
|
|
446
|
+
name: "sandbox version",
|
|
447
|
+
pass: true,
|
|
448
|
+
detail: `restored into PostgreSQL ${sandboxMajor} (source is ${sourceMajor}): ` +
|
|
449
|
+
(toolMajor !== null && restoreToolBreaksOnTarget(toolMajor, sourceMajor)
|
|
450
|
+
? `pg_restore ${toolMajor} sends SET transaction_timeout, which PostgreSQL ${sourceMajor} rejects`
|
|
451
|
+
: `output of pg_restore ${toolMajor} is only guaranteed to load into PostgreSQL ${toolMajor} or newer`) +
|
|
452
|
+
`; restoring into a newer major version is supported`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
399
455
|
// 旧 manifest(≤0.1.1)没有 extensions 字段 → 不装任何扩展,行为与从前一致
|
|
400
456
|
const extensions = manifest.database.extensions ?? [];
|
|
401
457
|
const unavailable = await installExtensions(pg.connString, extensions);
|
|
@@ -417,6 +473,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
417
473
|
: `installed ${extensions.map((e) => e.name).join(", ")}`,
|
|
418
474
|
});
|
|
419
475
|
}
|
|
476
|
+
await opts.supervision?.observe({ phase: "restore" });
|
|
420
477
|
const started = Date.now();
|
|
421
478
|
log.step("Restoring into ephemeral Postgres…");
|
|
422
479
|
let postData;
|
|
@@ -446,6 +503,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
446
503
|
? `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
504
|
: "all post-data objects restored",
|
|
448
505
|
});
|
|
506
|
+
await opts.supervision?.observe({ phase: "verify" });
|
|
449
507
|
const verify = await verifyRestored(pg.connString, manifest);
|
|
450
508
|
checks.push(...verify.checks);
|
|
451
509
|
// 语义层钩子:只在结构层全绿时才跑——结构已失败时再跑应用检查,只会把
|
|
@@ -485,6 +543,7 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
485
543
|
`(remove with: docker rm -f ${pg.containerId.slice(0, 12)})`);
|
|
486
544
|
}
|
|
487
545
|
else {
|
|
546
|
+
await observeCleanup(opts.supervision);
|
|
488
547
|
log.step("Tearing down ephemeral Postgres…");
|
|
489
548
|
await execFileAsync("docker", ["rm", "-f", pg.containerId]).catch(() => { });
|
|
490
549
|
// 显式删匿名卷(--rm 不可靠,见 startEphemeralPostgres);幂等,已被 --rm 带走也不报错
|
|
@@ -495,14 +554,16 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
495
554
|
}
|
|
496
555
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
497
556
|
export async function runDrill(config, opts = {}) {
|
|
557
|
+
validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
|
|
498
558
|
const s3 = targetClient(config);
|
|
499
559
|
const snapshotPrefix = await resolveSnapshot(s3, config, opts.snapshot);
|
|
500
560
|
const snapshot = snapshotPrefix.replace(/\/$/, "").split("/").pop();
|
|
501
561
|
log.step(`Drilling snapshot ${snapshot}`);
|
|
502
562
|
const manifest = parseManifest(await getObjectText(s3, config.storage.bucket, `${snapshotPrefix}manifest.json`));
|
|
503
|
-
const workdir = await mkdtemp(join(tmpdir(), "backupdrill-"));
|
|
563
|
+
const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
|
|
504
564
|
const dumpPath = join(workdir, "dump.pgcustom");
|
|
505
565
|
try {
|
|
566
|
+
await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
|
|
506
567
|
log.step("Downloading dump…");
|
|
507
568
|
const { sha256: sha } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
|
|
508
569
|
const integrityPass = sha === manifest.dump.sha256;
|
|
@@ -528,15 +589,18 @@ export async function runDrill(config, opts = {}) {
|
|
|
528
589
|
}
|
|
529
590
|
// Storage 文件完整性(备份含 Storage 时才校验)
|
|
530
591
|
if (manifest.storage && manifest.storage.files.length) {
|
|
592
|
+
await opts.supervision?.observe({ phase: "storage" });
|
|
531
593
|
log.step("Verifying Storage files…");
|
|
532
594
|
preChecks.push(await verifyStorage(s3, config, manifest, opts.verifyAllFiles ?? false));
|
|
533
595
|
}
|
|
534
596
|
return await drillDump(dumpPath, manifest, snapshot, preChecks, {
|
|
535
597
|
appCheckCommand: opts.appCheckCommand,
|
|
536
598
|
keepSandboxOnFailure: opts.keepSandboxOnFailure,
|
|
599
|
+
supervision: opts.supervision,
|
|
537
600
|
});
|
|
538
601
|
}
|
|
539
602
|
finally {
|
|
540
|
-
|
|
603
|
+
if (!opts.supervision)
|
|
604
|
+
await rm(workdir, { recursive: true, force: true }).catch(() => { });
|
|
541
605
|
}
|
|
542
606
|
}
|
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/dist/pgbin.d.ts
CHANGED
|
@@ -5,3 +5,36 @@
|
|
|
5
5
|
* 绝不能把 pg_dump 当恢复器执行。
|
|
6
6
|
*/
|
|
7
7
|
export declare function resolvePgRestoreBin(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Parse the major version out of `pg_dump --version` output.
|
|
10
|
+
*
|
|
11
|
+
* MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
|
|
12
|
+
* Debian/PGDG (what the deployed worker image uses) prints
|
|
13
|
+
* `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
|
|
14
|
+
* therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
|
|
15
|
+
* instead, falling back to the first dotted version token for non-standard builds.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parsePgDumpMajor(raw: string): number | null;
|
|
18
|
+
/** 本地 pg_restore 主版本(拿不到 → null,由调用方决定 fail-open 还是 fail-closed)。 */
|
|
19
|
+
export declare function localPgRestoreMajor(): Promise<number | null>;
|
|
20
|
+
/**
|
|
21
|
+
* pg_restore 17 起每次恢复都先发 `SET transaction_timeout = 0`,这个参数 PostgreSQL 17
|
|
22
|
+
* 才有,恢复进 16 及更老的服务端第一句就报 unrecognized configuration parameter。
|
|
23
|
+
* 上游定性为设计如此(pgsql-bugs 2024-10),不会修,只能绕开。
|
|
24
|
+
*/
|
|
25
|
+
export declare function restoreToolBreaksOnTarget(toolMajor: number, targetMajor: number): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* 恢复前的工具/目标版本闸:返回拒绝理由,可放行时返回 null。dry-run 与正式恢复共用,
|
|
28
|
+
* 保证两条路径的判定和给用户的出路一字不差。出路有两条:目标升到 17+;或当归档
|
|
29
|
+
* 本身只需要 ≤16 的 pg_restore(旧版 pg_dump 写的)时,换一个与目标同版本的旧客户端。
|
|
30
|
+
*/
|
|
31
|
+
export declare function restoreToolTargetBlocker(toolMajor: number, targetMajor: number, requiredMajor: number): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* 演练沙箱的主版本:源库与本机 pg_restore 取大者。pg_dump/pg_restore 的输出只保证能进
|
|
34
|
+
* 与工具同版本或更新的服务端(官方文档原话:不保证能载入更老的主版本)——除了
|
|
35
|
+
* restoreToolBreaksOnTarget 这条已知的,还有新版 pg_dump 生成旧服务端不认识的语法
|
|
36
|
+
* (交叉审查实测:pg_dump 18 导 PG17 的继承表)。往更新的主版本恢复是官方支持的方向,
|
|
37
|
+
* 反过来要求用户装与源库同版本的旧工具行不通(旧 pg_restore 读不了新 pg_dump 的归档)。
|
|
38
|
+
* 拿不到工具版本时退回源版本。
|
|
39
|
+
*/
|
|
40
|
+
export declare function sandboxMajorFor(sourceMajor: number, toolMajor: number | null): number;
|
package/dist/pgbin.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
1
4
|
/**
|
|
2
5
|
* pg_restore 二进制解析:显式 BACKUPDRILL_PG_RESTORE 最高优先;否则从
|
|
3
6
|
* BACKUPDRILL_PG_DUMP 同目录推导。推导未命中(结果仍含 pg_dump 或与原值相同,
|
|
@@ -15,3 +18,60 @@ export function resolvePgRestoreBin() {
|
|
|
15
18
|
}
|
|
16
19
|
return "pg_restore";
|
|
17
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse the major version out of `pg_dump --version` output.
|
|
23
|
+
*
|
|
24
|
+
* MUST tolerate a trailing vendor suffix: Homebrew/macOS prints `pg_dump (PostgreSQL) 17.2`, but
|
|
25
|
+
* Debian/PGDG (what the deployed worker image uses) prints
|
|
26
|
+
* `pg_dump (PostgreSQL) 17.10 (Debian 17.10-1.pgdg120+1)`. Anchoring the match to end-of-string
|
|
27
|
+
* therefore breaks in production while passing on a dev Mac. Anchor on the `(PostgreSQL)` marker
|
|
28
|
+
* instead, falling back to the first dotted version token for non-standard builds.
|
|
29
|
+
*/
|
|
30
|
+
export function parsePgDumpMajor(raw) {
|
|
31
|
+
const match = raw.match(/\(PostgreSQL\)\s+(\d+)/) ?? raw.match(/(\d+)(?:\.\d+)+/);
|
|
32
|
+
return match ? Number(match[1]) : null;
|
|
33
|
+
}
|
|
34
|
+
/** 本地 pg_restore 主版本(拿不到 → null,由调用方决定 fail-open 还是 fail-closed)。 */
|
|
35
|
+
export async function localPgRestoreMajor() {
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
|
|
38
|
+
return parsePgDumpMajor(stdout.trim());
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* pg_restore 17 起每次恢复都先发 `SET transaction_timeout = 0`,这个参数 PostgreSQL 17
|
|
46
|
+
* 才有,恢复进 16 及更老的服务端第一句就报 unrecognized configuration parameter。
|
|
47
|
+
* 上游定性为设计如此(pgsql-bugs 2024-10),不会修,只能绕开。
|
|
48
|
+
*/
|
|
49
|
+
export function restoreToolBreaksOnTarget(toolMajor, targetMajor) {
|
|
50
|
+
return toolMajor >= 17 && targetMajor < 17;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 恢复前的工具/目标版本闸:返回拒绝理由,可放行时返回 null。dry-run 与正式恢复共用,
|
|
54
|
+
* 保证两条路径的判定和给用户的出路一字不差。出路有两条:目标升到 17+;或当归档
|
|
55
|
+
* 本身只需要 ≤16 的 pg_restore(旧版 pg_dump 写的)时,换一个与目标同版本的旧客户端。
|
|
56
|
+
*/
|
|
57
|
+
export function restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor) {
|
|
58
|
+
if (!restoreToolBreaksOnTarget(toolMajor, targetMajor))
|
|
59
|
+
return null;
|
|
60
|
+
const olderClient = requiredMajor <= targetMajor
|
|
61
|
+
? ` or point BACKUPDRILL_PG_RESTORE at a pg_restore ${targetMajor} (this archive only needs v${requiredMajor})`
|
|
62
|
+
: "";
|
|
63
|
+
return (`local pg_restore is v${toolMajor}, which sends SET transaction_timeout — a setting PostgreSQL ` +
|
|
64
|
+
`${targetMajor} does not have, so the restore would fail on its first statement. ` +
|
|
65
|
+
`Create the target on PostgreSQL 17 or newer${olderClient}.`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 演练沙箱的主版本:源库与本机 pg_restore 取大者。pg_dump/pg_restore 的输出只保证能进
|
|
69
|
+
* 与工具同版本或更新的服务端(官方文档原话:不保证能载入更老的主版本)——除了
|
|
70
|
+
* restoreToolBreaksOnTarget 这条已知的,还有新版 pg_dump 生成旧服务端不认识的语法
|
|
71
|
+
* (交叉审查实测:pg_dump 18 导 PG17 的继承表)。往更新的主版本恢复是官方支持的方向,
|
|
72
|
+
* 反过来要求用户装与源库同版本的旧工具行不通(旧 pg_restore 读不了新 pg_dump 的归档)。
|
|
73
|
+
* 拿不到工具版本时退回源版本。
|
|
74
|
+
*/
|
|
75
|
+
export function sandboxMajorFor(sourceMajor, toolMajor) {
|
|
76
|
+
return toolMajor === null ? sourceMajor : Math.max(sourceMajor, toolMajor);
|
|
77
|
+
}
|
package/dist/restore.js
CHANGED
|
@@ -10,22 +10,9 @@ import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorag
|
|
|
10
10
|
import { verifyRestored } from "./drill.js";
|
|
11
11
|
import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
|
|
12
12
|
import { connectPg } from "./supabase-ca.js";
|
|
13
|
-
import {
|
|
13
|
+
import { localPgRestoreMajor, restoreToolTargetBlocker } from "./pgbin.js";
|
|
14
14
|
import { parsePgDumpMajor } from "./backup.js";
|
|
15
|
-
import { execFile } from "node:child_process";
|
|
16
|
-
import { promisify } from "node:util";
|
|
17
15
|
import { log } from "./log.js";
|
|
18
|
-
const execFileAsync = promisify(execFile);
|
|
19
|
-
/** 本地 pg_restore 主版本(拿不到 → null,由引擎自己的报错兜底)。 */
|
|
20
|
-
async function localPgRestoreMajor() {
|
|
21
|
-
try {
|
|
22
|
-
const { stdout } = await execFileAsync(resolvePgRestoreBin(), ["--version"]);
|
|
23
|
-
return parsePgDumpMajor(stdout.trim());
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
16
|
/** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
|
|
30
17
|
export function requiredRestoreToolMajor(manifest) {
|
|
31
18
|
return Math.max(parsePgDumpMajor(manifest.database.pgDumpVersion) ?? 0, parseInt(manifest.database.serverVersion, 10) || 0);
|
|
@@ -272,6 +259,13 @@ async function dryRunPreflight(config, manifest, targetUrl, storageTarget) {
|
|
|
272
259
|
else {
|
|
273
260
|
log.ok(`pg_restore tool: local v${toolMajor} ≥ archive requirement v${requiredMajor}`);
|
|
274
261
|
}
|
|
262
|
+
const toolTargetBlocker = toolMajor !== null && Number.isFinite(targetMajor)
|
|
263
|
+
? restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor)
|
|
264
|
+
: null;
|
|
265
|
+
if (toolTargetBlocker) {
|
|
266
|
+
blockers.push(`local pg_restore v${toolMajor} cannot restore into PostgreSQL ${targetMajor}`);
|
|
267
|
+
log.error(`pg_restore tool: ${toolTargetBlocker}`);
|
|
268
|
+
}
|
|
275
269
|
const extensions = db.extensions ?? [];
|
|
276
270
|
if (extensions.length) {
|
|
277
271
|
const available = await client.query(`select name from pg_available_extensions where name = any($1::text[])`, [extensions.map((e) => e.name)]);
|
|
@@ -447,6 +441,11 @@ export async function runRestore(config, opts) {
|
|
|
447
441
|
`(archive format follows the writing tool). Install postgresql client ${requiredMajor}+ ` +
|
|
448
442
|
`and/or point BACKUPDRILL_PG_RESTORE at it.`);
|
|
449
443
|
}
|
|
444
|
+
// 工具/目标版本闸:pg_restore 17+ 恢复进 ≤16 的目标第一句就失败。在下载与装扩展
|
|
445
|
+
// 之前拦下,别等恢复到一半才炸;旧 pg_restore 又读不了新归档,所以唯一出路是新目标
|
|
446
|
+
const toolTargetBlocker = restoreToolTargetBlocker(toolMajor, targetMajor, requiredMajor);
|
|
447
|
+
if (toolTargetBlocker)
|
|
448
|
+
throw new Error(toolTargetBlocker);
|
|
450
449
|
log.step("Downloading dump…");
|
|
451
450
|
const dumpPath = join(workdir, "dump.pgcustom");
|
|
452
451
|
const { sha256 } = await downloadToFile(s3, config.storage.bucket, manifest.dump.key, dumpPath);
|
package/package.json
CHANGED