363 lines
9.6 KiB
JavaScript
363 lines
9.6 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { createReadStream } from "node:fs";
|
|
import { chmod, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { createArtifact, restoreFiles, verifyArtifact } from "./core.mjs";
|
|
|
|
export const IMAGE = "mariadb:11.4.5";
|
|
const failure = () =>
|
|
Error(
|
|
"MariaDB backup operation failed; no credentials or server output were logged",
|
|
);
|
|
const quote = (value) =>
|
|
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
const literal = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
const identifier = (value) => {
|
|
if (
|
|
typeof value !== "string" ||
|
|
[...value].some((character) => character.charCodeAt(0) < 32)
|
|
)
|
|
throw failure();
|
|
return `\`${value.replaceAll("`", "``")}\``;
|
|
};
|
|
|
|
export function validateDatabase(config) {
|
|
if (
|
|
!config ||
|
|
Object.keys(config).some(
|
|
(key) => !["host", "port", "user", "password", "database"].includes(key),
|
|
) ||
|
|
!Number.isInteger(config.port) ||
|
|
config.port < 1 ||
|
|
config.port > 65535
|
|
)
|
|
throw failure();
|
|
for (const key of ["host", "user", "password", "database"])
|
|
if (
|
|
typeof config[key] !== "string" ||
|
|
!config[key] ||
|
|
[...config[key]].some((character) => character.charCodeAt(0) < 32)
|
|
)
|
|
throw failure();
|
|
if (
|
|
!/^[a-zA-Z0-9_]+$/.test(config.database) ||
|
|
["mysql", "sys", "information_schema", "performance_schema"].includes(
|
|
config.database.toLowerCase(),
|
|
)
|
|
)
|
|
throw failure();
|
|
return config;
|
|
}
|
|
|
|
export function credentialOptions(config) {
|
|
validateDatabase(config);
|
|
return `[client]\nhost=${quote(config.host)}\nport=${config.port}\nuser=${quote(config.user)}\npassword=${quote(config.password)}\nprotocol=tcp\ndefault-character-set=utf8mb4\n`;
|
|
}
|
|
|
|
export function dockerEnvironment(source = process.env) {
|
|
const keys = [
|
|
"PATH",
|
|
"Path",
|
|
"SystemRoot",
|
|
"SystemDrive",
|
|
"TEMP",
|
|
"TMP",
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"DOCKER_HOST",
|
|
"DOCKER_CONTEXT",
|
|
"DOCKER_CONFIG",
|
|
"DOCKER_TLS_VERIFY",
|
|
"DOCKER_CERT_PATH",
|
|
];
|
|
return Object.fromEntries(
|
|
keys
|
|
.filter((key) => source[key] !== undefined)
|
|
.map((key) => [key, source[key]]),
|
|
);
|
|
}
|
|
|
|
async function docker(args, { input, output, timeout = 1_800_000 } = {}) {
|
|
const file = output ? await open(output, "wx", 0o600) : undefined;
|
|
try {
|
|
const child = spawn("docker", args, {
|
|
env: dockerEnvironment(),
|
|
windowsHide: true,
|
|
stdio: [input ? "pipe" : "ignore", file ? file.fd : "pipe", "ignore"],
|
|
});
|
|
let stdout = "";
|
|
if (!file)
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString("utf8");
|
|
if (stdout.length > 16 * 1024 * 1024) child.kill();
|
|
});
|
|
const timer = setTimeout(() => child.kill(), timeout);
|
|
const completion = new Promise((resolve, reject) => {
|
|
child.once("error", () => reject(failure()));
|
|
child.once("close", (code) =>
|
|
code === 0 ? resolve(stdout) : reject(failure()),
|
|
);
|
|
});
|
|
try {
|
|
await Promise.all([
|
|
completion,
|
|
input
|
|
? pipeline(createReadStream(input), child.stdin)
|
|
: Promise.resolve(),
|
|
]);
|
|
return stdout;
|
|
} catch {
|
|
child.kill();
|
|
throw failure();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
} finally {
|
|
await file?.close();
|
|
}
|
|
}
|
|
|
|
async function credentials(config, use) {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "cms-backup-private-"));
|
|
try {
|
|
await chmod(directory, 0o700);
|
|
await writeFile(
|
|
path.join(directory, "client.cnf"),
|
|
credentialOptions(config),
|
|
{ flag: "wx", mode: 0o600 },
|
|
);
|
|
return await use(directory);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function sourceClient(directory, program, args, options) {
|
|
const name = `cms-backup-client-${randomUUID()}`;
|
|
try {
|
|
return await docker(
|
|
[
|
|
"run",
|
|
"--rm",
|
|
"--name",
|
|
name,
|
|
"--network",
|
|
"host",
|
|
"--mount",
|
|
`type=bind,src=${directory},dst=/run/backup,readonly`,
|
|
"--entrypoint",
|
|
program,
|
|
IMAGE,
|
|
"--defaults-file=/run/backup/client.cnf",
|
|
...args,
|
|
],
|
|
options,
|
|
);
|
|
} finally {
|
|
await docker(["rm", "-f", "-v", name], { timeout: 15_000 }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function inventory(query, database) {
|
|
const schema = literal(database);
|
|
const records = await query(
|
|
`SELECT JSON_OBJECT('kind', TABLE_TYPE, 'name', TABLE_NAME, 'engine', ENGINE) FROM information_schema.TABLES WHERE TABLE_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', ROUTINE_TYPE, 'name', ROUTINE_NAME, 'engine', NULL) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', 'TRIGGER', 'name', TRIGGER_NAME, 'engine', NULL) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA=${schema} UNION ALL SELECT JSON_OBJECT('kind', 'EVENT', 'name', EVENT_NAME, 'engine', NULL) FROM information_schema.EVENTS WHERE EVENT_SCHEMA=${schema}`,
|
|
);
|
|
const objects = records
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
if (
|
|
objects.some(
|
|
(object) => object.kind === "BASE TABLE" && object.engine !== "InnoDB",
|
|
)
|
|
)
|
|
throw Error("Only InnoDB tables are supported by this snapshot workflow");
|
|
objects.sort((a, b) =>
|
|
`${a.kind}:${a.name}`.localeCompare(`${b.kind}:${b.name}`, "en"),
|
|
);
|
|
const tables = [];
|
|
for (const table of objects.filter(
|
|
(object) => object.kind === "BASE TABLE",
|
|
)) {
|
|
const qualified = `${identifier(database)}.${identifier(table.name)}`;
|
|
const result = (
|
|
await query(
|
|
`SELECT CAST(COUNT(*) AS CHAR) FROM ${qualified}; CHECKSUM TABLE ${qualified} EXTENDED;`,
|
|
)
|
|
)
|
|
.trim()
|
|
.split("\n");
|
|
const checksum = result[1]?.split("\t").at(-1)?.trim();
|
|
if (!/^\d+$/.test(result[0]) || !/^\d+$/.test(checksum ?? ""))
|
|
throw failure();
|
|
tables.push({ name: table.name, rows: result[0], checksum });
|
|
}
|
|
if (!tables.length)
|
|
throw Error("The backup database must contain at least one InnoDB table");
|
|
return {
|
|
database,
|
|
objects: objects.map(({ kind, name }) => ({ kind, name })),
|
|
tables,
|
|
};
|
|
}
|
|
|
|
const queryArgs = (sql) => [
|
|
"--batch",
|
|
"--raw",
|
|
"--skip-column-names",
|
|
"--execute",
|
|
sql,
|
|
];
|
|
|
|
export async function createBackup({
|
|
database,
|
|
roots,
|
|
output,
|
|
writersQuiesced,
|
|
}) {
|
|
if (writersQuiesced !== true)
|
|
throw Error(
|
|
"Pause all database and file writers, then pass --writers-quiesced",
|
|
);
|
|
validateDatabase(database);
|
|
return credentials(database, (directory) =>
|
|
createArtifact({ roots, output }, async (target) => {
|
|
const query = (sql) => sourceClient(directory, "mariadb", queryArgs(sql));
|
|
const before = await inventory(query, database.database);
|
|
await sourceClient(
|
|
directory,
|
|
"mariadb-dump",
|
|
[
|
|
"--single-transaction",
|
|
"--quick",
|
|
"--skip-lock-tables",
|
|
"--routines",
|
|
"--events",
|
|
"--triggers",
|
|
"--hex-blob",
|
|
"--tz-utc",
|
|
"--skip-comments",
|
|
"--max-allowed-packet=512M",
|
|
"--databases",
|
|
database.database,
|
|
],
|
|
{ output: target },
|
|
);
|
|
const after = await inventory(query, database.database);
|
|
if (JSON.stringify(before) !== JSON.stringify(after))
|
|
throw Error(
|
|
"Database changed during backup; keep every writer paused and retry",
|
|
);
|
|
return before;
|
|
}),
|
|
);
|
|
}
|
|
|
|
export async function drillBackup({ artifact }) {
|
|
const expected = await verifyArtifact(artifact);
|
|
const database = expected.database?.database;
|
|
const password = randomUUID();
|
|
const config = validateDatabase({
|
|
host: "127.0.0.1",
|
|
port: 3306,
|
|
user: "root",
|
|
password,
|
|
database,
|
|
});
|
|
return credentials(config, async (directory) => {
|
|
const name = `cms-backup-drill-${randomUUID()}`;
|
|
const query = (sql) =>
|
|
docker([
|
|
"exec",
|
|
name,
|
|
"mariadb",
|
|
"--defaults-file=/run/backup/client.cnf",
|
|
...queryArgs(sql),
|
|
]);
|
|
let started = false;
|
|
try {
|
|
await restoreFiles(artifact, path.join(directory, "restored"));
|
|
await writeFile(path.join(directory, "password"), password, {
|
|
flag: "wx",
|
|
mode: 0o600,
|
|
});
|
|
await docker(
|
|
[
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
name,
|
|
"--label",
|
|
"cms.backup-drill=true",
|
|
"--network",
|
|
"none",
|
|
"--mount",
|
|
`type=bind,src=${directory},dst=/run/backup,readonly`,
|
|
"-e",
|
|
"MARIADB_ROOT_PASSWORD_FILE=/run/backup/password",
|
|
IMAGE,
|
|
"--character-set-server=utf8mb4",
|
|
"--collation-server=utf8mb4_unicode_ci",
|
|
"--event-scheduler=OFF",
|
|
"--max-allowed-packet=512M",
|
|
],
|
|
{ timeout: 120_000 },
|
|
);
|
|
started = true;
|
|
let ready = false;
|
|
for (let attempt = 0; attempt < 90; attempt++) {
|
|
try {
|
|
await docker(
|
|
[
|
|
"exec",
|
|
name,
|
|
"mariadb-admin",
|
|
"--defaults-file=/run/backup/client.cnf",
|
|
"ping",
|
|
"--silent",
|
|
],
|
|
{ timeout: 5_000 },
|
|
);
|
|
ready = true;
|
|
break;
|
|
} catch {
|
|
await delay(1000);
|
|
}
|
|
}
|
|
if (!ready) throw failure();
|
|
// Import over stdin; no network, published port or production filesystem mount.
|
|
await docker(
|
|
[
|
|
"exec",
|
|
"-i",
|
|
name,
|
|
"mariadb",
|
|
"--defaults-file=/run/backup/client.cnf",
|
|
"--binary-mode",
|
|
],
|
|
{ input: path.join(artifact, "database.sql") },
|
|
);
|
|
const actual = await inventory(query, database);
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected.database))
|
|
throw Error("Restored database inventory does not match the backup");
|
|
return {
|
|
verified: true,
|
|
database: actual,
|
|
files: expected.entries.filter(
|
|
(entry) => entry.type === "file" && entry.path.startsWith("files/"),
|
|
).length,
|
|
};
|
|
} finally {
|
|
const cleanup = docker(["rm", "-f", "-v", name], { timeout: 30_000 });
|
|
if (started) await cleanup;
|
|
else await cleanup.catch(() => {});
|
|
}
|
|
});
|
|
}
|