backupdrill 2.5.0 → 2.7.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 +116 -7
- package/dist/deadline.d.ts +9 -0
- package/dist/deadline.js +29 -0
- package/dist/drill-record.d.ts +53 -0
- package/dist/drill-record.js +126 -0
- package/dist/drill.js +100 -13
- package/dist/dump-sql.d.ts +52 -0
- package/dist/dump-sql.js +112 -0
- package/dist/exporter.d.ts +57 -0
- package/dist/exporter.js +305 -0
- package/dist/external-refs.d.ts +40 -0
- package/dist/external-refs.js +437 -0
- package/dist/index.js +61 -1
- package/dist/interrupt.d.ts +27 -0
- package/dist/interrupt.js +135 -0
- package/dist/recovery-doc.js +18 -5
- package/dist/restore-engine.d.ts +20 -6
- package/dist/restore-engine.js +130 -14
- package/dist/restore.d.ts +7 -0
- package/dist/restore.js +221 -16
- package/dist/snapshots.d.ts +5 -0
- package/dist/snapshots.js +16 -8
- package/package.json +1 -1
- package/plugin/skills/supabase-backup/assets/scheduled-backup.yml +1 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface QualifiedName {
|
|
2
|
+
schema: string;
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function unquoteIdent(ident: string): string;
|
|
6
|
+
export declare function formatQualified(name: QualifiedName): string;
|
|
7
|
+
/** `pg_restore -f - -L <只含 SCHEMA 条目的清单>` 的输出 → 按出现顺序的 schema 名。 */
|
|
8
|
+
export declare function parseCreateSchemas(sql: string): string[];
|
|
9
|
+
export interface ForeignKeyDef {
|
|
10
|
+
constraint: string;
|
|
11
|
+
table: QualifiedName;
|
|
12
|
+
columns: string[];
|
|
13
|
+
references: QualifiedName;
|
|
14
|
+
referencedColumns: string[];
|
|
15
|
+
/**
|
|
16
|
+
* ADD CONSTRAINT 时会不会校验已有的行。NOT VALID(源库就没校验过)和 PG18 的 NOT ENFORCED
|
|
17
|
+
* 都不校验,恢复不会因为缺行而失败,所以不用查。
|
|
18
|
+
*/
|
|
19
|
+
validatesRows: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* pg_dump 的外键语句(dumpConstraint):
|
|
23
|
+
* ALTER [FOREIGN ]TABLE [ONLY ]<表>\n ADD CONSTRAINT <名> FOREIGN KEY (<列>) REFERENCES <表>(<列>)<选项>;
|
|
24
|
+
* 分区父表上的外键没有 ONLY。认不出的形态(如 PG18 的 PERIOD 时态外键)不返回,
|
|
25
|
+
* 调用方拿条目数对账,差多少就如实报多少条没检查。
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseForeignKeys(sql: string): ForeignKeyDef[];
|
|
28
|
+
/** pg_dump 的挂分区语句(dumpTableAttach):ALTER TABLE ONLY <父> ATTACH PARTITION <子> <边界>; */
|
|
29
|
+
export declare function parsePartitionAttachments(sql: string): Array<{
|
|
30
|
+
parent: QualifiedName;
|
|
31
|
+
child: QualifiedName;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* pg_dump 的建表语句(dumpTableSchema)里每列的类型,按表(schema\0name)→ 列 → 类型。
|
|
35
|
+
* CREATE [UNLOGGED ]TABLE <表> (\n <列> <类型>[ COLLATE …][ DEFAULT …][ NOT NULL],\n CONSTRAINT …\n)…;
|
|
36
|
+
* 类型是 format_type 的输出,本身不含 COLLATE/DEFAULT/NOT NULL/GENERATED 这些词,读到它们为止。
|
|
37
|
+
* 带引号的列名可以跨行,所以按位置往下读,不按行切。
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseTableColumns(sql: string): Map<string, Map<string, string>>;
|
|
40
|
+
/**
|
|
41
|
+
* 数据段的 COPY 头(dumpTableData_copy):"COPY <表> <列清单> FROM stdin;"。没有列的表列清单是空串,
|
|
42
|
+
* 所以那一行中间是两个空格,返回空列表。
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseCopyHeader(line: string): {
|
|
45
|
+
table: QualifiedName;
|
|
46
|
+
columns: string[];
|
|
47
|
+
} | null;
|
|
48
|
+
/**
|
|
49
|
+
* COPY 文本格式的一个字段 → 值(\N = NULL)。转义规则照 PostgreSQL 文档 "COPY / Text Format":
|
|
50
|
+
* \b \f \n \r \t \v \\,\ 加 1–3 位八进制,\x 加 1–2 位十六进制,其余 \c 就是 c 本身。
|
|
51
|
+
*/
|
|
52
|
+
export declare function decodeCopyField(field: string): string | null;
|
package/dist/dump-sql.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 读 pg_restore 吐出来的 SQL(纯函数,可单测)。只认 pg_dump 自己的输出格式:
|
|
2
|
+
// 名字一律由 fmtId / quote_identifier 生成——[a-z_][a-z0-9_]* 原样,其余加双引号、内嵌引号翻倍,
|
|
3
|
+
// dump 头部把 search_path 设成空串,所以引用的表总是 schema.name 全限定。
|
|
4
|
+
// 不是通用 SQL 解析器,也不需要是:认不出的语句由调用方如实报"没法检查",不猜。
|
|
5
|
+
const QUOTED_IDENT = String.raw `"(?:[^"]|"")+"`;
|
|
6
|
+
const IDENT = String.raw `(?:${QUOTED_IDENT}|[a-z_][a-z0-9_]*)`;
|
|
7
|
+
const QUALIFIED = String.raw `(${IDENT})\.(${IDENT})`;
|
|
8
|
+
const COLUMNS = String.raw `(${IDENT}(?:\s*,\s*${IDENT})*)`;
|
|
9
|
+
export function unquoteIdent(ident) {
|
|
10
|
+
return ident.startsWith('"') ? ident.slice(1, -1).replace(/""/g, '"') : ident;
|
|
11
|
+
}
|
|
12
|
+
function splitColumns(list) {
|
|
13
|
+
return [...list.matchAll(new RegExp(IDENT, "g"))].map((match) => unquoteIdent(match[0]));
|
|
14
|
+
}
|
|
15
|
+
export function formatQualified(name) {
|
|
16
|
+
const show = (part) => (/^[a-z_][a-z0-9_]*$/.test(part) ? part : `"${part.replace(/"/g, '""')}"`);
|
|
17
|
+
return `${show(name.schema)}.${show(name.name)}`;
|
|
18
|
+
}
|
|
19
|
+
/** `pg_restore -f - -L <只含 SCHEMA 条目的清单>` 的输出 → 按出现顺序的 schema 名。 */
|
|
20
|
+
export function parseCreateSchemas(sql) {
|
|
21
|
+
return [...sql.matchAll(new RegExp(String.raw `^CREATE SCHEMA (${IDENT});$`, "gm"))].map((match) => unquoteIdent(match[1]));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* pg_dump 的外键语句(dumpConstraint):
|
|
25
|
+
* ALTER [FOREIGN ]TABLE [ONLY ]<表>\n ADD CONSTRAINT <名> FOREIGN KEY (<列>) REFERENCES <表>(<列>)<选项>;
|
|
26
|
+
* 分区父表上的外键没有 ONLY。认不出的形态(如 PG18 的 PERIOD 时态外键)不返回,
|
|
27
|
+
* 调用方拿条目数对账,差多少就如实报多少条没检查。
|
|
28
|
+
*/
|
|
29
|
+
export function parseForeignKeys(sql) {
|
|
30
|
+
const pattern = new RegExp(String.raw `^ALTER (?:FOREIGN )?TABLE (?:ONLY )?${QUALIFIED}\n {4}ADD CONSTRAINT (${IDENT}) ` +
|
|
31
|
+
String.raw `FOREIGN KEY \(${COLUMNS}\) REFERENCES ${QUALIFIED}\s*\(${COLUMNS}\)(.*);$`, "gm");
|
|
32
|
+
return [...sql.matchAll(pattern)].map((match) => {
|
|
33
|
+
// 选项里可能有带引号的列名(ON DELETE SET NULL ("x")),先去掉再找关键字
|
|
34
|
+
const options = match[8].replace(new RegExp(QUOTED_IDENT, "g"), "");
|
|
35
|
+
return {
|
|
36
|
+
constraint: unquoteIdent(match[3]),
|
|
37
|
+
table: { schema: unquoteIdent(match[1]), name: unquoteIdent(match[2]) },
|
|
38
|
+
columns: splitColumns(match[4]),
|
|
39
|
+
references: { schema: unquoteIdent(match[5]), name: unquoteIdent(match[6]) },
|
|
40
|
+
referencedColumns: splitColumns(match[7]),
|
|
41
|
+
validatesRows: !/\bNOT (?:VALID|ENFORCED)\b/.test(options),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** pg_dump 的挂分区语句(dumpTableAttach):ALTER TABLE ONLY <父> ATTACH PARTITION <子> <边界>; */
|
|
46
|
+
export function parsePartitionAttachments(sql) {
|
|
47
|
+
const pattern = new RegExp(String.raw `^ALTER TABLE ONLY ${QUALIFIED} ATTACH PARTITION ${QUALIFIED} `, "gm");
|
|
48
|
+
return [...sql.matchAll(pattern)].map((match) => ({
|
|
49
|
+
parent: { schema: unquoteIdent(match[1]), name: unquoteIdent(match[2]) },
|
|
50
|
+
child: { schema: unquoteIdent(match[3]), name: unquoteIdent(match[4]) },
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* pg_dump 的建表语句(dumpTableSchema)里每列的类型,按表(schema\0name)→ 列 → 类型。
|
|
55
|
+
* CREATE [UNLOGGED ]TABLE <表> (\n <列> <类型>[ COLLATE …][ DEFAULT …][ NOT NULL],\n CONSTRAINT …\n)…;
|
|
56
|
+
* 类型是 format_type 的输出,本身不含 COLLATE/DEFAULT/NOT NULL/GENERATED 这些词,读到它们为止。
|
|
57
|
+
* 带引号的列名可以跨行,所以按位置往下读,不按行切。
|
|
58
|
+
*/
|
|
59
|
+
export function parseTableColumns(sql) {
|
|
60
|
+
const tables = new Map();
|
|
61
|
+
const header = new RegExp(String.raw `^CREATE (?:UNLOGGED )?TABLE ${QUALIFIED} \(\n`, "gm");
|
|
62
|
+
for (const match of sql.matchAll(header)) {
|
|
63
|
+
const columns = new Map();
|
|
64
|
+
tables.set(`${unquoteIdent(match[1])}\u0000${unquoteIdent(match[2])}`, columns);
|
|
65
|
+
let at = match.index + match[0].length;
|
|
66
|
+
const column = new RegExp(String.raw ` {4}(${IDENT}) (.+?)(?= COLLATE | DEFAULT | NOT NULL| GENERATED |,?\n)`, "y");
|
|
67
|
+
while (at < sql.length && !sql.startsWith(")", at)) {
|
|
68
|
+
if (sql.startsWith(" CONSTRAINT ", at)) {
|
|
69
|
+
at = sql.indexOf("\n", at) + 1 || sql.length;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
column.lastIndex = at;
|
|
73
|
+
const found = column.exec(sql);
|
|
74
|
+
if (!found)
|
|
75
|
+
break;
|
|
76
|
+
columns.set(unquoteIdent(found[1]), found[2]);
|
|
77
|
+
at = sql.indexOf("\n", column.lastIndex) + 1 || sql.length;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return tables;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 数据段的 COPY 头(dumpTableData_copy):"COPY <表> <列清单> FROM stdin;"。没有列的表列清单是空串,
|
|
84
|
+
* 所以那一行中间是两个空格,返回空列表。
|
|
85
|
+
*/
|
|
86
|
+
export function parseCopyHeader(line) {
|
|
87
|
+
const match = new RegExp(String.raw `^COPY ${QUALIFIED} (?:\(${COLUMNS}\))? FROM stdin;$`).exec(line);
|
|
88
|
+
if (!match)
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
table: { schema: unquoteIdent(match[1]), name: unquoteIdent(match[2]) },
|
|
92
|
+
columns: match[3] ? splitColumns(match[3]) : [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* COPY 文本格式的一个字段 → 值(\N = NULL)。转义规则照 PostgreSQL 文档 "COPY / Text Format":
|
|
97
|
+
* \b \f \n \r \t \v \\,\ 加 1–3 位八进制,\x 加 1–2 位十六进制,其余 \c 就是 c 本身。
|
|
98
|
+
*/
|
|
99
|
+
export function decodeCopyField(field) {
|
|
100
|
+
if (field === "\\N")
|
|
101
|
+
return null;
|
|
102
|
+
if (!field.includes("\\"))
|
|
103
|
+
return field;
|
|
104
|
+
return field.replace(/\\(?:([0-7]{1,3})|x([0-9a-fA-F]{1,2})|(.))/g, (_, octal, hex, char) => {
|
|
105
|
+
if (octal)
|
|
106
|
+
return String.fromCharCode(parseInt(octal, 8));
|
|
107
|
+
if (hex)
|
|
108
|
+
return String.fromCharCode(parseInt(hex, 16));
|
|
109
|
+
const named = { b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
110
|
+
return named[char] ?? char;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -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>;
|
package/dist/exporter.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type pg from "pg";
|
|
2
|
+
import { type ForeignKeyDef } from "./dump-sql.js";
|
|
3
|
+
export interface CheckedForeignKey {
|
|
4
|
+
fk: ForeignKeyDef;
|
|
5
|
+
/** 引用列全非空的行数(MATCH SIMPLE:有一列为空就不校验) */
|
|
6
|
+
rows: number;
|
|
7
|
+
/** 其中在目标库找不到被引用行的 */
|
|
8
|
+
missingRows: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ExternalReferenceCheck {
|
|
11
|
+
checked: CheckedForeignKey[];
|
|
12
|
+
/** 恢复一定会失败的原因,一条一句 */
|
|
13
|
+
problems: string[];
|
|
14
|
+
/** 没能检查的,一条一句。恢复在一个事务里跑,这些真出问题也会整体回滚,所以只提示不拦 */
|
|
15
|
+
unchecked: string[];
|
|
16
|
+
/** 有外键要等扩展装上之后才查得了(见 pendingExtensions) */
|
|
17
|
+
waitingForExtensions: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 读 dump,找出指向 dumpedSchemas 之外的外键,逐条拿目标库对行。只读:目标库上只跑 select。
|
|
21
|
+
* pg_restore 起不来、归档读不了时抛错,由调用方当成 blocker。单条外键查不成(目标端报错、
|
|
22
|
+
* 数据块读不了)只影响它自己,记进 unchecked,不拖累别的外键的结论。
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkExternalReferences(opts: {
|
|
25
|
+
dumpPath: string;
|
|
26
|
+
dumpedSchemas: string[];
|
|
27
|
+
targetUrl: string;
|
|
28
|
+
/**
|
|
29
|
+
* manifest 里记着、目标上还没装的扩展。有它们在,被引用的表不在、引用方列的类型不在,都可能是
|
|
30
|
+
* 扩展装上之后才有的(扩展可以往别的 schema 里建表,只看它登记的 schema 不够,交叉审查复现),
|
|
31
|
+
* 这时报"装完扩展再查",不当成 blocker;正式恢复装完扩展会再查一遍。
|
|
32
|
+
*/
|
|
33
|
+
pendingExtensions?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* 调用方开着的事务(正式恢复里"先装扩展再复查"的那个):在它里面查,看得到还没提交的扩展。
|
|
36
|
+
* 每条查询包在一个保存点里、查完就回滚到保存点,检查本身留不下任何东西。
|
|
37
|
+
*/
|
|
38
|
+
inTransaction?: pg.Client;
|
|
39
|
+
}): Promise<ExternalReferenceCheck>;
|
|
40
|
+
export declare function describe(fk: ForeignKeyDef): string;
|