backupdrill 2.4.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.
@@ -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
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { loadConfig } from "./config.js";
3
+ import { loadConfig, resolvePingUrl } from "./config.js";
4
+ import { backupSummary, createPinger, drillOutcome, pingMessageOf, sensitiveValuesOf } from "./ping.js";
4
5
  import { runBackup } from "./backup.js";
5
6
  import { runEstimate } from "./estimate.js";
6
7
  import { runDrill } from "./drill.js";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { Writable } from "node:stream";
9
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";
10
13
  /** 隐藏式秘密输入(PRD §5.3.1):终端不回显;无 TTY(CI/脚本)时明确要求走环境变量。 */
11
14
  async function promptSecret(label) {
12
15
  if (!process.stdin.isTTY) {
@@ -37,6 +40,10 @@ const EGRESS_PRICING = {
37
40
  pro: { planLabel: "Pro", includedGb: 250, pricePerGb: 0.09, hardCap: false },
38
41
  team: { planLabel: "Team", includedGb: 250, pricePerGb: 0.09, hardCap: false },
39
42
  };
43
+ // backup 和 drill 共用:按 Healthchecks.io 的约定报告开始、成功、失败
44
+ const PING_FLAG = "--ping-url <url>";
45
+ const PING_HELP = "Healthchecks.io ping URL (or BACKUPDRILL_PING_URL): pings <url>/start, then <url> on " +
46
+ "success or <url>/fail on failure. A ping that fails only prints a warning";
40
47
  const program = new Command();
41
48
  program
42
49
  .name("backupdrill")
@@ -55,9 +62,13 @@ program
55
62
  .option("--endpoint <url>", "S3-compatible endpoint (required for R2/B2; omit for AWS S3)")
56
63
  .option("--region <region>", "bucket region (default: auto)")
57
64
  .option("--prefix <prefix>", "key prefix inside the bucket (default: backupdrill)")
65
+ .option(PING_FLAG, PING_HELP)
58
66
  .action(async (opts) => {
67
+ const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
68
+ await ping.start();
69
+ let config;
59
70
  try {
60
- const config = await loadConfig({
71
+ config = await loadConfig({
61
72
  configPath: opts.config,
62
73
  overrides: {
63
74
  databaseUrl: opts.databaseUrl,
@@ -79,10 +90,13 @@ program
79
90
  `${storageNote}.`);
80
91
  // stdout 输出机器可读结果,便于在 CI / GitHub Action 里消费
81
92
  process.stdout.write(JSON.stringify({ ok: true, manifest }) + "\n");
93
+ await ping.success(backupSummary(manifest));
82
94
  }
83
95
  catch (error) {
84
- log.error(error.message);
96
+ const message = error.message;
97
+ log.error(message);
85
98
  process.exitCode = 1;
99
+ await ping.fail(`backupdrill ${TOOL_VERSION}: backup failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config, opts.databaseUrl));
86
100
  }
87
101
  });
88
102
  program
@@ -101,13 +115,20 @@ program
101
115
  "Note: connects as the sandbox superuser, so RLS policies are NOT enforced — " +
102
116
  "verify data and business invariants here, not RLS behavior")
103
117
  .option("--keep", "if the drill fails, keep the sandbox container running for inspection")
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")
104
121
  .action(async (opts) => {
122
+ const ping = createPinger(await resolvePingUrl({ flag: opts.pingUrl, configPath: opts.config }));
123
+ await ping.start();
124
+ // config 放在 try 外面:出错时 /fail 要用它抹敏感值,--record 要用它记一笔"没通过"
125
+ let config;
105
126
  try {
106
127
  if (opts.checkCmd !== undefined && !String(opts.checkCmd).trim()) {
107
128
  throw new Error("--check-cmd is empty (did an env var fail to expand?) — refusing to run a drill " +
108
129
  "that would report a check that never executes");
109
130
  }
110
- const config = await loadConfig({
131
+ config = await loadConfig({
111
132
  configPath: opts.config,
112
133
  overrides: {
113
134
  projectName: opts.projectName,
@@ -136,12 +157,25 @@ program
136
157
  `${report.restoredTableCount} tables / ${report.restoredRowTotal.toLocaleString()} rows ` +
137
158
  `restored in ${report.restoreSeconds}s`);
138
159
  process.stdout.write(JSON.stringify({ ok: report.pass, report }) + "\n");
139
- if (!report.pass)
160
+ if (opts.record)
161
+ await recordDrillOutcome(config, report);
162
+ const outcome = drillOutcome(report);
163
+ if (outcome.pass) {
164
+ await ping.success(outcome.text);
165
+ }
166
+ else {
140
167
  process.exitCode = 1;
168
+ await ping.fail(outcome.text, sensitiveValuesOf(config));
169
+ }
141
170
  }
142
171
  catch (error) {
143
- log.error(error.message);
172
+ const message = error.message;
173
+ log.error(message);
144
174
  process.exitCode = 1;
175
+ // 没装 Docker、桶里没快照这类中途失败也算一次没通过的演练;配置都没读成就无处可记
176
+ if (opts.record && config)
177
+ await recordDrillOutcome(config, null);
178
+ await ping.fail(`backupdrill ${TOOL_VERSION}: drill failed. ${pingMessageOf(error)}`, sensitiveValuesOf(config));
145
179
  }
146
180
  });
147
181
  program
@@ -273,6 +307,44 @@ program
273
307
  process.exitCode = 1;
274
308
  }
275
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
+ });
276
348
  program.parseAsync().catch((error) => {
277
349
  log.error(error.message);
278
350
  process.exitCode = 1;
package/dist/ping.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { BackupConfig } from "./config.js";
2
+ import type { DrillReport } from "./drill.js";
3
+ import type { Manifest } from "./manifest.js";
4
+ export interface Pinger {
5
+ start(): Promise<void>;
6
+ success(summary: string): Promise<void>;
7
+ /** sensitive:这次运行用到的连接串、密钥等,发出去之前从 reason 里抹掉 */
8
+ fail(reason: string, sensitive?: string[]): Promise<void>;
9
+ }
10
+ export declare function createPinger(rawUrl: string | undefined, opts?: {
11
+ timeoutMs?: number;
12
+ attempts?: number;
13
+ retryDelayMs?: number;
14
+ }): Pinger;
15
+ /** backup 成功时发给监控的一行。快照名取对象键里的目录名(和 drill --snapshot 认的是同一个) */
16
+ export declare function backupSummary(manifest: Manifest): string;
17
+ /** drill 跑完(没抛错)后发哪种 ping、带什么:通过 = 成功;没通过 = 失败,列出没过的检查 */
18
+ export declare function drillOutcome(report: DrillReport): {
19
+ pass: boolean;
20
+ text: string;
21
+ };
22
+ /** 报错可以自带一句发给监控用的说法(pingMessage),比如不能复述配置文件原文的那种 */
23
+ export declare function pingMessageOf(error: unknown): string;
24
+ /**
25
+ * 失败原因发出去之前抹掉连接信息。先按值抹(这次运行确实用到的连接串、密码、主机、密钥),
26
+ * 再按形状兜底:pg / S3 的报错会带主机名、IP、整条连接串,配置文件解析失败时 JSON.parse
27
+ * 的报错还会带一截文件原文,这些都不是事先知道的值。
28
+ */
29
+ export declare function scrubForPing(text: string, sensitive: string[]): string;
30
+ /** 这次运行里不该出现在 ping 里的值:连接串、密码、主机、项目 ref、桶和 Storage 的密钥。 */
31
+ export declare function sensitiveValuesOf(config: BackupConfig | undefined, extraDatabaseUrl?: string): string[];