backupdrill 2.5.0 → 2.6.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/README.md CHANGED
@@ -158,7 +158,9 @@ backupdrill estimate --plan pro # measures your DB + Storage, prints the proje
158
158
  <prefix>/<project>/<timestamp>/
159
159
  ├── dump.pgcustom # pg_dump --format=custom (public schema by default)
160
160
  ├── storage/<bucket>/<key> # your Storage files (if configured)
161
+ ├── RECOVERY.md # how to restore this snapshot
161
162
  └── manifest.json # schemas, tables, row counts, file list, sizes + sha256
163
+ <prefix>/<project>.drills/<finished-at>-<passed|failed>-<tag>.json # only with drill --record: one file per drill
162
164
  ```
163
165
 
164
166
  ## Restoring & drilling
@@ -261,6 +263,80 @@ Both pings of a run carry the same `rid`, so Healthchecks pairs them even if two
261
263
 
262
264
  A ping never changes the result of a run. Each request times out after 10 seconds and is retried twice on network errors and 5xx responses; if it still fails, the CLI prints a warning and carries on, with the same exit code it would have had without a ping URL.
263
265
 
266
+ ## Prometheus metrics
267
+
268
+ `backupdrill exporter` serves [Prometheus](https://prometheus.io) metrics about the backups in your bucket, and about the restore drills you run with `--record`. It reads the bucket each time Prometheus scrapes it and never writes, so a key with read-only access to the bucket is enough.
269
+
270
+ ```bash
271
+ # reads the same BACKUPDRILL_S3_* and BACKUPDRILL_PROJECT_NAME settings as backup and drill
272
+ backupdrill exporter # http://127.0.0.1:10059/metrics
273
+ backupdrill exporter --host 0.0.0.0 # when Prometheus runs on another machine
274
+ ```
275
+
276
+ It listens on `127.0.0.1` unless you pass `--host`, because every scrape costs a few requests against your bucket and the port shouldn't end up open to the internet by accident. `--port` changes the port.
277
+
278
+ Drill results only show up if the drill leaves them in the bucket. Add `--record` to your drill job: when the drill finishes, it writes one small JSON file to `<prefix>/<project>.drills/`, named after the time it finished, whether it passed and a random tag (`2026-10-20T04-12-09-311Z-passed-3f9a1c07.json`). Each drill writes a file under its own name instead of updating a shared one, so the order in which two overlapping drills finish writing doesn't matter. Only the outcome, the time, the snapshot name and the restore time are stored, never the check details. A drill that stops early (Docker isn't running, the bucket is empty) is recorded as not passed. `--record` needs write access to the bucket; if the write fails, the drill prints a warning and its own result and exit code stay the same. Without `--record`, `drill` only reads the bucket, as before.
279
+
280
+ ```bash
281
+ backupdrill drill --record
282
+ ```
283
+
284
+ Point Prometheus at it. One exporter watches one project. The exporter doesn't add a project label, so set one in the scrape config:
285
+
286
+ ```yaml
287
+ scrape_configs:
288
+ - job_name: backupdrill
289
+ scrape_interval: 1m
290
+ static_configs:
291
+ - targets: ["backup-host:10059"]
292
+ labels:
293
+ project: my-app
294
+ ```
295
+
296
+ For a second project, run a second exporter with `--project-name` and a different `--port`.
297
+
298
+ | Metric | Meaning |
299
+ |---|---|
300
+ | `backupdrill_up` | 1 if the bucket was read, 0 if not (the reason goes to the exporter's log) |
301
+ | `backupdrill_scrape_duration_seconds` | How long reading the bucket took |
302
+ | `backupdrill_build_info{version}` | CLI version, always 1 |
303
+ | `backupdrill_snapshots` | Snapshot folders for the project, complete or not |
304
+ | `backupdrill_backup_last_success_timestamp_seconds` | When the newest complete snapshot finished (`createdAt` in its `manifest.json`); 0 if there is none |
305
+ | `backupdrill_latest_snapshot_dump_bytes` | Size of the database dump in that snapshot |
306
+ | `backupdrill_latest_snapshot_storage_files`, `_storage_bytes` | Storage files in that snapshot and their total size; 0 for a database-only backup |
307
+ | `backupdrill_latest_snapshot_tables`, `_estimated_rows` | Tables captured, and the row count Postgres estimated at backup time |
308
+ | `backupdrill_drill_last_run_timestamp_seconds` | When the last recorded drill finished, passed or not; 0 if none |
309
+ | `backupdrill_drill_last_run_passed` | 1 if that drill passed, 0 if not |
310
+ | `backupdrill_drill_last_success_timestamp_seconds` | When the last recorded drill that passed finished; 0 if none |
311
+ | `backupdrill_drill_last_success_restore_duration_seconds` | How long the database restore took in that drill |
312
+
313
+ A snapshot counts as complete once its `manifest.json` exists, because the backup writes it last. While there is no complete snapshot, the `latest_snapshot` metrics are left out. `backupdrill_drill_last_run_passed` appears once a drill has been recorded, and the restore duration once a recorded drill has passed. A manifest dated more than 10 minutes in the future counts as a failed read (`backupdrill_up 0`), and a drill file named with such a time is skipped with a warning in the log: either would hold back the alerts that work out an age from that timestamp. Check the clock on the machine that wrote it.
314
+
315
+ Alert rules to start from. Save them as `backupdrill-alerts.yml`, list that file under `rule_files` in `prometheus.yml` and reload Prometheus; `promtool check rules` passes on them:
316
+
317
+ ```yaml
318
+ groups:
319
+ - name: backupdrill
320
+ rules:
321
+ - alert: BackupdrillBucketUnreadable
322
+ expr: backupdrill_up == 0
323
+ for: 15m
324
+ - alert: SupabaseBackupMissing
325
+ # daily backups; use 8 * 86400 for weekly
326
+ expr: time() - backupdrill_backup_last_success_timestamp_seconds > 26 * 3600
327
+ for: 15m
328
+ - alert: SupabaseBackupShrank
329
+ expr: backupdrill_latest_snapshot_dump_bytes < 0.7 * max_over_time(backupdrill_latest_snapshot_dump_bytes[7d])
330
+ - alert: SupabaseRestoreDrillFailed
331
+ expr: backupdrill_drill_last_run_passed == 0
332
+ - alert: SupabaseRestoreDrillMissing
333
+ # weekly drills
334
+ expr: time() - backupdrill_drill_last_success_timestamp_seconds > 8 * 86400
335
+ for: 1h
336
+ ```
337
+
338
+ What a scrape costs: one list request per 1,000 snapshot folders and one per 1,000 drill files, plus reads of the newest `manifest.json` and the newest passing drill file. The manifest is fetched with `If-None-Match`, so when it hasn't changed the bucket answers `304 Not Modified` and nothing is downloaded. That matters once you back up Storage files, since the manifest lists every file. At a one-minute interval this comes to about 86,000 list requests and 86,000 small reads a month; check your storage provider's request pricing. Each read of the bucket stops half a second before Prometheus's scrape timeout (10 seconds if there's no Prometheus header), so a slow bucket shows up as `backupdrill_up 0` instead of a failed scrape.
339
+
264
340
  ## Roadmap
265
341
 
266
342
  - [x] Database backup → your bucket, with checksummed manifest
@@ -270,6 +346,7 @@ A ping never changes the result of a run. Each request times out after 10 second
270
346
  - [x] Storage-file integrity check inside the drill (checksums, sampled by default)
271
347
  - [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)
272
348
  - [x] GitHub Action wrapper
349
+ - [x] `exporter` — Prometheus metrics for backups and recorded drills
273
350
 
274
351
  For scheduling, automated restore drills, and alerting, use the hosted service at [backupdrill.com](https://backupdrill.com).
275
352
 
@@ -278,9 +355,10 @@ For scheduling, automated restore drills, and alerting, use the hosted service a
278
355
  `backupdrill` follows [semantic versioning](https://semver.org/). From **1.0.0** onwards the
279
356
  following is the stable public surface — breaking it requires a major version bump:
280
357
 
281
- - the commands (`backup`, `drill`, `restore`, `estimate`) and their flags
358
+ - the commands (`backup`, `drill`, `restore`, `estimate`, `exporter`) and their flags
282
359
  - the `BACKUPDRILL_*` environment variables and the config-file keys
283
- - the layout written to your bucket, and the shape of `manifest.json`
360
+ - the layout written to your bucket, and the shape of `manifest.json` and the drill record files
361
+ - the names and meaning of the metrics `exporter` serves
284
362
 
285
363
  `manifest.json` carries a `schemaVersion` so the format can evolve safely. A snapshot written by
286
364
  a newer release is **refused with a clear message** rather than parsed on a best-effort basis —
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 到点就放弃,不管 run 停没停。只把 AbortSignal 交给 SDK 不够:它在两次重试之间按
3
+ * Retry-After 睡觉时不看 signal,503 带 Retry-After: 2 就能把 1 秒的时限拖到 2 秒多。
4
+ * signal 仍然传下去;结束时(成功、失败、超时)一律 abort,让并行中还没回来的请求收手,
5
+ * 不然一个先失败、一个挂着的 Promise.all 会把挂着的那个连接一直留着。
6
+ */
7
+ export declare function withDeadline<T>(run: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T>;
8
+ /** 超时类错误(withDeadline 的、AbortSignal 的)统一认出来,好给一句人话 */
9
+ export declare function isTimeout(error: unknown): boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 到点就放弃,不管 run 停没停。只把 AbortSignal 交给 SDK 不够:它在两次重试之间按
3
+ * Retry-After 睡觉时不看 signal,503 带 Retry-After: 2 就能把 1 秒的时限拖到 2 秒多。
4
+ * signal 仍然传下去;结束时(成功、失败、超时)一律 abort,让并行中还没回来的请求收手,
5
+ * 不然一个先失败、一个挂着的 Promise.all 会把挂着的那个连接一直留着。
6
+ */
7
+ export async function withDeadline(run, ms) {
8
+ const controller = new AbortController();
9
+ let timer;
10
+ const deadline = new Promise((_, reject) => {
11
+ timer = setTimeout(() => {
12
+ const error = new Error(`timed out after ${ms}ms`);
13
+ error.name = "TimeoutError";
14
+ reject(error);
15
+ }, ms);
16
+ });
17
+ try {
18
+ return await Promise.race([run(controller.signal), deadline]);
19
+ }
20
+ finally {
21
+ clearTimeout(timer);
22
+ controller.abort();
23
+ }
24
+ }
25
+ /** 超时类错误(withDeadline 的、AbortSignal 的)统一认出来,好给一句人话 */
26
+ export function isTimeout(error) {
27
+ const name = error?.name;
28
+ return name === "TimeoutError" || name === "AbortError";
29
+ }
@@ -0,0 +1,53 @@
1
+ import { type S3Client } from "@aws-sdk/client-s3";
2
+ import { type BackupConfig } from "./config.js";
3
+ import type { DrillReport } from "./drill.js";
4
+ /**
5
+ * `drill --record` 写进桶里的演练结论。`exporter` 只读桶,演练结果不落桶它就无从得知。
6
+ *
7
+ * 每次演练写一个新对象,从不覆盖:
8
+ * <prefix>/<project>.drills/<结束时间>-<passed|failed>-<8 位随机>.json
9
+ * 为什么不是一份可覆盖的"最近一次":两次演练重叠时,先结束的那次可能后写进桶,把更新的
10
+ * "没通过"盖成更早的"通过",失败告警就没了;先读后写也挡不住(不是原子的),而各家
11
+ * S3 兼容存储对条件写入的支持参差不齐。每次一个对象,"哪次最新"留给读的一方按名字判断,
12
+ * 写入顺序就无所谓了。
13
+ *
14
+ * 为什么放在 <project>.drills/,和快照目录并排而不是在它里面:resolveSnapshot(包括已经发出去的
15
+ * 旧版本)按字典序取 <project>/ 下最大的目录当最新快照,里面多一个 drills/ 会被当成快照。
16
+ *
17
+ * 文件名本身带着结束时间和结论,exporter 列一下目录就知道最近一次和最近一次通过是哪个;
18
+ * 只为了恢复用时才读一次通过的那份。只记结论,不记检查明细:失败明细里可能有 pg_restore
19
+ * 回显的表数据,而桶的读权限可能比演练日志给得更宽(exporter 只要只读 key)。
20
+ */
21
+ /** 版本规则同 manifest:缺省不接受,更高的版本明确报错让用户升级,不按旧结构硬读。 */
22
+ export declare const DRILL_RECORD_SCHEMA_VERSION = 1;
23
+ export interface DrillRecord {
24
+ schemaVersion: number;
25
+ tool: "backupdrill-cli";
26
+ toolVersion: string;
27
+ /** 演练结束的时间(ISO8601),与文件名里的时间相同 */
28
+ finishedAt: string;
29
+ pass: boolean;
30
+ /** 演练的快照名;演练在选定快照之前就失败了(没装 Docker、桶里没有快照)时没有 */
31
+ snapshot?: string;
32
+ /** 恢复用时,只有通过的演练才记:没通过时这个数可能只是半程,拿来估恢复时间会误导 */
33
+ restoreSeconds?: number;
34
+ }
35
+ /** 演练记录所在的"目录",带尾斜杠 */
36
+ export declare function drillRecordsPrefix(config: BackupConfig): string;
37
+ export declare function drillRecordName(record: Pick<DrillRecord, "finishedAt" | "pass">, unique?: string): string;
38
+ /** 从文件名读出结束时间(毫秒)和结论;不是记录文件的名字返回 null */
39
+ export declare function parseDrillRecordName(name: string): {
40
+ finishedAt: number;
41
+ pass: boolean;
42
+ } | null;
43
+ /** report 为 null = 演练中途抛错、没有报告;这也算一次没通过的演练。 */
44
+ export declare function buildDrillRecord(report: DrillReport | null, finishedAt: Date): DrillRecord;
45
+ /** 写一个新对象,返回它的 key。不读桶:只需要写权限 */
46
+ export declare function writeDrillRecord(s3: S3Client, config: BackupConfig, record: DrillRecord, abortSignal?: AbortSignal): Promise<string>;
47
+ /**
48
+ * CLI 的 `drill --record`:写不进去只警告,演练本身的结论和退出码不变。
49
+ * 常见原因是给演练配的是只读 key;这种情况下 exporter 的演练时间会一直不动,告警规则会发现。
50
+ */
51
+ export declare function recordDrillOutcome(config: BackupConfig, report: DrillReport | null, s3?: S3Client, timeoutMs?: number): Promise<void>;
52
+ /** 读桶里的演练记录。结构不对就报错:exporter 宁可报 up=0,也不报一个读错的结论。 */
53
+ export declare function parseDrillRecord(json: string, file: string): DrillRecord;
@@ -0,0 +1,126 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { PutObjectCommand } from "@aws-sdk/client-s3";
3
+ import { requireBucket } from "./config.js";
4
+ import { isTimeout, withDeadline } from "./deadline.js";
5
+ import { log } from "./log.js";
6
+ import { targetClient } from "./snapshots.js";
7
+ import { TOOL_VERSION } from "./version.js";
8
+ /**
9
+ * `drill --record` 写进桶里的演练结论。`exporter` 只读桶,演练结果不落桶它就无从得知。
10
+ *
11
+ * 每次演练写一个新对象,从不覆盖:
12
+ * <prefix>/<project>.drills/<结束时间>-<passed|failed>-<8 位随机>.json
13
+ * 为什么不是一份可覆盖的"最近一次":两次演练重叠时,先结束的那次可能后写进桶,把更新的
14
+ * "没通过"盖成更早的"通过",失败告警就没了;先读后写也挡不住(不是原子的),而各家
15
+ * S3 兼容存储对条件写入的支持参差不齐。每次一个对象,"哪次最新"留给读的一方按名字判断,
16
+ * 写入顺序就无所谓了。
17
+ *
18
+ * 为什么放在 <project>.drills/,和快照目录并排而不是在它里面:resolveSnapshot(包括已经发出去的
19
+ * 旧版本)按字典序取 <project>/ 下最大的目录当最新快照,里面多一个 drills/ 会被当成快照。
20
+ *
21
+ * 文件名本身带着结束时间和结论,exporter 列一下目录就知道最近一次和最近一次通过是哪个;
22
+ * 只为了恢复用时才读一次通过的那份。只记结论,不记检查明细:失败明细里可能有 pg_restore
23
+ * 回显的表数据,而桶的读权限可能比演练日志给得更宽(exporter 只要只读 key)。
24
+ */
25
+ /** 版本规则同 manifest:缺省不接受,更高的版本明确报错让用户升级,不按旧结构硬读。 */
26
+ export const DRILL_RECORD_SCHEMA_VERSION = 1;
27
+ /** 演练记录所在的"目录",带尾斜杠 */
28
+ export function drillRecordsPrefix(config) {
29
+ const storage = requireBucket(config);
30
+ return `${storage.prefix}/${config.projectName}.drills/`;
31
+ }
32
+ /**
33
+ * 时间戳写法同快照目录名(把 ISO 里的 : 和 . 换成 -),定长,按名字排序就是按时间排序。
34
+ * 末尾的随机段:两次演练在同一毫秒结束、结论又相同时,只靠时间和结论会撞名、后写的覆盖先写的
35
+ */
36
+ const RECORD_NAME = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z-(passed|failed)-[0-9a-f]{8}\.json$/;
37
+ export function drillRecordName(record, unique = randomBytes(4).toString("hex")) {
38
+ return `${record.finishedAt.replace(/[:.]/g, "-")}-${record.pass ? "passed" : "failed"}-${unique}.json`;
39
+ }
40
+ /** 从文件名读出结束时间(毫秒)和结论;不是记录文件的名字返回 null */
41
+ export function parseDrillRecordName(name) {
42
+ const m = RECORD_NAME.exec(name);
43
+ if (!m)
44
+ return null;
45
+ const finishedAt = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
46
+ return Number.isFinite(finishedAt) ? { finishedAt, pass: m[6] === "passed" } : null;
47
+ }
48
+ /** report 为 null = 演练中途抛错、没有报告;这也算一次没通过的演练。 */
49
+ export function buildDrillRecord(report, finishedAt) {
50
+ return {
51
+ schemaVersion: DRILL_RECORD_SCHEMA_VERSION,
52
+ tool: "backupdrill-cli",
53
+ toolVersion: TOOL_VERSION,
54
+ finishedAt: finishedAt.toISOString(),
55
+ pass: report?.pass === true,
56
+ ...(report ? { snapshot: report.snapshot } : {}),
57
+ ...(report?.pass ? { restoreSeconds: report.restoreSeconds } : {}),
58
+ };
59
+ }
60
+ /** 写一个新对象,返回它的 key。不读桶:只需要写权限 */
61
+ export async function writeDrillRecord(s3, config, record, abortSignal) {
62
+ const key = drillRecordsPrefix(config) + drillRecordName(record);
63
+ await s3.send(new PutObjectCommand({
64
+ Bucket: requireBucket(config).bucket,
65
+ Key: key,
66
+ Body: JSON.stringify(record, null, 2),
67
+ ContentType: "application/json",
68
+ }), { abortSignal });
69
+ return key;
70
+ }
71
+ /** 记一笔最多等这么久:桶接了请求却不回应时,演练不能卡在这里,后面还要退出、发监控 ping */
72
+ const RECORD_TIMEOUT_MS = 30_000;
73
+ /**
74
+ * CLI 的 `drill --record`:写不进去只警告,演练本身的结论和退出码不变。
75
+ * 常见原因是给演练配的是只读 key;这种情况下 exporter 的演练时间会一直不动,告警规则会发现。
76
+ */
77
+ export async function recordDrillOutcome(config, report, s3 = targetClient(config), timeoutMs = RECORD_TIMEOUT_MS) {
78
+ const record = buildDrillRecord(report, new Date());
79
+ try {
80
+ const key = await withDeadline((signal) => writeDrillRecord(s3, config, record, signal), timeoutMs);
81
+ log.ok(`Drill result recorded → s3://${requireBucket(config).bucket}/${key}`);
82
+ }
83
+ catch (error) {
84
+ const reason = isTimeout(error)
85
+ ? `no answer from the bucket within ${timeoutMs / 1000}s`
86
+ : error.message;
87
+ log.warn(`Could not record the drill result (${reason}). ` +
88
+ `--record needs write access to the bucket. The drill's own result is unchanged.`);
89
+ }
90
+ }
91
+ function malformed(file, what) {
92
+ throw new Error(`${file} is malformed: ${what}`);
93
+ }
94
+ /** 读桶里的演练记录。结构不对就报错:exporter 宁可报 up=0,也不报一个读错的结论。 */
95
+ export function parseDrillRecord(json, file) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(json);
99
+ }
100
+ catch (error) {
101
+ throw new Error(`${file} is not valid JSON: ${error.message}`);
102
+ }
103
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
104
+ malformed(file, "root is not an object");
105
+ }
106
+ const r = parsed;
107
+ if (typeof r.schemaVersion !== "number" || !Number.isInteger(r.schemaVersion) || r.schemaVersion < 1) {
108
+ malformed(file, `schemaVersion ${JSON.stringify(r.schemaVersion)}`);
109
+ }
110
+ if (r.schemaVersion > DRILL_RECORD_SCHEMA_VERSION) {
111
+ throw new Error(`${file} uses schema version ${r.schemaVersion}, but this BackupDrill understands up to ` +
112
+ `${DRILL_RECORD_SCHEMA_VERSION}. Upgrade the CLI (npm i -g backupdrill@latest) to read it.`);
113
+ }
114
+ if (typeof r.finishedAt !== "string" || !Number.isFinite(Date.parse(r.finishedAt))) {
115
+ malformed(file, "finishedAt");
116
+ }
117
+ if (typeof r.pass !== "boolean")
118
+ malformed(file, "pass");
119
+ if (r.snapshot !== undefined && typeof r.snapshot !== "string")
120
+ malformed(file, "snapshot");
121
+ if (r.restoreSeconds !== undefined &&
122
+ (typeof r.restoreSeconds !== "number" || !Number.isFinite(r.restoreSeconds) || r.restoreSeconds < 0)) {
123
+ malformed(file, "restoreSeconds");
124
+ }
125
+ return r;
126
+ }
@@ -0,0 +1,57 @@
1
+ import { type Server } from "node:http";
2
+ import { type S3Client } from "@aws-sdk/client-s3";
3
+ import { type BackupConfig } from "./config.js";
4
+ /**
5
+ * `backupdrill exporter`:按 Prometheus 文本格式暴露桶里备份与演练的状态。
6
+ * 规矩照 https://prometheus.io/docs/instrumenting/writing_exporters/ :
7
+ * - 每次抓取现读桶,不按自己的定时器预取,不报旧值;
8
+ * - 读不了桶时照样回 200,backupdrill_up 为 0,其余业务指标不出(半套数据比没有更误导);
9
+ * - 指标不带标签(一个 exporter 进程只看一个项目,项目名属于 Prometheus 那边的 target 标签),
10
+ * 连接串、key、桶名一律不进指标。
11
+ */
12
+ /** 10059:Prometheus wiki「Default port allocations」上 10058 之后的第一个空位(2026-10-20 查)。 */
13
+ export declare const DEFAULT_EXPORTER_PORT = 10059;
14
+ /** 最新一份完整快照里 exporter 要用的数,不留整份 manifest(Storage 文件多时 manifest 很大) */
15
+ export interface SnapshotSummary {
16
+ createdAt: number;
17
+ dumpBytes: number;
18
+ storageFiles: number;
19
+ storageBytes: number;
20
+ tables: number;
21
+ estimatedRows: number;
22
+ }
23
+ export interface BucketState {
24
+ /** <prefix>/<project>/ 下的快照目录数,含没写完 manifest 的 */
25
+ snapshots: number;
26
+ /** 最新一份有 manifest.json 的快照;一份都没有时为 null */
27
+ latest: SnapshotSummary | null;
28
+ /** 最近一次记下的演练(drill --record);没有时为 null */
29
+ lastDrill: {
30
+ finishedAt: number;
31
+ pass: boolean;
32
+ } | null;
33
+ /** 最近一次通过的演练;restoreSeconds 来自它的记录内容 */
34
+ lastPassedDrill: {
35
+ finishedAt: number;
36
+ restoreSeconds?: number;
37
+ } | null;
38
+ }
39
+ /**
40
+ * 读桶的函数。每次调用都现读;唯一记住的是最新一份 manifest 的 ETag 和摘要:
41
+ * 下次带 If-None-Match 去问,没变就是 304、不传正文。快照写完就不再改,而一份 manifest 列着
42
+ * 每个 Storage 文件,可能有几十 MB,每 30 秒整份下载一遍会变成用户账单上的流量。
43
+ */
44
+ export declare function createBucketReader(s3: S3Client, config: BackupConfig): (abortSignal: AbortSignal) => Promise<BucketState>;
45
+ /** state 为 null = 这次没读成桶。全部是 gauge:都是"某一刻的状态",没有一个会只增不减。 */
46
+ export declare function renderMetrics(state: BucketState | null, scrapeSeconds: number): string;
47
+ /**
48
+ * 读桶的时限跟着 Prometheus 走:它在请求头里告诉我们这次抓取最多等多久,留半秒余量,
49
+ * 好让超时的这次还能回一个 up=0,而不是被 Prometheus 当成 exporter 挂了。
50
+ */
51
+ export declare function scrapeTimeoutMs(header: string | string[] | undefined): number;
52
+ export declare function createExporterServer(read: (abortSignal: AbortSignal) => Promise<BucketState>): Server;
53
+ /** CLI 的 `backupdrill exporter`:起 HTTP 服务,直到进程被停掉。 */
54
+ export declare function startExporter(config: BackupConfig, opts: {
55
+ host: string;
56
+ port: number;
57
+ }): Promise<Server>;
@@ -0,0 +1,305 @@
1
+ import { createServer } from "node:http";
2
+ import { performance } from "node:perf_hooks";
3
+ import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
4
+ import { requireBucket } from "./config.js";
5
+ import { withDeadline } from "./deadline.js";
6
+ import { drillRecordsPrefix, parseDrillRecord, parseDrillRecordName } from "./drill-record.js";
7
+ import { log } from "./log.js";
8
+ import { parseManifest } from "./manifest.js";
9
+ import { listSnapshotPrefixes, targetClient } from "./snapshots.js";
10
+ import { TOOL_VERSION } from "./version.js";
11
+ /**
12
+ * `backupdrill exporter`:按 Prometheus 文本格式暴露桶里备份与演练的状态。
13
+ * 规矩照 https://prometheus.io/docs/instrumenting/writing_exporters/ :
14
+ * - 每次抓取现读桶,不按自己的定时器预取,不报旧值;
15
+ * - 读不了桶时照样回 200,backupdrill_up 为 0,其余业务指标不出(半套数据比没有更误导);
16
+ * - 指标不带标签(一个 exporter 进程只看一个项目,项目名属于 Prometheus 那边的 target 标签),
17
+ * 连接串、key、桶名一律不进指标。
18
+ */
19
+ /** 10059:Prometheus wiki「Default port allocations」上 10058 之后的第一个空位(2026-10-20 查)。 */
20
+ export const DEFAULT_EXPORTER_PORT = 10059;
21
+ /** 没有 Prometheus 超时头时(手动 curl)给读桶留的时间,与 Prometheus 默认 scrape_timeout 一致 */
22
+ const DEFAULT_SCRAPE_TIMEOUT_MS = 10_000;
23
+ const MAX_SCRAPE_TIMEOUT_MS = 5 * 60_000;
24
+ /**
25
+ * 记录里的时间最多可以比这台机器的时钟快多少。备份、演练、exporter 可能在不同机器上跑,
26
+ * 几分钟的时钟差是正常的;再多就不可信。
27
+ */
28
+ const CLOCK_SKEW_TOLERANCE_MS = 10 * 60_000;
29
+ function httpStatusOf(error) {
30
+ return error.$metadata?.httpStatusCode;
31
+ }
32
+ async function readObject(s3, bucket, key, abortSignal, ifNoneMatch) {
33
+ try {
34
+ const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key, IfNoneMatch: ifNoneMatch }), { abortSignal });
35
+ return { status: "ok", text: await res.Body.transformToString(), etag: res.ETag };
36
+ }
37
+ catch (error) {
38
+ const status = httpStatusOf(error);
39
+ if (status === 304)
40
+ return { status: "not-modified" };
41
+ if (status === 404 || error.name === "NoSuchKey")
42
+ return { status: "missing" };
43
+ throw error;
44
+ }
45
+ }
46
+ function summarize(text, key) {
47
+ const manifest = parseManifest(text);
48
+ // parseManifest 不校验 createdAt(演练和恢复用不到),这里要拿它当备份时间,自己把关
49
+ const createdAt = Date.parse(manifest.createdAt);
50
+ if (!Number.isFinite(createdAt)) {
51
+ throw new Error(`${key} is malformed: createdAt`);
52
+ }
53
+ assertNotInFuture(createdAt, key, "createdAt");
54
+ const summary = {
55
+ createdAt,
56
+ dumpBytes: manifest.dump.bytes,
57
+ storageFiles: manifest.storage?.fileCount ?? 0,
58
+ storageBytes: manifest.storage?.totalBytes ?? 0,
59
+ tables: manifest.database.tableCount,
60
+ estimatedRows: manifest.database.estimatedRowTotal,
61
+ };
62
+ // 这些数会原样写进指标文本。parseManifest 只管演练和恢复要用的字段(estimatedRowTotal 就不查),
63
+ // 一个字符串或带换行的值能让整次抓取在 Prometheus 那边解析失败,甚至塞进假样本
64
+ for (const [field, value] of Object.entries(summary)) {
65
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
66
+ throw new Error(`${key} is malformed: ${field} is not a non-negative number`);
67
+ }
68
+ }
69
+ return summary;
70
+ }
71
+ /**
72
+ * 时间在将来的记录会让"距今多久"的告警永远不响(time() - 将来的时间是负数)。
73
+ * 报成读桶失败(up=0),让人去看是记录被改了还是哪台机器的时钟不对。
74
+ */
75
+ function assertNotInFuture(at, key, field) {
76
+ if (at > Date.now() + CLOCK_SKEW_TOLERANCE_MS) {
77
+ throw new Error(`${key} has ${field} ${new Date(at).toISOString()}, more than 10 minutes in the future ` +
78
+ `(check the clock on the machine that wrote it)`);
79
+ }
80
+ }
81
+ /** <prefix>/<project>.drills/ 下的对象名,分页列完 */
82
+ async function listDrillRecordNames(s3, bucket, prefix, abortSignal) {
83
+ const names = [];
84
+ let token;
85
+ do {
86
+ const res = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, Delimiter: "/", ContinuationToken: token }), { abortSignal });
87
+ for (const object of res.Contents ?? []) {
88
+ if (object.Key)
89
+ names.push(object.Key.slice(prefix.length));
90
+ }
91
+ token = res.IsTruncated ? res.NextContinuationToken : undefined;
92
+ } while (token);
93
+ return names;
94
+ }
95
+ /**
96
+ * 最近一次演练和最近一次通过的演练。结束时间和结论都在文件名里,列一下目录就够;
97
+ * 只为了恢复用时读一次通过的那份。时间在将来的记录(写它的机器时钟快了,或被改过)跳过并警告:
98
+ * 它会一直排在最前面,让"距今多久"的告警算出负数。记录从不删除,所以不能让它把读桶整个弄成失败。
99
+ */
100
+ async function readDrills(s3, config, abortSignal) {
101
+ const bucket = requireBucket(config).bucket;
102
+ const prefix = drillRecordsPrefix(config);
103
+ const now = Date.now();
104
+ let lastDrill = null;
105
+ let lastPassed = null;
106
+ for (const name of await listDrillRecordNames(s3, bucket, prefix, abortSignal)) {
107
+ const parsed = parseDrillRecordName(name);
108
+ if (!parsed)
109
+ continue;
110
+ if (parsed.finishedAt > now + CLOCK_SKEW_TOLERANCE_MS) {
111
+ log.warn(`Ignoring ${prefix}${name}: it is dated more than 10 minutes in the future (check the clock on the machine that ran the drill).`);
112
+ continue;
113
+ }
114
+ if (!lastDrill || parsed.finishedAt > lastDrill.finishedAt)
115
+ lastDrill = { ...parsed, name };
116
+ if (parsed.pass && (!lastPassed || parsed.finishedAt > lastPassed.finishedAt)) {
117
+ lastPassed = { finishedAt: parsed.finishedAt, name };
118
+ }
119
+ }
120
+ let lastPassedDrill = null;
121
+ if (lastPassed) {
122
+ const key = prefix + lastPassed.name;
123
+ const read = await readObject(s3, bucket, key, abortSignal);
124
+ // 刚列出来就不见了:生命周期规则正好删了它,或者有人手动删了。按读失败处理,下次抓取再看
125
+ if (read.status !== "ok")
126
+ throw new Error(`${key} disappeared while it was being read`);
127
+ const record = parseDrillRecord(read.text, key);
128
+ if (!record.pass || Date.parse(record.finishedAt) !== lastPassed.finishedAt) {
129
+ throw new Error(`${key} is malformed: its content does not match its name`);
130
+ }
131
+ lastPassedDrill = { finishedAt: lastPassed.finishedAt, restoreSeconds: record.restoreSeconds };
132
+ }
133
+ return {
134
+ lastDrill: lastDrill ? { finishedAt: lastDrill.finishedAt, pass: lastDrill.pass } : null,
135
+ lastPassedDrill,
136
+ };
137
+ }
138
+ /**
139
+ * 读桶的函数。每次调用都现读;唯一记住的是最新一份 manifest 的 ETag 和摘要:
140
+ * 下次带 If-None-Match 去问,没变就是 304、不传正文。快照写完就不再改,而一份 manifest 列着
141
+ * 每个 Storage 文件,可能有几十 MB,每 30 秒整份下载一遍会变成用户账单上的流量。
142
+ */
143
+ export function createBucketReader(s3, config) {
144
+ const storage = requireBucket(config);
145
+ const root = `${storage.prefix}/${config.projectName}/`;
146
+ let remembered = null;
147
+ return async (abortSignal) => {
148
+ const prefixes = (await listSnapshotPrefixes(s3, storage.bucket, root, abortSignal))
149
+ .sort()
150
+ .reverse();
151
+ // manifest 是快照的完成标记,最后写。目录在、manifest 不在 = 备份还在跑或中途失败了,
152
+ // 往前找上一份完整的。不设上限:找太久就撞上抓取超时,报 up=0,这本身就该有人去看
153
+ let latest = null;
154
+ for (const prefix of prefixes) {
155
+ const key = `${prefix}manifest.json`;
156
+ const known = remembered?.key === key ? remembered : null;
157
+ const read = await readObject(s3, storage.bucket, key, abortSignal, known?.etag);
158
+ if (read.status === "missing")
159
+ continue;
160
+ if (read.status === "not-modified" && known) {
161
+ // 内容没变,但"是不是在将来"取决于现在几点:本机时钟被校正回来之后要重新判断
162
+ assertNotInFuture(known.summary.createdAt, key, "createdAt");
163
+ latest = known.summary;
164
+ }
165
+ else if (read.status === "ok") {
166
+ latest = summarize(read.text, key);
167
+ remembered = read.etag ? { key, etag: read.etag, summary: latest } : null;
168
+ }
169
+ else {
170
+ // 没带 If-None-Match 却收到 304:对端行为不对,别猜
171
+ throw new Error(`unexpected 304 Not Modified for ${key}`);
172
+ }
173
+ break;
174
+ }
175
+ const drills = await readDrills(s3, config, abortSignal);
176
+ return { snapshots: prefixes.length, latest, ...drills };
177
+ };
178
+ }
179
+ const escapeHelp = (s) => s.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
180
+ const escapeLabel = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
181
+ const seconds = (ms) => ms / 1000;
182
+ function gauge(name, help, value) {
183
+ return value === null ? null : { name, help, samples: [{ value }] };
184
+ }
185
+ /** state 为 null = 这次没读成桶。全部是 gauge:都是"某一刻的状态",没有一个会只增不减。 */
186
+ export function renderMetrics(state, scrapeSeconds) {
187
+ const latest = state?.latest ?? null;
188
+ const lastDrill = state?.lastDrill ?? null;
189
+ const lastPassed = state?.lastPassedDrill ?? null;
190
+ const families = [
191
+ gauge("backupdrill_up", "Whether the last read of the backup bucket worked (1) or failed (0).", state ? 1 : 0),
192
+ gauge("backupdrill_scrape_duration_seconds", "How long this scrape took to read the backup bucket.", scrapeSeconds),
193
+ {
194
+ name: "backupdrill_build_info",
195
+ help: "The backupdrill CLI version running this exporter. Always 1.",
196
+ samples: [{ labels: { version: TOOL_VERSION }, value: 1 }],
197
+ },
198
+ ];
199
+ if (state) {
200
+ families.push(gauge("backupdrill_snapshots", "Snapshot folders under <prefix>/<project>/, including ones without a manifest.json yet.", state.snapshots), gauge("backupdrill_backup_last_success_timestamp_seconds", "When the newest complete snapshot finished (its manifest.json createdAt), as a Unix timestamp. 0 if there is none.", latest ? seconds(latest.createdAt) : 0), gauge("backupdrill_latest_snapshot_dump_bytes", "Size of the database dump (pg_dump custom format) in the newest complete snapshot.", latest?.dumpBytes ?? null), gauge("backupdrill_latest_snapshot_storage_files", "Supabase Storage files in the newest complete snapshot. 0 for a database-only backup.", latest?.storageFiles ?? null), gauge("backupdrill_latest_snapshot_storage_bytes", "Total size of the Supabase Storage files in the newest complete snapshot.", latest?.storageBytes ?? null), gauge("backupdrill_latest_snapshot_tables", "Tables, partitioned tables and materialized views captured in the newest complete snapshot.", latest?.tables ?? null), gauge("backupdrill_latest_snapshot_estimated_rows", "Row count of the newest complete snapshot, estimated from Postgres statistics (n_live_tup) at backup time.", latest?.estimatedRows ?? null), gauge("backupdrill_drill_last_run_timestamp_seconds", "When the last drill recorded with --record finished, passed or not, as a Unix timestamp. 0 if none is recorded.", lastDrill ? seconds(lastDrill.finishedAt) : 0), gauge("backupdrill_drill_last_run_passed", "Whether the last recorded drill passed (1) or not (0).", lastDrill ? (lastDrill.pass ? 1 : 0) : null), gauge("backupdrill_drill_last_success_timestamp_seconds", "When the last passing drill recorded with --record finished, as a Unix timestamp. 0 if none is recorded.", lastPassed ? seconds(lastPassed.finishedAt) : 0), gauge("backupdrill_drill_last_success_restore_duration_seconds", "How long the database restore took in the last passing recorded drill.", lastPassed?.restoreSeconds ?? null));
201
+ }
202
+ const lines = [];
203
+ for (const family of families) {
204
+ if (!family)
205
+ continue;
206
+ lines.push(`# HELP ${family.name} ${escapeHelp(family.help)}`, `# TYPE ${family.name} gauge`);
207
+ for (const sample of family.samples) {
208
+ const labels = sample.labels
209
+ ? `{${Object.entries(sample.labels)
210
+ .map(([k, v]) => `${k}="${escapeLabel(v)}"`)
211
+ .join(",")}}`
212
+ : "";
213
+ lines.push(`${family.name}${labels} ${sample.value}`);
214
+ }
215
+ }
216
+ return lines.join("\n") + "\n";
217
+ }
218
+ /**
219
+ * 读桶的时限跟着 Prometheus 走:它在请求头里告诉我们这次抓取最多等多久,留半秒余量,
220
+ * 好让超时的这次还能回一个 up=0,而不是被 Prometheus 当成 exporter 挂了。
221
+ */
222
+ export function scrapeTimeoutMs(header) {
223
+ const value = Number(Array.isArray(header) ? header[0] : header);
224
+ if (!Number.isFinite(value) || value <= 0)
225
+ return DEFAULT_SCRAPE_TIMEOUT_MS;
226
+ // AbortSignal.timeout 只收整数毫秒(1.1 秒算出来是 600.0000000000001,直接抛错);
227
+ // 上限防一个离谱的头把读桶拖成几小时
228
+ return Math.min(Math.max(1, Math.floor(Math.max(value - 0.5, value / 2) * 1000)), MAX_SCRAPE_TIMEOUT_MS);
229
+ }
230
+ const LANDING_PAGE = `<!doctype html>
231
+ <html lang="en">
232
+ <head><meta charset="utf-8"><title>backupdrill exporter</title></head>
233
+ <body>
234
+ <h1>backupdrill exporter</h1>
235
+ <p>Version ${TOOL_VERSION}. Prometheus metrics for the backups and recorded restore drills in one project's bucket.</p>
236
+ <p><a href="/metrics">Metrics</a></p>
237
+ </body>
238
+ </html>
239
+ `;
240
+ export function createExporterServer(read) {
241
+ return createServer((req, res) => {
242
+ // 处理函数是 async:里面任何没接住的异常都会变成未处理的 rejection,把整个进程带走。
243
+ // 端口对 Prometheus 开着,一个怪请求不能让 exporter 停掉
244
+ handle(req, res, read).catch((error) => {
245
+ log.warn(`Request failed: ${error.message}`);
246
+ if (!res.headersSent)
247
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
248
+ res.end();
249
+ });
250
+ });
251
+ }
252
+ async function handle(req, res, read) {
253
+ // 只要路径,不用 new URL:"GET // HTTP/1.1" 这种请求行会让它抛 Invalid URL
254
+ const path = (req.url ?? "/").split("?")[0];
255
+ if (req.method !== "GET") {
256
+ res.writeHead(405, { Allow: "GET", "Content-Type": "text/plain; charset=utf-8" });
257
+ res.end("Method Not Allowed\n");
258
+ return;
259
+ }
260
+ if (path === "/") {
261
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
262
+ res.end(LANDING_PAGE);
263
+ return;
264
+ }
265
+ if (path !== "/metrics") {
266
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
267
+ res.end("Not Found\n");
268
+ return;
269
+ }
270
+ const started = performance.now();
271
+ const timeoutMs = scrapeTimeoutMs(req.headers["x-prometheus-scrape-timeout-seconds"]);
272
+ let state = null;
273
+ try {
274
+ state = await withDeadline(read, timeoutMs);
275
+ }
276
+ catch (error) {
277
+ const e = error;
278
+ const reason = e.name === "TimeoutError" || e.name === "AbortError"
279
+ ? `timed out after ${(timeoutMs / 1000).toFixed(1)}s`
280
+ : e.message;
281
+ log.warn(`Could not read the backup bucket: ${reason}`);
282
+ }
283
+ // 微秒够用,免得样本里出现 0.021267749999999978 这种浮点尾巴
284
+ const body = renderMetrics(state, Number(((performance.now() - started) / 1000).toFixed(6)));
285
+ res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4; charset=utf-8" });
286
+ res.end(body);
287
+ }
288
+ /** CLI 的 `backupdrill exporter`:起 HTTP 服务,直到进程被停掉。 */
289
+ export async function startExporter(config, opts) {
290
+ const storage = requireBucket(config);
291
+ const server = createExporterServer(createBucketReader(targetClient(config), config));
292
+ await new Promise((resolve, reject) => {
293
+ server.once("error", reject);
294
+ server.listen(opts.port, opts.host, () => {
295
+ server.off("error", reject);
296
+ resolve();
297
+ });
298
+ });
299
+ // 报实际绑定的端口:--port 0 时由系统挑
300
+ const port = server.address().port;
301
+ const shownHost = opts.host.includes(":") ? `[${opts.host}]` : opts.host;
302
+ log.ok(`Serving metrics for s3://${storage.bucket}/${storage.prefix}/${config.projectName}/ ` +
303
+ `at http://${shownHost}:${port}/metrics`);
304
+ return server;
305
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import { runDrill } from "./drill.js";
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { Writable } from "node:stream";
10
10
  import { runRestore, NO_SOURCE_DATABASE } from "./restore.js";
11
+ import { recordDrillOutcome } from "./drill-record.js";
12
+ import { DEFAULT_EXPORTER_PORT, startExporter } from "./exporter.js";
11
13
  /** 隐藏式秘密输入(PRD §5.3.1):终端不回显;无 TTY(CI/脚本)时明确要求走环境变量。 */
12
14
  async function promptSecret(label) {
13
15
  if (!process.stdin.isTTY) {
@@ -114,9 +116,12 @@ program
114
116
  "verify data and business invariants here, not RLS behavior")
115
117
  .option("--keep", "if the drill fails, keep the sandbox container running for inspection")
116
118
  .option(PING_FLAG, PING_HELP)
119
+ .option("--record", "write the outcome as a small JSON file in <prefix>/<project>.drills/, where " +
120
+ "`backupdrill exporter` reads it. Needs write access to the bucket")
117
121
  .action(async (opts) => {
118
122
  const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
119
123
  await ping.start();
124
+ // config 放在 try 外面:出错时 /fail 要用它抹敏感值,--record 要用它记一笔"没通过"
120
125
  let config;
121
126
  try {
122
127
  if (opts.checkCmd !== undefined && !String(opts.checkCmd).trim()) {
@@ -152,6 +157,8 @@ program
152
157
  `${report.restoredTableCount} tables / ${report.restoredRowTotal.toLocaleString()} rows ` +
153
158
  `restored in ${report.restoreSeconds}s`);
154
159
  process.stdout.write(JSON.stringify({ ok: report.pass, report }) + "\n");
160
+ if (opts.record)
161
+ await recordDrillOutcome(config, report);
155
162
  const outcome = drillOutcome(report);
156
163
  if (outcome.pass) {
157
164
  await ping.success(outcome.text);
@@ -165,6 +172,9 @@ program
165
172
  const message = error.message;
166
173
  log.error(message);
167
174
  process.exitCode = 1;
175
+ // 没装 Docker、桶里没快照这类中途失败也算一次没通过的演练;配置都没读成就无处可记
176
+ if (opts.record && config)
177
+ await recordDrillOutcome(config, null);
168
178
  await ping.fail(`backupdrill ${TOOL_VERSION}: drill failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config));
169
179
  }
170
180
  });
@@ -297,6 +307,44 @@ program
297
307
  process.exitCode = 1;
298
308
  }
299
309
  });
310
+ program
311
+ .command("exporter")
312
+ .description("Serve Prometheus metrics about the backups (and drills run with --record) in your bucket")
313
+ .option("-c, --config <path>", "path to a JSON config file", "backupdrill.config.json")
314
+ .option("--bucket <name>", "bucket the backups live in")
315
+ .option("--endpoint <url>", "S3-compatible endpoint (for R2/B2)")
316
+ .option("--region <region>", "bucket region")
317
+ .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
318
+ .option("--project-name <name>", "project name used in the object key")
319
+ .option("--host <address>", "address to listen on; use 0.0.0.0 when Prometheus runs on another machine", "127.0.0.1")
320
+ .option("--port <port>", "port to listen on", String(DEFAULT_EXPORTER_PORT))
321
+ .action(async (opts) => {
322
+ try {
323
+ const port = Number(opts.port);
324
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
325
+ throw new Error(`--port must be a whole number from 1 to 65535, got "${opts.port}"`);
326
+ }
327
+ const config = await loadConfig({
328
+ configPath: opts.config,
329
+ overrides: {
330
+ projectName: opts.projectName,
331
+ // exporter 只读桶,不连源库;给 databaseUrl 占位以通过校验
332
+ databaseUrl: "postgresql://unused",
333
+ storage: {
334
+ bucket: opts.bucket,
335
+ endpoint: opts.endpoint,
336
+ region: opts.region,
337
+ prefix: opts.prefix,
338
+ },
339
+ },
340
+ });
341
+ await startExporter(config, { host: opts.host, port });
342
+ }
343
+ catch (error) {
344
+ log.error(error.message);
345
+ process.exitCode = 1;
346
+ }
347
+ });
300
348
  program.parseAsync().catch((error) => {
301
349
  log.error(error.message);
302
350
  process.exitCode = 1;
@@ -2,6 +2,11 @@ import { S3Client } from "@aws-sdk/client-s3";
2
2
  import { type BackupConfig } from "./config.js";
3
3
  /** 读备份桶(用户自己的桶)的 S3 客户端。drill/restore 共用。 */
4
4
  export declare function targetClient(config: BackupConfig): S3Client;
5
+ /**
6
+ * 列出 root(带尾斜杠)下的全部快照目录,返回带尾斜杠的前缀,未排序。只看 CommonPrefixes,
7
+ * root 下直接放的对象不算快照。
8
+ */
9
+ export declare function listSnapshotPrefixes(s3: S3Client, bucket: string, root: string, abortSignal?: AbortSignal): Promise<string[]>;
5
10
  /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
6
11
  export declare function resolveSnapshot(s3: S3Client, config: BackupConfig, explicit?: string): Promise<string>;
7
12
  export declare function getObjectText(s3: S3Client, bucket: string, key: string): Promise<string>;
package/dist/snapshots.js CHANGED
@@ -18,27 +18,35 @@ export function targetClient(config) {
18
18
  },
19
19
  });
20
20
  }
21
- /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
22
- export async function resolveSnapshot(s3, config, explicit) {
23
- const storage = requireBucket(config);
24
- const root = `${storage.prefix}/${config.projectName}/`;
25
- if (explicit)
26
- return `${root}${explicit}/`;
21
+ /**
22
+ * 列出 root(带尾斜杠)下的全部快照目录,返回带尾斜杠的前缀,未排序。只看 CommonPrefixes,
23
+ * root 下直接放的对象不算快照。
24
+ */
25
+ export async function listSnapshotPrefixes(s3, bucket, root, abortSignal) {
27
26
  const prefixes = [];
28
27
  let token;
29
28
  do {
30
29
  const res = await s3.send(new ListObjectsV2Command({
31
- Bucket: storage.bucket,
30
+ Bucket: bucket,
32
31
  Prefix: root,
33
32
  Delimiter: "/",
34
33
  ContinuationToken: token,
35
- }));
34
+ }), { abortSignal });
36
35
  for (const cp of res.CommonPrefixes ?? []) {
37
36
  if (cp.Prefix)
38
37
  prefixes.push(cp.Prefix);
39
38
  }
40
39
  token = res.IsTruncated ? res.NextContinuationToken : undefined;
41
40
  } while (token);
41
+ return prefixes;
42
+ }
43
+ /** 定位快照:显式指定,否则取字典序最大(时间戳格式天然可排序)。返回带尾斜杠的前缀。 */
44
+ export async function resolveSnapshot(s3, config, explicit) {
45
+ const storage = requireBucket(config);
46
+ const root = `${storage.prefix}/${config.projectName}/`;
47
+ if (explicit)
48
+ return `${root}${explicit}/`;
49
+ const prefixes = await listSnapshotPrefixes(s3, storage.bucket, root);
42
50
  if (!prefixes.length) {
43
51
  throw new Error(`No snapshots found under s3://${storage.bucket}/${root}. Run a backup first.`);
44
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backupdrill",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Open-source CLI to back up a Supabase project — Postgres database and Storage files — to your own S3/R2/B2 bucket, with a checksummed manifest.",
5
5
  "type": "module",
6
6
  "bin": {