backupdrill 1.1.0 → 2.0.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/README.md +26 -5
- package/dist/backup.js +33 -0
- package/dist/index.js +69 -7
- package/dist/manifest.d.ts +1 -0
- package/dist/manifest.js +8 -0
- package/dist/recovery-doc.d.ts +11 -0
- package/dist/recovery-doc.js +123 -0
- package/dist/restore-engine.d.ts +17 -0
- package/dist/restore-engine.js +127 -8
- package/dist/restore.d.ts +45 -9
- package/dist/restore.js +402 -30
- package/dist/storage-restore.d.ts +79 -0
- package/dist/storage-restore.js +634 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -160,14 +160,20 @@ Your command runs after all structural checks pass, with `BACKUPDRILL_SANDBOX_UR
|
|
|
160
160
|
|
|
161
161
|
**What this can and cannot verify, honestly:** `BACKUPDRILL_SANDBOX_URL` connects as the sandbox **superuser**, and superusers bypass row-level security — plus policies referencing Supabase-managed roles (`authenticated`, `anon`) are skipped during restore, since those roles don't exist outside the platform. So use `--check-cmd` for data and business invariants ("orders reference existing users", "the row count I care about is sane"). It is **not** an RLS behavior test — verifying RLS takes an authenticated client stack with real JWTs, which no restore sandbox can fake generically.
|
|
162
162
|
|
|
163
|
-
When you actually need to recover, `restore` puts the database back into a
|
|
163
|
+
When you actually need to recover, `restore` puts the database back into a fresh
|
|
164
|
+
target project (through the same engine the drills use, with verification) and
|
|
165
|
+
uploads the Storage files back — start with `--dry-run`, then confirm by typing
|
|
166
|
+
the target's project ref:
|
|
164
167
|
|
|
165
168
|
```bash
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
# credentials via hidden prompts (nothing lands in shell history); CI can inject
|
|
170
|
+
# BACKUPDRILL_TARGET_DATABASE_URL / BACKUPDRILL_TARGET_SERVICE_ROLE_KEY as managed secrets instead
|
|
171
|
+
backupdrill restore --database --target-supabase-url https://<ref>.supabase.co --dry-run
|
|
172
|
+
backupdrill restore --database --target-supabase-url https://<ref>.supabase.co --confirm-target <ref>
|
|
169
173
|
```
|
|
170
174
|
|
|
175
|
+
Prefer files on disk instead? Skip the Storage target and pass `--storage-dir ./recovered`.
|
|
176
|
+
|
|
171
177
|
Or restore the dump by hand — into an **empty** database, without `--clean`:
|
|
172
178
|
|
|
173
179
|
```bash
|
|
@@ -180,6 +186,21 @@ carries no ACLs), leaving the restored API returning 401s. One error is expected
|
|
|
180
186
|
harmless: `schema "public" already exists` — the target always has it. The
|
|
181
187
|
`backupdrill restore` command above handles all of this (plus verification) for you.
|
|
182
188
|
|
|
189
|
+
## 2.0 migration — `restore` flags changed
|
|
190
|
+
|
|
191
|
+
Restore now refuses to take credentials on the command line (argv is visible to
|
|
192
|
+
every process on the machine):
|
|
193
|
+
|
|
194
|
+
- `--target-database-url <url>` was **removed**. Set `BACKUPDRILL_TARGET_DATABASE_URL`
|
|
195
|
+
(or let the hidden prompt ask) and pass `--database` to say you want the
|
|
196
|
+
database restored.
|
|
197
|
+
- Writing to a target now requires `--confirm-target <target-ref>` — type the
|
|
198
|
+
target project's ref to confirm. Run `--dry-run` first to preview every check.
|
|
199
|
+
- The service-role key for Storage upload comes from
|
|
200
|
+
`BACKUPDRILL_TARGET_SERVICE_ROLE_KEY` (or the hidden prompt), never a flag.
|
|
201
|
+
|
|
202
|
+
`backup`, `drill` and `estimate` are unchanged.
|
|
203
|
+
|
|
183
204
|
## GitHub Action / scheduled backups
|
|
184
205
|
|
|
185
206
|
Want this running on a schedule without paying for a server? Run it in GitHub Actions.
|
|
@@ -199,7 +220,7 @@ Before switching to a **daily** schedule, read the egress-cost section above —
|
|
|
199
220
|
- [x] `estimate` — project your monthly Supabase egress cost before you schedule
|
|
200
221
|
- [x] `drill` — restore a snapshot into an ephemeral Postgres and prove it comes back
|
|
201
222
|
- [x] Storage-file integrity check inside the drill (checksums, sampled by default)
|
|
202
|
-
- [x] `restore` — recover a snapshot into a
|
|
223
|
+
- [x] `restore` — recover a snapshot into a fresh Supabase project: database through the drill-grade engine with verification, Storage uploaded back with per-file reconciliation (local download remains an option)
|
|
203
224
|
- [x] GitHub Action wrapper
|
|
204
225
|
|
|
205
226
|
For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
|
package/dist/backup.js
CHANGED
|
@@ -8,6 +8,8 @@ import { Upload } from "@aws-sdk/lib-storage";
|
|
|
8
8
|
import { MANIFEST_SCHEMA_VERSION } from "./manifest.js";
|
|
9
9
|
import { syncStorage } from "./storage.js";
|
|
10
10
|
import { inspectBucketAttrs } from "./storage-catalog.js";
|
|
11
|
+
import { renderRecoveryDoc } from "./recovery-doc.js";
|
|
12
|
+
import { projectRefOf, refFromStorageEndpoint, assertNoHostOverride } from "./restore-engine.js";
|
|
11
13
|
import { log } from "./log.js";
|
|
12
14
|
import { TOOL_VERSION } from "./version.js";
|
|
13
15
|
import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
|
|
@@ -149,6 +151,19 @@ async function dumpToS3(config, s3, key, pgDumpBin) {
|
|
|
149
151
|
return { bytes, sha256: hash.digest("hex") };
|
|
150
152
|
}
|
|
151
153
|
export async function runBackup(config) {
|
|
154
|
+
// 源身份先于一切传输(评审第 12 轮):①连接串不许带 ?host=/user= 覆盖——否则
|
|
155
|
+
// 快照记的 ref 与实际读取对象脱节,恢复端的同源保护随之失真;②库与 Storage
|
|
156
|
+
// 跨项目 = 配置错误,全量 dump+同步之后才发现会白烧 egress 还留下半截快照前缀
|
|
157
|
+
assertNoHostOverride(config.databaseUrl);
|
|
158
|
+
const dbRef = projectRefOf(config.databaseUrl);
|
|
159
|
+
const storageRef = config.supabaseStorage
|
|
160
|
+
? refFromStorageEndpoint(config.supabaseStorage.endpoint)
|
|
161
|
+
: null;
|
|
162
|
+
if (dbRef && storageRef && dbRef !== storageRef) {
|
|
163
|
+
throw new Error(`database points at Supabase project "${dbRef}" but Storage at "${storageRef}" — ` +
|
|
164
|
+
`one BackupDrill project backs up one Supabase project; fix the config before backing up.`);
|
|
165
|
+
}
|
|
166
|
+
const sourceProjectRef = dbRef ?? storageRef;
|
|
152
167
|
const pgDumpBin = process.env.BACKUPDRILL_PG_DUMP || "pg_dump";
|
|
153
168
|
log.step("Inspecting database…");
|
|
154
169
|
const db = await inspectDatabase(config.databaseUrl, config.schemas);
|
|
@@ -214,6 +229,7 @@ export async function runBackup(config) {
|
|
|
214
229
|
toolVersion: TOOL_VERSION,
|
|
215
230
|
createdAt: new Date().toISOString(),
|
|
216
231
|
projectName: config.projectName,
|
|
232
|
+
...(sourceProjectRef ? { sourceProjectRef } : {}),
|
|
217
233
|
database: {
|
|
218
234
|
serverVersion: db.serverVersion,
|
|
219
235
|
pgDumpVersion: pgd.raw,
|
|
@@ -226,6 +242,23 @@ export async function runBackup(config) {
|
|
|
226
242
|
dump: { key: dumpKey, format: "custom", bytes, sha256 },
|
|
227
243
|
storage: storageResult,
|
|
228
244
|
};
|
|
245
|
+
// RECOVERY.md(PRD §5.9.2.1)先于 manifest 落桶:manifest 是快照的"完成标记"
|
|
246
|
+
// (演练/恢复按它找快照)——先写手册再写标记,"快照完成"就蕴含"手册在场";
|
|
247
|
+
// 手册写失败 = 备份失败(同桶同权限,与 manifest 自身的写入风险一致)。
|
|
248
|
+
await s3.send(new PutObjectCommand({
|
|
249
|
+
Bucket: config.storage.bucket,
|
|
250
|
+
Key: `${base}/RECOVERY.md`,
|
|
251
|
+
Body: renderRecoveryDoc(manifest, {
|
|
252
|
+
snapshot: timestamp,
|
|
253
|
+
bucket: config.storage.bucket,
|
|
254
|
+
endpoint: config.storage.endpoint,
|
|
255
|
+
region: config.storage.region,
|
|
256
|
+
prefix: config.storage.prefix,
|
|
257
|
+
projectName: config.projectName,
|
|
258
|
+
}),
|
|
259
|
+
ContentType: "text/markdown",
|
|
260
|
+
}));
|
|
261
|
+
log.ok(`Recovery runbook written → s3://${config.storage.bucket}/${base}/RECOVERY.md`);
|
|
229
262
|
await s3.send(new PutObjectCommand({
|
|
230
263
|
Bucket: config.storage.bucket,
|
|
231
264
|
Key: `${base}/manifest.json`,
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,28 @@ import { loadConfig } from "./config.js";
|
|
|
4
4
|
import { runBackup } from "./backup.js";
|
|
5
5
|
import { runEstimate } from "./estimate.js";
|
|
6
6
|
import { runDrill } from "./drill.js";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { Writable } from "node:stream";
|
|
7
9
|
import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
|
|
10
|
+
/** 隐藏式秘密输入(PRD §5.3.1):终端不回显;无 TTY(CI/脚本)时明确要求走环境变量。 */
|
|
11
|
+
async function promptSecret(label) {
|
|
12
|
+
if (!process.stdin.isTTY) {
|
|
13
|
+
throw new Error(`${label} is required — set the environment variable (no TTY available for a prompt).`);
|
|
14
|
+
}
|
|
15
|
+
const muted = new Writable({ write: (_chunk, _enc, cb) => cb() });
|
|
16
|
+
const rl = createInterface({ input: process.stdin, output: muted, terminal: true });
|
|
17
|
+
process.stderr.write(`${label} (hidden): `);
|
|
18
|
+
try {
|
|
19
|
+
const value = (await rl.question("")).trim();
|
|
20
|
+
process.stderr.write("\n");
|
|
21
|
+
if (!value)
|
|
22
|
+
throw new Error(`${label} was empty.`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
8
29
|
import { log } from "./log.js";
|
|
9
30
|
import { TOOL_VERSION } from "./version.js";
|
|
10
31
|
// Supabase egress 定价表(2026-07-10 核实,来源见 README「Egress & cost」)。
|
|
@@ -125,10 +146,18 @@ program
|
|
|
125
146
|
});
|
|
126
147
|
program
|
|
127
148
|
.command("restore")
|
|
128
|
-
.description("Recover a snapshot
|
|
149
|
+
.description("Recover a snapshot into a FRESH Supabase project: database via the drill-grade engine, " +
|
|
150
|
+
"Storage files uploaded back (or downloaded locally). Start with --dry-run.")
|
|
129
151
|
.option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
|
|
130
|
-
.option("--
|
|
131
|
-
.
|
|
152
|
+
.option("--database", "restore the database — connection string via env BACKUPDRILL_TARGET_DATABASE_URL or a hidden prompt " +
|
|
153
|
+
"(2.0: the credential-bearing --target-database-url flag was removed; argv is visible to every process)")
|
|
154
|
+
.option("--target-supabase-url <url>", "target project URL (https://<ref>.supabase.co) to upload Storage files into; " +
|
|
155
|
+
"service-role key via env BACKUPDRILL_TARGET_SERVICE_ROLE_KEY or a hidden prompt")
|
|
156
|
+
.option("--confirm-target <ref>", "type the TARGET project ref to confirm writing to it")
|
|
157
|
+
.option("--dry-run", "run every read-only preflight check and write nothing")
|
|
158
|
+
.option("--acknowledge-unverified-source", "proceed although the source identity cannot be verified (legacy snapshot without config) — " +
|
|
159
|
+
"same-source protection is inert; double-check the target first")
|
|
160
|
+
.option("--storage-dir <path>", "local directory to write Storage files to (when not uploading)")
|
|
132
161
|
.option("--snapshot <timestamp>", "which snapshot to restore (default: latest)")
|
|
133
162
|
.option("--bucket <name>", "bucket the backups live in")
|
|
134
163
|
.option("--endpoint <url>", "S3-compatible endpoint (for R2/B2)")
|
|
@@ -160,18 +189,51 @@ program
|
|
|
160
189
|
overrides: { ...overrides, databaseUrl: NO_SOURCE_DATABASE },
|
|
161
190
|
});
|
|
162
191
|
}
|
|
192
|
+
// 凭据形态(决策 D6,2.0 起):env 或隐藏式交互输入,绝不经 argv。
|
|
193
|
+
// --database 只表达意图,秘密自身不上命令行
|
|
194
|
+
// 秘密只随各自的意图旗标读取(评审第 9/10 轮):环境变量残留不能把 storage-only
|
|
195
|
+
// 变成意外的数据库恢复,也不能把无 Storage 目标的运行拖进凭据配对检查;
|
|
196
|
+
// 空串按缺失处理(误 export 空值不许静默吞掉一个显式请求)
|
|
197
|
+
let targetDatabaseUrl;
|
|
198
|
+
if (opts.database) {
|
|
199
|
+
targetDatabaseUrl =
|
|
200
|
+
process.env.BACKUPDRILL_TARGET_DATABASE_URL?.trim() ||
|
|
201
|
+
(await promptSecret("Target database connection string (BACKUPDRILL_TARGET_DATABASE_URL)"));
|
|
202
|
+
}
|
|
203
|
+
let targetServiceRoleKey;
|
|
204
|
+
if (opts.targetSupabaseUrl) {
|
|
205
|
+
targetServiceRoleKey =
|
|
206
|
+
process.env.BACKUPDRILL_TARGET_SERVICE_ROLE_KEY?.trim() ||
|
|
207
|
+
(await promptSecret("Target service-role key (BACKUPDRILL_TARGET_SERVICE_ROLE_KEY)"));
|
|
208
|
+
}
|
|
163
209
|
const result = await runRestore(config, {
|
|
164
|
-
targetDatabaseUrl
|
|
210
|
+
targetDatabaseUrl,
|
|
211
|
+
targetSupabaseUrl: opts.targetSupabaseUrl,
|
|
212
|
+
targetServiceRoleKey,
|
|
213
|
+
confirmTarget: opts.confirmTarget,
|
|
214
|
+
dryRun: opts.dryRun,
|
|
215
|
+
acknowledgeUnverifiedSource: opts.acknowledgeUnverifiedSource,
|
|
165
216
|
storageDir: opts.storageDir,
|
|
166
217
|
snapshot: opts.snapshot,
|
|
167
218
|
});
|
|
219
|
+
// stdout = 机器可读通道(人读日志全在 stderr):完整结构化结果供脚本/报告消费
|
|
220
|
+
console.log(JSON.stringify(result));
|
|
221
|
+
if (result.dryRun)
|
|
222
|
+
return; // 预检自行打印结论;走到这里 = 零 blocker
|
|
168
223
|
const failedChecks = (result.databaseChecks ?? []).filter((c) => !c.pass);
|
|
224
|
+
const rec = result.storageSummary?.reconcile;
|
|
225
|
+
const storageProblems = (result.storageSummary?.filesFailed.length ?? 0) +
|
|
226
|
+
(result.storageSummary?.checksumSample.mismatched.length ?? 0) +
|
|
227
|
+
// 属性漂移进裁决(评审第 10 轮):访问行为与快照不一致不算干净恢复
|
|
228
|
+
(result.storageSummary?.bucketAttrDrift.length ?? 0) +
|
|
229
|
+
(rec ? rec.missing.length + rec.sizeMismatched.length + rec.extras : 0);
|
|
169
230
|
const summary = `snapshot ${result.snapshot}: ` +
|
|
170
231
|
`database ${result.restoredToDatabase ? "restored" : "skipped"}, ` +
|
|
171
|
-
`${result.storageFilesWritten} Storage files written` +
|
|
232
|
+
`${result.storageFilesWritten} Storage files ${result.storageSummary ? "uploaded to target" : "written"}` +
|
|
172
233
|
(result.storageDir ? ` to ${result.storageDir}` : "");
|
|
173
|
-
if (failedChecks.length) {
|
|
174
|
-
log.error(`Restore finished with ${failedChecks.length}
|
|
234
|
+
if (failedChecks.length || storageProblems) {
|
|
235
|
+
log.error(`Restore finished with issues (${failedChecks.length} failed check(s), ` +
|
|
236
|
+
`${storageProblems} storage problem(s)) — ${summary}`);
|
|
175
237
|
process.exitCode = 1;
|
|
176
238
|
}
|
|
177
239
|
else {
|
package/dist/manifest.d.ts
CHANGED
package/dist/manifest.js
CHANGED
|
@@ -29,6 +29,10 @@ function optionalString(value, what) {
|
|
|
29
29
|
malformed(what);
|
|
30
30
|
}
|
|
31
31
|
function assertManifestShape(m) {
|
|
32
|
+
// ref 驱动同源安全门:malformed 值会被当"身份已知"却永远匹配不上 = 保护静默失效
|
|
33
|
+
if (m.sourceProjectRef !== undefined && !/^[a-z0-9]{16,}$/.test(m.sourceProjectRef)) {
|
|
34
|
+
malformed("sourceProjectRef (not a Supabase project ref)");
|
|
35
|
+
}
|
|
32
36
|
const d = m.dump;
|
|
33
37
|
if (typeof d !== "object" || d === null)
|
|
34
38
|
malformed("dump section missing");
|
|
@@ -45,6 +49,10 @@ function assertManifestShape(m) {
|
|
|
45
49
|
if (typeof m.database.serverVersion !== "string" || !(parseInt(m.database.serverVersion, 10) > 0)) {
|
|
46
50
|
malformed("database.serverVersion");
|
|
47
51
|
}
|
|
52
|
+
// 恢复端的工具版本闸要解析它(requiredRestoreToolMajor)——错型值不能到那才炸
|
|
53
|
+
if (typeof m.database.pgDumpVersion !== "string" || !m.database.pgDumpVersion) {
|
|
54
|
+
malformed("database.pgDumpVersion");
|
|
55
|
+
}
|
|
48
56
|
if (!Array.isArray(m.database.schemas) || m.database.schemas.some((s) => typeof s !== "string" || !s)) {
|
|
49
57
|
malformed("database.schemas");
|
|
50
58
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Manifest } from "./manifest.js";
|
|
2
|
+
/** 生成手册所需的非秘密快照坐标。刻意不收连接串/密钥类型——签名即防线。 */
|
|
3
|
+
export interface RecoveryDocContext {
|
|
4
|
+
snapshot: string;
|
|
5
|
+
bucket: string;
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
region?: string;
|
|
8
|
+
prefix: string;
|
|
9
|
+
projectName: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function renderRecoveryDoc(manifest: Manifest, ctx: RecoveryDocContext): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
function quoteSqlIdent(name) {
|
|
2
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
3
|
+
}
|
|
4
|
+
/** POSIX 安全的 shell 值引用:项目名/前缀里合法的空格与元字符不得拆散或改写命令。 */
|
|
5
|
+
function shellQuote(value) {
|
|
6
|
+
return /^[A-Za-z0-9._/:@=-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
7
|
+
}
|
|
8
|
+
export function renderRecoveryDoc(manifest, ctx) {
|
|
9
|
+
const base = manifest.dump.key.replace(/\/dump\.pgcustom$/, "");
|
|
10
|
+
const db = manifest.database;
|
|
11
|
+
const extensions = db.extensions ?? [];
|
|
12
|
+
const storage = manifest.storage;
|
|
13
|
+
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
|
|
14
|
+
// 归档格式跟随 dump 工具版本(允许比服务端新):pg_restore 必须 ≥ 两者较大值,
|
|
15
|
+
// 只按服务端版本指引会让 PG17 的 pg_restore 读不了 PG18 pg_dump 写的归档。
|
|
16
|
+
// 解析口径与 backup.ts parsePgDumpMajor 一致(不 import:backup → recovery-doc 会成环)
|
|
17
|
+
const dumpToolMajor = db.pgDumpVersion.match(/\(PostgreSQL\)\s+(\d+)/)?.[1] ??
|
|
18
|
+
db.pgDumpVersion.match(/(\d+)(?:\.\d+)+/)?.[1];
|
|
19
|
+
const requiredRestoreMajor = Math.max(Number(dumpToolMajor ?? 0), parseInt(db.serverVersion, 10) || 0);
|
|
20
|
+
const extensionSql = extensions.length
|
|
21
|
+
? extensions
|
|
22
|
+
.map((e) => `create schema if not exists ${quoteSqlIdent(e.schema)};\n` +
|
|
23
|
+
`create extension if not exists ${quoteSqlIdent(e.name)} schema ${quoteSqlIdent(e.schema)} cascade;`)
|
|
24
|
+
.join("\n")
|
|
25
|
+
: "-- (this snapshot records no extensions)";
|
|
26
|
+
// 2.0 形态:凭据只经环境变量(argv 对全机器可见);写入需要键入目标 ref 确认。
|
|
27
|
+
// DB-only 快照不出现 Storage 凭据与旗标——不为用不上的能力索要宽权限 key
|
|
28
|
+
const cliRestoreCommand = [
|
|
29
|
+
`read -rs BACKUPDRILL_TARGET_DATABASE_URL && export BACKUPDRILL_TARGET_DATABASE_URL # hidden prompt`,
|
|
30
|
+
...(storage
|
|
31
|
+
? [`read -rs BACKUPDRILL_TARGET_SERVICE_ROLE_KEY && export BACKUPDRILL_TARGET_SERVICE_ROLE_KEY`]
|
|
32
|
+
: []),
|
|
33
|
+
`backupdrill restore \\`,
|
|
34
|
+
` --snapshot ${shellQuote(ctx.snapshot)} \\`,
|
|
35
|
+
` --bucket ${shellQuote(ctx.bucket)} \\`,
|
|
36
|
+
...(ctx.endpoint ? [` --endpoint ${shellQuote(ctx.endpoint)} \\`] : []),
|
|
37
|
+
...(ctx.region ? [` --region ${shellQuote(ctx.region)} \\`] : []),
|
|
38
|
+
` --prefix ${shellQuote(ctx.prefix)} \\`,
|
|
39
|
+
` --project-name ${shellQuote(ctx.projectName)} \\`,
|
|
40
|
+
` --database \\`,
|
|
41
|
+
...(storage ? [` --target-supabase-url https://<target-ref>.supabase.co \\`] : []),
|
|
42
|
+
` --confirm-target <target-ref>`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
return `# BackupDrill recovery runbook — ${ctx.projectName} @ ${ctx.snapshot}
|
|
45
|
+
|
|
46
|
+
This file was written next to the snapshot it describes and is **self-contained**:
|
|
47
|
+
even if BackupDrill (the service and the website) is unreachable, everything below
|
|
48
|
+
works with standard PostgreSQL tools against this bucket alone.
|
|
49
|
+
|
|
50
|
+
## What this snapshot contains
|
|
51
|
+
|
|
52
|
+
- **Database dump**: \`${manifest.dump.key}\`
|
|
53
|
+
(pg_dump custom format, ${mb(manifest.dump.bytes)} MB, sha256 \`${manifest.dump.sha256}\`)
|
|
54
|
+
- schemas: ${db.schemas.join(", ")} — ${db.tableCount} tables, ~${db.estimatedRowTotal.toLocaleString("en-US")} rows at backup time
|
|
55
|
+
- source PostgreSQL ${db.serverVersion}, archive written by ${db.pgDumpVersion} — restore with pg_restore ${requiredRestoreMajor} or newer (the archive format follows the dump tool, not the server)
|
|
56
|
+
${extensions.length
|
|
57
|
+
? ` - extensions required: ${extensions.map((e) => `${e.name} (schema "${e.schema}")`).join(", ")}`
|
|
58
|
+
: " - no extensions recorded"}
|
|
59
|
+
- **Storage files**: ${storage
|
|
60
|
+
? `${storage.fileCount} files, ${mb(storage.totalBytes)} MB under \`${base}/storage/<bucket>/<key>\` — per-file sha256 in manifest.json`
|
|
61
|
+
: "none (database-only snapshot)"}
|
|
62
|
+
- **Machine-readable inventory**: \`${base}/manifest.json\`
|
|
63
|
+
|
|
64
|
+
## Not covered — plan for these separately
|
|
65
|
+
|
|
66
|
+
Supabase **Auth users/sessions, secret values, Edge Function code and most
|
|
67
|
+
platform settings are NOT in this snapshot**. After restoring, reconfigure them
|
|
68
|
+
by hand. This is a database + Storage snapshot, not a full project image.
|
|
69
|
+
|
|
70
|
+
## Restore the database (into an EMPTY project)
|
|
71
|
+
|
|
72
|
+
1. Create a **fresh** Supabase project (never restore onto the source project),
|
|
73
|
+
and use its **Session Pooler** connection string.
|
|
74
|
+
2. Install the required extensions first (the dump references their types):
|
|
75
|
+
|
|
76
|
+
\`\`\`sql
|
|
77
|
+
${extensionSql}
|
|
78
|
+
\`\`\`
|
|
79
|
+
|
|
80
|
+
3. Download \`${manifest.dump.key}\` from this bucket, then verify it:
|
|
81
|
+
|
|
82
|
+
\`\`\`bash
|
|
83
|
+
shasum -a 256 dump.pgcustom # must print ${manifest.dump.sha256}
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
4. Restore — **without \`--clean\`**, into the empty target. Enter the password at
|
|
87
|
+
a hidden prompt so it never touches the URL, argv, or shell history:
|
|
88
|
+
|
|
89
|
+
\`\`\`bash
|
|
90
|
+
read -rs PGPASSWORD && export PGPASSWORD # hidden prompt
|
|
91
|
+
pg_restore --no-owner --no-privileges \\
|
|
92
|
+
--dbname "postgresql://postgres.<target-ref>@<target-pooler-host>:5432/postgres" dump.pgcustom
|
|
93
|
+
unset PGPASSWORD
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
One error is expected and harmless: \`schema "public" already exists\` (the
|
|
97
|
+
target always has it). Do **not** use \`--clean\` against a Supabase project —
|
|
98
|
+
dropping and recreating \`public\` silently wipes Supabase's default grants for
|
|
99
|
+
\`anon\`/\`authenticated\`, and the restored API answers 401.
|
|
100
|
+
|
|
101
|
+
## Restore Storage files
|
|
102
|
+
|
|
103
|
+
Files live at \`${base}/storage/<original-bucket>/<original-key>\` in this
|
|
104
|
+
bucket. Recreate each bucket in the target project, then re-upload the files
|
|
105
|
+
${storage?.buckets ? "(bucket attributes — public flag, size and MIME limits — are recorded in manifest.json under `storage.buckets`)" : "(bucket attributes were not captured for this snapshot; recreate buckets with the settings you know)"}.
|
|
106
|
+
|
|
107
|
+
## Easiest path — the BackupDrill CLI
|
|
108
|
+
|
|
109
|
+
\`\`\`bash
|
|
110
|
+
npm install -g backupdrill
|
|
111
|
+
${cliRestoreCommand}
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
Add \`--dry-run\` first to preview every check without writing anything. (Skip the
|
|
115
|
+
\`read\` lines to have the CLI prompt you itself; \`unset\` both variables afterwards.)
|
|
116
|
+
The CLI verifies the archive checksum, refuses non-empty targets, installs the
|
|
117
|
+
extensions, restores through the same engine the weekly drills use, verifies
|
|
118
|
+
table counts and row presence afterwards, then rebuilds buckets, uploads the
|
|
119
|
+
Storage files back and reconciles every one against the target.
|
|
120
|
+
|
|
121
|
+
More: https://backupdrill.com/docs/restore
|
|
122
|
+
`;
|
|
123
|
+
}
|
package/dist/restore-engine.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { ExtensionInfo } from "./manifest.js";
|
|
2
2
|
export type RestoreTargetKind = "sandbox" | "supabase";
|
|
3
|
+
/** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
|
|
4
|
+
* 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
|
|
5
|
+
export declare function normalizeHost(hostname: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
|
|
8
|
+
* pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
|
|
9
|
+
* 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
|
|
10
|
+
*/
|
|
11
|
+
export declare function projectRefOf(connString: string): string | null;
|
|
12
|
+
/** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
|
|
13
|
+
export declare function refFromStorageEndpoint(endpoint: string): string | null;
|
|
3
14
|
export interface ClassifiedPass {
|
|
4
15
|
/** allowlist 命中数(环境预期/托管冲突)——如实入报告,不算失败 */
|
|
5
16
|
expectedSkips: number;
|
|
@@ -56,6 +67,12 @@ export declare function credentialSafeDbArgs(connString: string): {
|
|
|
56
67
|
url: string;
|
|
57
68
|
env: NodeJS.ProcessEnv;
|
|
58
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
|
|
72
|
+
* 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
|
|
73
|
+
* 恢复侧(确认门/同源阻断)共用本检查。
|
|
74
|
+
*/
|
|
75
|
+
export declare function assertNoHostOverride(connString: string): void;
|
|
59
76
|
export declare function restoreDatabaseArtifact(opts: {
|
|
60
77
|
dumpPath: string;
|
|
61
78
|
connString: string;
|
package/dist/restore-engine.js
CHANGED
|
@@ -11,7 +11,44 @@
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { Client } from "pg";
|
|
13
13
|
import { resolvePgRestoreBin } from "./pgbin.js";
|
|
14
|
-
import { pgConnectOptions } from "./supabase-ca.js";
|
|
14
|
+
import { pgConnectOptions, dumpUrlFor } from "./supabase-ca.js";
|
|
15
|
+
/** 主机名规范化:剥掉合法的 DNS 根点(db.x.supabase.co. ≡ db.x.supabase.co)。
|
|
16
|
+
* 身份比较不规范化 = 一个尾点就能绕过同源阻断(supabase-ca 对同类早有钉子)。 */
|
|
17
|
+
export function normalizeHost(hostname) {
|
|
18
|
+
return hostname.replace(/\.$/, "").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 从连接串提取 Supabase 项目 ref(纯函数):直连主机 `db.<ref>.supabase.co` 或
|
|
22
|
+
* pooler 用户名 `postgres.<ref>`。放在引擎层:备份端(写 manifest.sourceProjectRef)
|
|
23
|
+
* 与恢复端(同源阻断)共用同一个身份判定。非 Supabase 形态返回 null。
|
|
24
|
+
*/
|
|
25
|
+
export function projectRefOf(connString) {
|
|
26
|
+
try {
|
|
27
|
+
const url = new URL(connString);
|
|
28
|
+
const direct = normalizeHost(url.hostname).match(/^db\.([a-z0-9]{16,})\.supabase\.co$/);
|
|
29
|
+
if (direct)
|
|
30
|
+
return direct[1];
|
|
31
|
+
// 用户名必须先解码再匹配:URL 解析器保留百分号编码,而 pg/libpq 会解码——
|
|
32
|
+
// postgres%2Eref 在驱动眼里就是 postgres.ref,不解码 = 身份判定可被编码绕过
|
|
33
|
+
const pooled = decodeURIComponent(url.username).match(/^postgres\.([a-z0-9]{16,})$/);
|
|
34
|
+
if (pooled)
|
|
35
|
+
return pooled[1];
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** 从 Supabase Storage S3 端点(https://<ref>.storage.supabase.co/…)提取项目 ref。 */
|
|
43
|
+
export function refFromStorageEndpoint(endpoint) {
|
|
44
|
+
try {
|
|
45
|
+
const match = normalizeHost(new URL(endpoint).hostname).match(/^([a-z0-9]{16,})\.(?:storage\.)?supabase\.co$/);
|
|
46
|
+
return match ? match[1] : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
15
52
|
export function spawnPgRestore(bin, args, extraEnv = {}) {
|
|
16
53
|
return new Promise((resolve, reject) => {
|
|
17
54
|
const proc = spawn(bin, args, {
|
|
@@ -124,21 +161,103 @@ export async function installExtensions(connString, extensions) {
|
|
|
124
161
|
* 原样透传(libpq 自己适配的形态,不在这里破坏)。
|
|
125
162
|
*/
|
|
126
163
|
export function credentialSafeDbArgs(connString) {
|
|
164
|
+
let url;
|
|
127
165
|
try {
|
|
128
|
-
|
|
129
|
-
if (!url.password)
|
|
130
|
-
return { url: connString, env: {} };
|
|
131
|
-
const password = decodeURIComponent(url.password);
|
|
132
|
-
url.password = "";
|
|
133
|
-
return { url: url.toString(), env: { PGPASSWORD: password } };
|
|
166
|
+
url = new URL(connString);
|
|
134
167
|
}
|
|
135
168
|
catch {
|
|
169
|
+
// 非 URL(libpq keyword)形态拆不了密码——含内联密码的一律拒绝,不赌
|
|
170
|
+
if (/\bpassword\s*=/i.test(connString)) {
|
|
171
|
+
throw new Error("keyword-style connection strings with an inline password are not accepted — " +
|
|
172
|
+
"use the URL form; the password is moved into PGPASSWORD automatically");
|
|
173
|
+
}
|
|
136
174
|
return { url: connString, env: {} };
|
|
137
175
|
}
|
|
176
|
+
let password = "";
|
|
177
|
+
if (url.password) {
|
|
178
|
+
try {
|
|
179
|
+
password = decodeURIComponent(url.password);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// 编码坏了不许 fail-open(评审第 13 轮):原样放行 = 密码整段留在 argv
|
|
183
|
+
throw new Error("the connection string password contains malformed percent-encoding — " +
|
|
184
|
+
"fix the URL; refusing to pass it through argv");
|
|
185
|
+
}
|
|
186
|
+
url.password = "";
|
|
187
|
+
}
|
|
188
|
+
// ?password= 同样不许进 argv(libpq 认它)。剥参按**解码后的键名**匹配
|
|
189
|
+
// (pass%77ord= 也是 password=,评审第 15 轮),不用 URLSearchParams.delete:
|
|
190
|
+
// 它会重序列化其余参数(%20 变 +),libpq 按字面处理,options 之类会被改坏
|
|
191
|
+
const rawQuery = url.search.startsWith("?") ? url.search.slice(1) : url.search;
|
|
192
|
+
if (rawQuery) {
|
|
193
|
+
const kept = [];
|
|
194
|
+
const queryPasswords = [];
|
|
195
|
+
for (const part of rawQuery.split("&")) {
|
|
196
|
+
const eq = part.indexOf("=");
|
|
197
|
+
const rawKey = eq === -1 ? part : part.slice(0, eq);
|
|
198
|
+
let key;
|
|
199
|
+
try {
|
|
200
|
+
key = decodeURIComponent(rawKey.replace(/\+/g, "%20"));
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
key = rawKey;
|
|
204
|
+
}
|
|
205
|
+
if (/^password$/i.test(key)) {
|
|
206
|
+
const rawValue = eq === -1 ? "" : part.slice(eq + 1);
|
|
207
|
+
try {
|
|
208
|
+
const value = decodeURIComponent(rawValue.replace(/\+/g, "%20"));
|
|
209
|
+
if (value)
|
|
210
|
+
queryPasswords.push(value);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
throw new Error("the connection string ?password= value contains malformed percent-encoding — " +
|
|
214
|
+
"fix the URL; refusing to pass it through argv");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
kept.push(part);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (queryPasswords.length) {
|
|
222
|
+
// authority 与查询、以及重复查询项之间不一致:libpq 的取值优先级和我们的猜测
|
|
223
|
+
// 可能不同,预检用 A 通过、pg_restore 用 B 失败的分叉不可接受——直接拒绝
|
|
224
|
+
const distinct = new Set(password ? [password, ...queryPasswords] : queryPasswords);
|
|
225
|
+
if (distinct.size > 1) {
|
|
226
|
+
throw new Error("the connection string carries two different passwords (authority/?password=) — remove one");
|
|
227
|
+
}
|
|
228
|
+
password = [...distinct][0];
|
|
229
|
+
url.search = kept.length ? `?${kept.join("&")}` : "";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { url: url.toString(), env: password ? { PGPASSWORD: password } : {} };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* 拒绝连接串里的 host/hostaddr/user 查询覆盖:驱动会让它们改写实际连接目标/租户——
|
|
236
|
+
* 身份判定(ref/主机)看的是 authority,读写却发生在别处。备份侧(源身份自记)与
|
|
237
|
+
* 恢复侧(确认门/同源阻断)共用本检查。
|
|
238
|
+
*/
|
|
239
|
+
export function assertNoHostOverride(connString) {
|
|
240
|
+
try {
|
|
241
|
+
const params = new URL(connString).searchParams;
|
|
242
|
+
for (const key of params.keys()) {
|
|
243
|
+
if (/^(host|hostaddr|user)$/i.test(key)) {
|
|
244
|
+
throw new Error(`connection string carries a ?${key}= override — the effective server/identity would ` +
|
|
245
|
+
`differ from the URL authority that identity checks inspect. Use a plain connection string.`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.message.includes("override"))
|
|
251
|
+
throw error;
|
|
252
|
+
// URL 解析不了的连接串由 pg 自己报错,这里放行
|
|
253
|
+
}
|
|
138
254
|
}
|
|
139
255
|
export async function restoreDatabaseArtifact(opts) {
|
|
140
256
|
const bin = resolvePgRestoreBin();
|
|
141
|
-
|
|
257
|
+
// TLS 全链路 verify-full(创始人承诺项):pg_restore 与 pg_dump 同一改写——
|
|
258
|
+
// Supabase 主机强制 verify-full + 打包根 CA(libpq 的 require 不验证书,
|
|
259
|
+
// 中间人可拿到连接与数据);沙箱/外部主机原样透传。先改写 SSL 再拆密码。
|
|
260
|
+
const { url, env } = credentialSafeDbArgs(dumpUrlFor(opts.connString));
|
|
142
261
|
const common = ["--no-owner", "--no-privileges", "--dbname", url, opts.dumpPath];
|
|
143
262
|
const first = await spawnPgRestore(bin, ["--section=pre-data", "--section=data", ...common], env);
|
|
144
263
|
const preData = finalizePass(first.code, first.stderr, classifyBlocks(first.stderr, SCHEMA_EXISTS_ERROR));
|