feat(ops): add integrity-checked backups and isolated restore drills
This commit is contained in:
1 parent
7867bf6b72
commit
46f7ad6571
10 files changed
+1309
-1
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { safeDirectory } from "./core.mjs";
|
||||
import { createBackup, drillBackup, validateDatabase } from "./database.mjs";
|
||||
|
||||
export async function loadConfig(file) {
|
||||
if (!path.isAbsolute(file)) throw Error("Use an absolute configuration path");
|
||||
await safeDirectory(path.dirname(file));
|
||||
const stat = await lstat(file);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.nlink !== 1 ||
|
||||
(process.platform !== "win32" && stat.mode & 0o077)
|
||||
)
|
||||
throw Error("Configuration must be a private regular file");
|
||||
const config = JSON.parse(await readFile(file, "utf8"));
|
||||
if (
|
||||
Object.keys(config).some((key) => !["database", "roots"].includes(key)) ||
|
||||
!config.roots
|
||||
)
|
||||
throw Error("Invalid backup configuration");
|
||||
validateDatabase(config.database);
|
||||
for (const root of Object.values(config.roots)) {
|
||||
if (typeof root !== "string" || !path.isAbsolute(root))
|
||||
throw Error("Use absolute source paths");
|
||||
const relative = path.relative(path.resolve(root), file);
|
||||
if (
|
||||
!relative ||
|
||||
(!relative.startsWith(`..${path.sep}`) &&
|
||||
relative !== ".." &&
|
||||
!path.isAbsolute(relative))
|
||||
)
|
||||
throw Error("Keep the configuration outside every source root");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function main(args = process.argv.slice(2)) {
|
||||
const [command, ...options] = args;
|
||||
if (!["create", "drill"].includes(command))
|
||||
throw Error("Choose create or drill");
|
||||
const schema =
|
||||
command === "create"
|
||||
? {
|
||||
config: { type: "string" },
|
||||
output: { type: "string" },
|
||||
"writers-quiesced": { type: "boolean" },
|
||||
}
|
||||
: { artifact: { type: "string" } };
|
||||
const { values } = parseArgs({
|
||||
args: options,
|
||||
options: schema,
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
if (command === "create") {
|
||||
if (
|
||||
!values.config ||
|
||||
!values.output ||
|
||||
values["writers-quiesced"] !== true ||
|
||||
process.platform !== "linux"
|
||||
)
|
||||
throw Error("Create requires Linux, explicit paths and paused writers");
|
||||
const config = await loadConfig(values.config);
|
||||
await createBackup({
|
||||
...config,
|
||||
output: values.output,
|
||||
writersQuiesced: true,
|
||||
});
|
||||
process.stdout.write(
|
||||
"Backup created and checksums verified. Run the isolated drill before relying on it.\n",
|
||||
);
|
||||
} else {
|
||||
if (!values.artifact) throw Error("Provide an artifact");
|
||||
await drillBackup({ artifact: values.artifact });
|
||||
process.stdout.write(
|
||||
"Isolated restore verified; disposable database and files removed.\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
main().catch(() => {
|
||||
process.stderr.write(
|
||||
"Backup command failed. Check private configuration, prerequisites, paused writers and artifact integrity. No server output was logged.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { loadConfig } from "./cli.mjs";
|
||||
|
||||
it("does not provide a production restore command or target option", () => {
|
||||
for (const args of [
|
||||
["restore", "--target", "production"],
|
||||
["drill", "--artifact", "/missing", "--target", "production"],
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["scripts/backup/cli.mjs", ...args],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).not.toContain("production");
|
||||
}
|
||||
});
|
||||
|
||||
it("loads only explicit configuration and refuses to include that configuration in file roots", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "cms-backup-cli-"));
|
||||
try {
|
||||
const root = path.join(directory, "storage");
|
||||
await mkdir(root);
|
||||
const config = {
|
||||
database: {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "fixture",
|
||||
password: "secret-value",
|
||||
database: "fixture",
|
||||
},
|
||||
roots: {
|
||||
storage: root,
|
||||
nitro: path.join(directory, "nitro"),
|
||||
swf: path.join(directory, "swf"),
|
||||
},
|
||||
};
|
||||
const file = path.join(directory, "private.json");
|
||||
await writeFile(file, JSON.stringify(config), { mode: 0o600 });
|
||||
expect(await loadConfig(file)).toEqual(config);
|
||||
const unsafe = path.join(root, "custom-settings.json");
|
||||
await writeFile(unsafe, JSON.stringify(config), { mode: 0o600 });
|
||||
await expect(loadConfig(unsafe)).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
credentialOptions,
|
||||
dockerEnvironment,
|
||||
validateDatabase,
|
||||
} from "./database.mjs";
|
||||
|
||||
it("quotes credentials without permitting option-file injection", () => {
|
||||
const config = {
|
||||
host: "127.0.0.1",
|
||||
port: 3306,
|
||||
user: "backup",
|
||||
password: 'quote"slash\\secret',
|
||||
database: "cms",
|
||||
};
|
||||
expect(credentialOptions(config)).toContain(
|
||||
'password="quote\\"slash\\\\secret"',
|
||||
);
|
||||
expect(() =>
|
||||
credentialOptions({ ...config, password: "secret\n[client]" }),
|
||||
).toThrow();
|
||||
expect(() => validateDatabase({ ...config, database: "mysql" })).toThrow();
|
||||
expect(() =>
|
||||
validateDatabase({ ...config, database: "cms; DROP DATABASE other" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("passes only explicit Docker runtime variables to subprocesses", () => {
|
||||
expect(
|
||||
dockerEnvironment({
|
||||
PATH: "/bin",
|
||||
DOCKER_HOST: "unix:///run/docker.sock",
|
||||
DATABASE_URL: "private",
|
||||
MARIADB_PWD: "secret",
|
||||
NODE_OPTIONS: "--inspect",
|
||||
HOME: "/operator",
|
||||
}),
|
||||
).toEqual({
|
||||
PATH: "/bin",
|
||||
DOCKER_HOST: "unix:///run/docker.sock",
|
||||
HOME: "/operator",
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user