backupdrill 2.3.0 → 2.4.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/backup.d.ts +5 -1
- package/dist/backup.js +28 -58
- package/dist/config.d.ts +8 -2
- package/dist/config.js +7 -0
- package/dist/drill.d.ts +3 -0
- package/dist/drill.js +19 -12
- package/dist/lib.d.ts +4 -2
- package/dist/lib.js +3 -1
- package/dist/restore.d.ts +3 -0
- package/dist/restore.js +15 -11
- package/dist/snapshot-store.d.ts +54 -0
- package/dist/snapshot-store.js +76 -0
- package/dist/snapshots.d.ts +1 -1
- package/dist/snapshots.js +11 -8
- package/package.json +1 -1
package/dist/backup.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { BackupConfig } from "./config.js";
|
|
2
|
+
import { type SnapshotStore } from "./snapshot-store.js";
|
|
2
3
|
import type { Manifest } from "./manifest.js";
|
|
3
4
|
import { parsePgDumpMajor } from "./pgbin.js";
|
|
4
5
|
export { parsePgDumpMajor };
|
|
5
|
-
export declare function runBackup(config: BackupConfig
|
|
6
|
+
export declare function runBackup(config: BackupConfig, opts?: {
|
|
7
|
+
/** 快照写到哪;省略 = config.storage 指的 S3 兼容桶 */
|
|
8
|
+
store?: SnapshotStore;
|
|
9
|
+
}): Promise<Manifest>;
|
package/dist/backup.js
CHANGED
|
@@ -2,18 +2,15 @@ import { spawn, execFile } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { Transform } from "node:stream";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import {
|
|
6
|
-
import { Upload } from "@aws-sdk/lib-storage";
|
|
5
|
+
import { s3SnapshotStore } from "./snapshot-store.js";
|
|
7
6
|
import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
|
|
8
7
|
import { syncStorage } from "./storage.js";
|
|
9
8
|
import { inspectBucketAttrs } from "./storage-catalog.js";
|
|
10
|
-
import { renderRecoveryDoc } from "./recovery-doc.js";
|
|
11
9
|
import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
|
|
12
10
|
import { log } from "./log.js";
|
|
13
11
|
import { TOOL_VERSION } from "./version.js";
|
|
14
12
|
import { parsePgDumpMajor } from "./pgbin.js";
|
|
15
13
|
import { relationBytes } from "./estimate.js";
|
|
16
|
-
import { dumpPartSizeFor } from "./part-size.js";
|
|
17
14
|
// 解析函数搬到了 pgbin(演练与恢复都要读 pg_restore 版本,放这里会成环);保留旧出口
|
|
18
15
|
export { parsePgDumpMajor };
|
|
19
16
|
import { dumpUrlFor, connectPg, isSupabaseHost } from "./supabase-ca.js";
|
|
@@ -93,8 +90,8 @@ async function inspectDatabase(databaseUrl, schemas) {
|
|
|
93
90
|
await client.end();
|
|
94
91
|
}
|
|
95
92
|
}
|
|
96
|
-
/** 流式跑 pg_dump
|
|
97
|
-
async function
|
|
93
|
+
/** 流式跑 pg_dump,边写快照边算 sha256 与字节数。返回校验和与大小。 */
|
|
94
|
+
async function dumpToSnapshot(config, writer, pgDumpBin, dumpRelationBytes) {
|
|
98
95
|
const hash = createHash("sha256");
|
|
99
96
|
let bytes = 0;
|
|
100
97
|
const meter = new Transform({
|
|
@@ -136,33 +133,26 @@ async function dumpToS3(config, s3, key, pgDumpBin, partSize) {
|
|
|
136
133
|
? resolve()
|
|
137
134
|
: reject(new Error(`pg_dump exited with code ${code}: ${stderr.trim()}`)));
|
|
138
135
|
});
|
|
139
|
-
const upload = new Upload({
|
|
140
|
-
client: s3,
|
|
141
|
-
partSize,
|
|
142
|
-
params: {
|
|
143
|
-
Bucket: config.storage.bucket,
|
|
144
|
-
Key: key,
|
|
145
|
-
Body: meter,
|
|
146
|
-
ContentType: "application/octet-stream",
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
const uploadDone = upload.done();
|
|
150
136
|
// 失败双向联动:dump 失败要中止上传(绝不把半截转储当成功);上传失败也必须
|
|
151
137
|
// 杀掉 pg_dump —— 否则上传侧停止消费后,背压会让 pg_dump 永远阻塞在 stdout
|
|
152
138
|
// 写入上,调用方(worker 的串行队列)随之永久挂起。Promise.all 会订阅两侧,
|
|
153
139
|
// 先失败的一方抛出后,另一方稍后的 rejection 也已被视为 handled。
|
|
140
|
+
// 开始上传也放进 try:实现可能在建上传时就同步抛错,那时 pg_dump 已经在跑,同样要杀掉。
|
|
141
|
+
let upload = null;
|
|
154
142
|
try {
|
|
155
|
-
|
|
143
|
+
upload = writer.uploadDump(meter, dumpRelationBytes);
|
|
144
|
+
await Promise.all([dumpDone, upload.done]);
|
|
156
145
|
}
|
|
157
146
|
catch (error) {
|
|
147
|
+
dumpDone.catch(() => { }); // 没走到 Promise.all 时,被杀的 pg_dump 的退出也得有人接住
|
|
158
148
|
dump.kill("SIGKILL");
|
|
159
149
|
meter.destroy();
|
|
160
|
-
await upload
|
|
150
|
+
await upload?.abort().catch(() => { });
|
|
161
151
|
throw error;
|
|
162
152
|
}
|
|
163
153
|
return { bytes, sha256: hash.digest("hex") };
|
|
164
154
|
}
|
|
165
|
-
export async function runBackup(config) {
|
|
155
|
+
export async function runBackup(config, opts = {}) {
|
|
166
156
|
// 源身份先于一切传输(评审第 12 轮):①连接串不许带 ?host=/user= 覆盖——否则
|
|
167
157
|
// 快照记的 ref 与实际读取对象脱节,恢复端的同源保护随之失真;②库与 Storage
|
|
168
158
|
// 跨项目 = 配置错误,全量 dump+同步之后才发现会白烧 egress 还留下半截快照前缀
|
|
@@ -190,29 +180,26 @@ export async function runBackup(config) {
|
|
|
190
180
|
`pg_dump must be >= the server major version. Install postgresql-client-${serverMajor} ` +
|
|
191
181
|
`(or newer) and/or point BACKUPDRILL_PG_DUMP at it.`);
|
|
192
182
|
}
|
|
193
|
-
const
|
|
194
|
-
region: config.storage.region,
|
|
195
|
-
endpoint: config.storage.endpoint,
|
|
196
|
-
forcePathStyle: config.storage.forcePathStyle,
|
|
197
|
-
credentials: {
|
|
198
|
-
accessKeyId: config.storage.accessKeyId,
|
|
199
|
-
secretAccessKey: config.storage.secretAccessKey,
|
|
200
|
-
},
|
|
201
|
-
});
|
|
183
|
+
const store = opts.store ?? s3SnapshotStore(config);
|
|
202
184
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
185
|
+
const writer = await store.createSnapshot(timestamp);
|
|
186
|
+
const storageFiles = writer.storageFiles;
|
|
187
|
+
// 在转储之前拦下:目标放不了 Storage 文件却配了 Storage,转储完才发现等于白跑一遍
|
|
188
|
+
if (config.supabaseStorage && !storageFiles) {
|
|
189
|
+
throw new Error("Storage file backup needs an S3-compatible bucket; this backup destination only holds the database.");
|
|
190
|
+
}
|
|
191
|
+
const dumpKey = writer.dumpKey;
|
|
192
|
+
log.step(`Dumping → ${writer.describe("dump.pgcustom")}`);
|
|
193
|
+
const { bytes, sha256 } = await dumpToSnapshot(config, writer, pgDumpBin, db.relationBytes);
|
|
207
194
|
log.ok(`Database dumped (${(bytes / 1024 / 1024).toFixed(1)} MB, sha256 ${sha256.slice(0, 12)}…)`);
|
|
208
195
|
// Storage 文件同步(源配置存在才做;否则只备数据库)
|
|
209
196
|
let storageResult = null;
|
|
210
|
-
if (config.supabaseStorage) {
|
|
197
|
+
if (config.supabaseStorage && storageFiles) {
|
|
211
198
|
log.step("Syncing Storage files…");
|
|
212
199
|
const synced = await syncStorage(config.supabaseStorage, {
|
|
213
|
-
client: s3,
|
|
214
|
-
bucket:
|
|
215
|
-
base, // syncStorage 内部会加 /storage/<bucket>/<key>
|
|
200
|
+
client: storageFiles.s3,
|
|
201
|
+
bucket: storageFiles.bucket,
|
|
202
|
+
base: storageFiles.base, // syncStorage 内部会加 /storage/<bucket>/<key>
|
|
216
203
|
});
|
|
217
204
|
// v2 bucket 属性采集,通道 = 已有的只读库连接(per-file 元数据已随 GetObject
|
|
218
205
|
// 响应在 syncStorage 内原子捕获)。失败只降级不报错:storage schema 读不到
|
|
@@ -257,26 +244,9 @@ export async function runBackup(config) {
|
|
|
257
244
|
// RECOVERY.md(PRD §5.9.2.1)先于 manifest 落桶:manifest 是快照的"完成标记"
|
|
258
245
|
// (演练/恢复按它找快照)——先写手册再写标记,"快照完成"就蕴含"手册在场";
|
|
259
246
|
// 手册写失败 = 备份失败(同桶同权限,与 manifest 自身的写入风险一致)。
|
|
260
|
-
await
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
snapshot: timestamp,
|
|
265
|
-
bucket: config.storage.bucket,
|
|
266
|
-
endpoint: config.storage.endpoint,
|
|
267
|
-
region: config.storage.region,
|
|
268
|
-
prefix: config.storage.prefix,
|
|
269
|
-
projectName: config.projectName,
|
|
270
|
-
}),
|
|
271
|
-
ContentType: "text/markdown",
|
|
272
|
-
}));
|
|
273
|
-
log.ok(`Recovery runbook written → s3://${config.storage.bucket}/${base}/RECOVERY.md`);
|
|
274
|
-
await s3.send(new PutObjectCommand({
|
|
275
|
-
Bucket: config.storage.bucket,
|
|
276
|
-
Key: `${base}/manifest.json`,
|
|
277
|
-
Body: JSON.stringify(manifest, null, 2),
|
|
278
|
-
ContentType: "application/json",
|
|
279
|
-
}));
|
|
280
|
-
log.ok(`Manifest written → s3://${config.storage.bucket}/${base}/manifest.json`);
|
|
247
|
+
await writer.writeRecoveryDoc(manifest);
|
|
248
|
+
log.ok(`Recovery runbook written → ${writer.describe("RECOVERY.md")}`);
|
|
249
|
+
await writer.writeManifest(manifest);
|
|
250
|
+
log.ok(`Manifest written → ${writer.describe("manifest.json")}`);
|
|
281
251
|
return manifest;
|
|
282
252
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -26,9 +26,15 @@ export interface BackupConfig {
|
|
|
26
26
|
databaseUrl: string;
|
|
27
27
|
projectName: string;
|
|
28
28
|
schemas: string[];
|
|
29
|
-
storage
|
|
29
|
+
storage?: StorageConfig;
|
|
30
30
|
supabaseStorage?: SupabaseStorageConfig;
|
|
31
31
|
}
|
|
32
|
+
/** CLI 读出来的配置一定带桶:loadConfig 缺了桶直接报错,所以返回类型里 storage 是必填 */
|
|
33
|
+
export type LoadedBackupConfig = BackupConfig & {
|
|
34
|
+
storage: StorageConfig;
|
|
35
|
+
};
|
|
36
|
+
/** 需要桶的地方先过这一道:没配桶时给一句能看懂的报错,而不是读 undefined 的属性。 */
|
|
37
|
+
export declare function requireBucket(config: BackupConfig): StorageConfig;
|
|
32
38
|
interface RawConfig {
|
|
33
39
|
databaseUrl?: string;
|
|
34
40
|
projectName?: string;
|
|
@@ -40,5 +46,5 @@ interface RawConfig {
|
|
|
40
46
|
export declare function loadConfig(opts: {
|
|
41
47
|
configPath: string;
|
|
42
48
|
overrides: RawConfig;
|
|
43
|
-
}): Promise<
|
|
49
|
+
}): Promise<LoadedBackupConfig>;
|
|
44
50
|
export {};
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
/** 需要桶的地方先过这一道:没配桶时给一句能看懂的报错,而不是读 undefined 的属性。 */
|
|
3
|
+
export function requireBucket(config) {
|
|
4
|
+
if (!config.storage) {
|
|
5
|
+
throw new Error("No S3-compatible bucket configured (storage) — set storage.bucket and its keys.");
|
|
6
|
+
}
|
|
7
|
+
return config.storage;
|
|
8
|
+
}
|
|
2
9
|
function fromEnv() {
|
|
3
10
|
const env = process.env;
|
|
4
11
|
const storage = {};
|
package/dist/drill.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BackupConfig } from "./config.js";
|
|
2
2
|
import type { Manifest } from "./manifest.js";
|
|
3
|
+
import { type SnapshotStore } from "./snapshot-store.js";
|
|
3
4
|
/**
|
|
4
5
|
* 无放回随机抽样:部分 Fisher-Yates,只洗出前 n 位(O(n) 次交换,不必全 shuffle)。
|
|
5
6
|
* 此前恒取 manifest 顺序的前 CAP 个 → 超过 CAP 的项目里排序靠后的文件永远不被校验;
|
|
@@ -92,5 +93,7 @@ export declare function runDrill(config: BackupConfig, opts?: {
|
|
|
92
93
|
appCheckCommand?: string;
|
|
93
94
|
keepSandboxOnFailure?: boolean;
|
|
94
95
|
supervision?: DrillSupervision;
|
|
96
|
+
/** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
|
|
97
|
+
store?: SnapshotStore;
|
|
95
98
|
}): Promise<DrillReport>;
|
|
96
99
|
export {};
|
package/dist/drill.js
CHANGED
|
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { parseManifest } from "./manifest.js";
|
|
7
|
-
import {
|
|
7
|
+
import { hashObject } from "./snapshots.js";
|
|
8
|
+
import { s3SnapshotStore } from "./snapshot-store.js";
|
|
8
9
|
import { SANDBOX_MANAGED_ERROR, classifyBlocks, finalizePass, installExtensions, quoteIdent, restoreDatabaseArtifact, installSandboxShim, } from "./restore-engine.js";
|
|
9
10
|
import { connectPg } from "./supabase-ca.js";
|
|
10
11
|
import { localPgRestoreMajor, restoreToolBreaksOnTarget, sandboxMajorFor } from "./pgbin.js";
|
|
@@ -329,16 +330,15 @@ export async function verifyRestored(connString, manifest) {
|
|
|
329
330
|
* Storage 完整性校验:从备份桶重新读回文件、比对 manifest 里记录的 sha256,
|
|
330
331
|
* 证明备份的文件副本真实存在且未损坏。文件多时抽样(默认上限),避免大量 egress。
|
|
331
332
|
*/
|
|
332
|
-
async function verifyStorage(
|
|
333
|
+
async function verifyStorage(location, manifest, verifyAll) {
|
|
333
334
|
const files = manifest.storage.files;
|
|
334
335
|
const sample = verifyAll ? files : sampleStorageFiles(files, STORAGE_SAMPLE_CAP);
|
|
335
|
-
// 备份写入时的键:<snapshot base>/storage/<bucket>/<key
|
|
336
|
-
const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
|
|
336
|
+
// 备份写入时的键:<snapshot base>/storage/<bucket>/<key>
|
|
337
337
|
const mismatches = [];
|
|
338
338
|
for (const f of sample) {
|
|
339
|
-
const objectKey = `${base}/storage/${f.bucket}/${f.key}`;
|
|
339
|
+
const objectKey = `${location.base}/storage/${f.bucket}/${f.key}`;
|
|
340
340
|
try {
|
|
341
|
-
const { sha256 } = await hashObject(s3,
|
|
341
|
+
const { sha256 } = await hashObject(location.s3, location.bucket, objectKey);
|
|
342
342
|
if (sha256 !== f.sha256)
|
|
343
343
|
mismatches.push(`${f.bucket}/${f.key}`);
|
|
344
344
|
}
|
|
@@ -569,17 +569,17 @@ export async function drillDump(dumpPath, manifest, snapshot, preChecks = [], op
|
|
|
569
569
|
/** 演练一份快照:从桶里下载→drillDump(含 Storage 校验)→报告。 */
|
|
570
570
|
export async function runDrill(config, opts = {}) {
|
|
571
571
|
validateSupervision(opts.supervision, opts.appCheckCommand, opts.keepSandboxOnFailure);
|
|
572
|
-
const
|
|
573
|
-
const
|
|
574
|
-
const snapshot =
|
|
572
|
+
const store = opts.store ?? s3SnapshotStore(config);
|
|
573
|
+
const reader = await store.openSnapshot(opts.snapshot);
|
|
574
|
+
const snapshot = reader.name;
|
|
575
575
|
log.step(`Drilling snapshot ${snapshot}`);
|
|
576
|
-
const manifest = parseManifest(await
|
|
576
|
+
const manifest = parseManifest(await reader.readManifest());
|
|
577
577
|
const workdir = opts.supervision?.workdir ?? await mkdtemp(join(tmpdir(), "backupdrill-"));
|
|
578
578
|
const dumpPath = join(workdir, "dump.pgcustom");
|
|
579
579
|
try {
|
|
580
580
|
await opts.supervision?.observe({ phase: "download", dumpBytes: manifest.dump.bytes });
|
|
581
581
|
log.step("Downloading dump…");
|
|
582
|
-
const { sha256: sha } = await
|
|
582
|
+
const { sha256: sha } = await reader.downloadDump(manifest, dumpPath);
|
|
583
583
|
const integrityPass = sha === manifest.dump.sha256;
|
|
584
584
|
const preChecks = [
|
|
585
585
|
{
|
|
@@ -605,7 +605,14 @@ export async function runDrill(config, opts = {}) {
|
|
|
605
605
|
if (manifest.storage && manifest.storage.files.length) {
|
|
606
606
|
await opts.supervision?.observe({ phase: "storage" });
|
|
607
607
|
log.step("Verifying Storage files…");
|
|
608
|
-
|
|
608
|
+
const location = reader.storageFiles(manifest);
|
|
609
|
+
preChecks.push(location
|
|
610
|
+
? await verifyStorage(location, manifest, opts.verifyAllFiles ?? false)
|
|
611
|
+
: {
|
|
612
|
+
name: "storage file integrity",
|
|
613
|
+
pass: false,
|
|
614
|
+
detail: "the manifest lists Storage files, but this snapshot location cannot hold them",
|
|
615
|
+
});
|
|
609
616
|
}
|
|
610
617
|
return await drillDump(dumpPath, manifest, snapshot, preChecks, {
|
|
611
618
|
appCheckCommand: opts.appCheckCommand,
|
package/dist/lib.d.ts
CHANGED
|
@@ -5,9 +5,11 @@ export { runRestore } from "./restore.js";
|
|
|
5
5
|
export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
|
|
6
6
|
export { sameDatabaseTarget } from "./restore.js";
|
|
7
7
|
export { runEstimate, project } from "./estimate.js";
|
|
8
|
-
export { loadConfig } from "./config.js";
|
|
8
|
+
export { loadConfig, requireBucket } from "./config.js";
|
|
9
9
|
export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
10
|
-
export
|
|
10
|
+
export { s3SnapshotStore } from "./snapshot-store.js";
|
|
11
|
+
export type { SnapshotStore, SnapshotWriter, SnapshotReader, DumpUpload, StorageFilesLocation, } from "./snapshot-store.js";
|
|
12
|
+
export type { BackupConfig, LoadedBackupConfig, StorageConfig, SupabaseStorageConfig, } from "./config.js";
|
|
11
13
|
export type { BucketAttrs, ExtensionInfo, Manifest, StorageFile, TableStat } from "./manifest.js";
|
|
12
14
|
export type { DrillReport, DrillCheck, DrillSupervision, DrillPhase } from "./drill.js";
|
|
13
15
|
export type { RestoreResult } from "./restore.js";
|
package/dist/lib.js
CHANGED
|
@@ -10,5 +10,7 @@ export { runRestore } from "./restore.js";
|
|
|
10
10
|
export { projectRefOf, assertNoHostOverride, libpqChildEnv } from "./restore-engine.js";
|
|
11
11
|
export { sameDatabaseTarget } from "./restore.js";
|
|
12
12
|
export { runEstimate, project } from "./estimate.js";
|
|
13
|
-
export { loadConfig } from "./config.js";
|
|
13
|
+
export { loadConfig, requireBucket } from "./config.js";
|
|
14
14
|
export { targetClient, resolveSnapshot, getObjectText, downloadToFile, hashObject, } from "./snapshots.js";
|
|
15
|
+
// 快照位置接口:备份、演练、恢复都可以传入自己的实现(托管 worker 的 Google Drive 目标)
|
|
16
|
+
export { s3SnapshotStore } from "./snapshot-store.js";
|
package/dist/restore.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BackupConfig } from "./config.js";
|
|
2
2
|
import type { Manifest } from "./manifest.js";
|
|
3
|
+
import { type SnapshotStore } from "./snapshot-store.js";
|
|
3
4
|
import { type DrillCheck } from "./drill.js";
|
|
4
5
|
import { type StorageRestoreSummary } from "./storage-restore.js";
|
|
5
6
|
/** 归档可读性要求:pg_restore 必须 ≥ max(写入工具版本, 源服务端版本)——格式跟随写入工具。 */
|
|
@@ -88,4 +89,6 @@ export declare function runRestore(config: BackupConfig, opts: {
|
|
|
88
89
|
skipStorage?: boolean;
|
|
89
90
|
snapshot?: string;
|
|
90
91
|
storageDir?: string;
|
|
92
|
+
/** 快照从哪读;省略 = config.storage 指的 S3 兼容桶 */
|
|
93
|
+
store?: SnapshotStore;
|
|
91
94
|
}): Promise<RestoreResult>;
|
package/dist/restore.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname, resolve, sep } from "node:path";
|
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
|
7
7
|
import { parseManifest } from "./manifest.js";
|
|
8
|
-
import {
|
|
8
|
+
import { s3SnapshotStore } from "./snapshot-store.js";
|
|
9
9
|
import { restoreDatabaseArtifact, installExtensions, projectRefOf, refFromStorageEndpoint, assertNoHostOverride, normalizeHost, } from "./restore-engine.js";
|
|
10
10
|
import { verifyRestored } from "./drill.js";
|
|
11
11
|
import { restoreStorage, planStorageRestore, } from "./storage-restore.js";
|
|
@@ -335,11 +335,10 @@ export async function runRestore(config, opts) {
|
|
|
335
335
|
assertNoHostOverride(config.databaseUrl);
|
|
336
336
|
if (opts.targetDatabaseUrl)
|
|
337
337
|
assertNoHostOverride(opts.targetDatabaseUrl);
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
const snapshot =
|
|
341
|
-
const manifest = parseManifest(await
|
|
342
|
-
const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
|
|
338
|
+
const store = opts.store ?? s3SnapshotStore(config);
|
|
339
|
+
const reader = await store.openSnapshot(opts.snapshot);
|
|
340
|
+
const snapshot = reader.name;
|
|
341
|
+
const manifest = parseManifest(await reader.readManifest());
|
|
343
342
|
// Storage 目标构造即校验(https + 恰为 <ref>.supabase.co):service key 只发往
|
|
344
343
|
// 已验证的 Supabase 项目源;目标库连接串拒绝 host 覆盖(身份判定=实际写入目标)
|
|
345
344
|
// 两个字段必须成对:只给其一时静默退回"本地下载"会让库级调用方把受保护文件
|
|
@@ -389,6 +388,11 @@ export async function runRestore(config, opts) {
|
|
|
389
388
|
// 这次运行会不会碰 Storage(回传到目标,或下载到本地)。只有会碰时,空目标门才该
|
|
390
389
|
// 提示"库已恢复、只重试 Storage";skipStorage 的纯数据库运行(托管恢复)不该看到这句。
|
|
391
390
|
const alsoRestoringStorage = !!manifest.storage && !opts.skipStorage;
|
|
391
|
+
const storageSource = manifest.storage ? reader.storageFiles(manifest) : null;
|
|
392
|
+
if (alsoRestoringStorage && !storageSource) {
|
|
393
|
+
throw new Error("the manifest lists Storage files, but this snapshot location cannot hold them — " +
|
|
394
|
+
"restore the database only (skip Storage).");
|
|
395
|
+
}
|
|
392
396
|
// dry-run:只读预检后直接返回,零写入(PRD §5.3.2)
|
|
393
397
|
if (opts.dryRun) {
|
|
394
398
|
log.step(`Dry run for snapshot ${snapshot} — nothing will be written`);
|
|
@@ -461,7 +465,7 @@ export async function runRestore(config, opts) {
|
|
|
461
465
|
throw new Error(toolTargetBlocker);
|
|
462
466
|
log.step("Downloading dump…");
|
|
463
467
|
const dumpPath = join(workdir, "dump.pgcustom");
|
|
464
|
-
const { sha256 } = await
|
|
468
|
+
const { sha256 } = await reader.downloadDump(manifest, dumpPath);
|
|
465
469
|
// 归档完整性是恢复的前提:哈希不符还继续,只会把"备份坏了"变成一堆误导性
|
|
466
470
|
// 恢复错误(演练侧同一裁决:integrity FAIL 即短路)
|
|
467
471
|
if (sha256 !== manifest.dump.sha256) {
|
|
@@ -505,7 +509,7 @@ export async function runRestore(config, opts) {
|
|
|
505
509
|
// DB-only 阶段(hosted 检查点重试):既不回传也不本地下载,Storage 单独一段跑
|
|
506
510
|
}
|
|
507
511
|
else if (manifest.storage && storageTarget) {
|
|
508
|
-
const summary = await restoreStorage(
|
|
512
|
+
const summary = await restoreStorage(storageSource, storageTarget, manifest);
|
|
509
513
|
result.storageSummary = summary;
|
|
510
514
|
result.storageFilesWritten = summary.filesUploaded;
|
|
511
515
|
log.ok(`Storage restored: ${summary.filesUploaded}/${manifest.storage.files.length} files uploaded` +
|
|
@@ -568,9 +572,9 @@ export async function runRestore(config, opts) {
|
|
|
568
572
|
throw new Error(`unsafe storage path escapes output directory: ${f.bucket}/${f.key}`);
|
|
569
573
|
}
|
|
570
574
|
await mkdir(dirname(dest), { recursive: true });
|
|
571
|
-
const res = await s3.send(new GetObjectCommand({
|
|
572
|
-
Bucket:
|
|
573
|
-
Key: `${base}/storage/${f.bucket}/${f.key}`,
|
|
575
|
+
const res = await storageSource.s3.send(new GetObjectCommand({
|
|
576
|
+
Bucket: storageSource.bucket,
|
|
577
|
+
Key: `${storageSource.base}/storage/${f.bucket}/${f.key}`,
|
|
574
578
|
}));
|
|
575
579
|
await pipeline(res.Body, createWriteStream(dest));
|
|
576
580
|
result.storageFilesWritten += 1;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { type S3Client } from "@aws-sdk/client-s3";
|
|
3
|
+
import { type BackupConfig } from "./config.js";
|
|
4
|
+
import type { Manifest } from "./manifest.js";
|
|
5
|
+
/**
|
|
6
|
+
* 快照放在哪里,对引擎来说只是这几个动作:建一份新快照往里写、打开一份已有快照往外读。
|
|
7
|
+
* 备份、演练、恢复都只经这层读写快照。CLI 只用 S3 兼容桶(s3SnapshotStore);托管服务
|
|
8
|
+
* 的 worker 可以传入自己的实现(Google Drive 目标,实现不进公开包)。
|
|
9
|
+
*/
|
|
10
|
+
export interface SnapshotStore {
|
|
11
|
+
/** name = 快照名(备份开始时的 UTC 时间戳) */
|
|
12
|
+
createSnapshot(name: string): Promise<SnapshotWriter>;
|
|
13
|
+
/** name 省略 = 最新一份 */
|
|
14
|
+
openSnapshot(name?: string): Promise<SnapshotReader>;
|
|
15
|
+
}
|
|
16
|
+
/** 快照里 Storage 文件的位置(<base>/storage/<bucket>/<key>)。只有 S3 兼容目标能放 Storage 文件。 */
|
|
17
|
+
export interface StorageFilesLocation {
|
|
18
|
+
s3: S3Client;
|
|
19
|
+
bucket: string;
|
|
20
|
+
base: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DumpUpload {
|
|
23
|
+
/** 转储全部写完才 resolve;任何失败都 reject */
|
|
24
|
+
done: Promise<void>;
|
|
25
|
+
/** 中止写入,尽量不留下半截文件 */
|
|
26
|
+
abort(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export interface SnapshotWriter {
|
|
29
|
+
/** 记进 manifest.dump.key 的值 */
|
|
30
|
+
readonly dumpKey: string;
|
|
31
|
+
/** 这份快照能不能放 Storage 文件;不能时为 null */
|
|
32
|
+
readonly storageFiles: StorageFilesLocation | null;
|
|
33
|
+
/** 日志里显示的位置 */
|
|
34
|
+
describe(file: string): string;
|
|
35
|
+
/** relationBytes = 要转储的表的磁盘占用(量不出为 null),实现可据此决定分块 */
|
|
36
|
+
uploadDump(body: Readable, relationBytes: number | null): DumpUpload;
|
|
37
|
+
/** RECOVERY.md 写的是"从这个位置怎么恢复",所以由各实现自己渲染 */
|
|
38
|
+
writeRecoveryDoc(manifest: Manifest): Promise<void>;
|
|
39
|
+
/** manifest 是快照的"完成标记",调用方保证最后写 */
|
|
40
|
+
writeManifest(manifest: Manifest): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface SnapshotReader {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readManifest(): Promise<string>;
|
|
45
|
+
/** 断点续传下载转储到本地文件,返回实际落盘的字节数与 sha256 */
|
|
46
|
+
downloadDump(manifest: Manifest, dest: string): Promise<{
|
|
47
|
+
bytes: number;
|
|
48
|
+
sha256: string;
|
|
49
|
+
}>;
|
|
50
|
+
/** 这份快照的 Storage 文件从哪读;实现不支持 Storage 文件时为 null */
|
|
51
|
+
storageFiles(manifest: Manifest): StorageFilesLocation | null;
|
|
52
|
+
}
|
|
53
|
+
/** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
|
|
54
|
+
export declare function s3SnapshotStore(config: BackupConfig): SnapshotStore;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { Upload } from "@aws-sdk/lib-storage";
|
|
3
|
+
import { requireBucket } from "./config.js";
|
|
4
|
+
import { targetClient, resolveSnapshot, getObjectText, downloadToFile } from "./snapshots.js";
|
|
5
|
+
import { renderRecoveryDoc } from "./recovery-doc.js";
|
|
6
|
+
import { dumpPartSizeFor } from "./part-size.js";
|
|
7
|
+
/** 写入时的键都在 <prefix>/<projectName>/<快照名>/ 下;读取时 Storage 文件的 base 从 dump.key 反推。 */
|
|
8
|
+
function storageBaseOf(manifest) {
|
|
9
|
+
return manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
|
|
10
|
+
}
|
|
11
|
+
/** S3 兼容桶(S3 / R2 / B2 / Wasabi)。行为与抽出这层之前逐字一致。 */
|
|
12
|
+
export function s3SnapshotStore(config) {
|
|
13
|
+
const storage = requireBucket(config);
|
|
14
|
+
let client = null;
|
|
15
|
+
// 客户端推迟到第一次真正读写时才建:与抽层之前一致,配置错误先由前面的检查报出来
|
|
16
|
+
const s3 = () => (client ??= targetClient(config));
|
|
17
|
+
return {
|
|
18
|
+
async createSnapshot(name) {
|
|
19
|
+
const base = `${storage.prefix}/${config.projectName}/${name}`;
|
|
20
|
+
const location = (file) => `s3://${storage.bucket}/${base}/${file}`;
|
|
21
|
+
return {
|
|
22
|
+
dumpKey: `${base}/dump.pgcustom`,
|
|
23
|
+
storageFiles: { s3: s3(), bucket: storage.bucket, base },
|
|
24
|
+
describe: location,
|
|
25
|
+
uploadDump(body, relationBytes) {
|
|
26
|
+
const upload = new Upload({
|
|
27
|
+
client: s3(),
|
|
28
|
+
partSize: dumpPartSizeFor(relationBytes),
|
|
29
|
+
params: {
|
|
30
|
+
Bucket: storage.bucket,
|
|
31
|
+
Key: `${base}/dump.pgcustom`,
|
|
32
|
+
Body: body,
|
|
33
|
+
ContentType: "application/octet-stream",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
done: upload.done().then(() => { }),
|
|
38
|
+
abort: () => upload.abort(),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
async writeRecoveryDoc(manifest) {
|
|
42
|
+
await s3().send(new PutObjectCommand({
|
|
43
|
+
Bucket: storage.bucket,
|
|
44
|
+
Key: `${base}/RECOVERY.md`,
|
|
45
|
+
Body: renderRecoveryDoc(manifest, {
|
|
46
|
+
snapshot: name,
|
|
47
|
+
bucket: storage.bucket,
|
|
48
|
+
endpoint: storage.endpoint,
|
|
49
|
+
region: storage.region,
|
|
50
|
+
prefix: storage.prefix,
|
|
51
|
+
projectName: config.projectName,
|
|
52
|
+
}),
|
|
53
|
+
ContentType: "text/markdown",
|
|
54
|
+
}));
|
|
55
|
+
},
|
|
56
|
+
async writeManifest(manifest) {
|
|
57
|
+
await s3().send(new PutObjectCommand({
|
|
58
|
+
Bucket: storage.bucket,
|
|
59
|
+
Key: `${base}/manifest.json`,
|
|
60
|
+
Body: JSON.stringify(manifest, null, 2),
|
|
61
|
+
ContentType: "application/json",
|
|
62
|
+
}));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
async openSnapshot(name) {
|
|
67
|
+
const prefix = await resolveSnapshot(s3(), config, name);
|
|
68
|
+
return {
|
|
69
|
+
name: prefix.replace(/\/$/, "").split("/").pop(),
|
|
70
|
+
readManifest: () => getObjectText(s3(), storage.bucket, `${prefix}manifest.json`),
|
|
71
|
+
downloadDump: (manifest, dest) => downloadToFile(s3(), storage.bucket, manifest.dump.key, dest),
|
|
72
|
+
storageFiles: (manifest) => ({ s3: s3(), bucket: storage.bucket, base: storageBaseOf(manifest) }),
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/dist/snapshots.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
-
import type
|
|
2
|
+
import { type BackupConfig } from "./config.js";
|
|
3
3
|
/** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
|
|
4
4
|
export declare function targetClient(config: BackupConfig): S3Client;
|
|
5
5
|
/** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
|
package/dist/snapshots.js
CHANGED
|
@@ -4,28 +4,31 @@ import { pipeline } from "node:stream/promises";
|
|
|
4
4
|
import { Writable } from "node:stream";
|
|
5
5
|
import { log } from "./log.js";
|
|
6
6
|
import { S3Client, ListObjectsV2Command, GetObjectCommand, } from "@aws-sdk/client-s3";
|
|
7
|
+
import { requireBucket } from "./config.js";
|
|
7
8
|
/** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
|
|
8
9
|
export function targetClient(config) {
|
|
10
|
+
const storage = requireBucket(config);
|
|
9
11
|
return new S3Client({
|
|
10
|
-
region:
|
|
11
|
-
endpoint:
|
|
12
|
-
forcePathStyle:
|
|
12
|
+
region: storage.region,
|
|
13
|
+
endpoint: storage.endpoint,
|
|
14
|
+
forcePathStyle: storage.forcePathStyle,
|
|
13
15
|
credentials: {
|
|
14
|
-
accessKeyId:
|
|
15
|
-
secretAccessKey:
|
|
16
|
+
accessKeyId: storage.accessKeyId,
|
|
17
|
+
secretAccessKey: storage.secretAccessKey,
|
|
16
18
|
},
|
|
17
19
|
});
|
|
18
20
|
}
|
|
19
21
|
/** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
|
|
20
22
|
export async function resolveSnapshot(s3, config, explicit) {
|
|
21
|
-
const
|
|
23
|
+
const storage = requireBucket(config);
|
|
24
|
+
const root = `${storage.prefix}/${config.projectName}/`;
|
|
22
25
|
if (explicit)
|
|
23
26
|
return `${root}${explicit}/`;
|
|
24
27
|
const prefixes = [];
|
|
25
28
|
let token;
|
|
26
29
|
do {
|
|
27
30
|
const res = await s3.send(new ListObjectsV2Command({
|
|
28
|
-
Bucket:
|
|
31
|
+
Bucket: storage.bucket,
|
|
29
32
|
Prefix: root,
|
|
30
33
|
Delimiter: "/",
|
|
31
34
|
ContinuationToken: token,
|
|
@@ -37,7 +40,7 @@ export async function resolveSnapshot(s3, config, explicit) {
|
|
|
37
40
|
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
|
38
41
|
} while (token);
|
|
39
42
|
if (!prefixes.length) {
|
|
40
|
-
throw new Error(`No snapshots found under s3://${
|
|
43
|
+
throw new Error(`No snapshots found under s3://${storage.bucket}/${root}. Run a backup first.`);
|
|
41
44
|
}
|
|
42
45
|
prefixes.sort();
|
|
43
46
|
return prefixes[prefixes.length - 1];
|
package/package.json
CHANGED