mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 00:01:36 +00:00
feat(backup): add verified SQLite snapshots (#105718)
* feat(backup): expose verified SQLite snapshots Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * feat(sqlite): add verified snapshot repository (#105525) Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * test(sqlite): use canonical snapshot schemas * fix(backup): restrict SQLite snapshot roles * fix(sqlite): harden snapshot staging paths Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): pin snapshot repository paths Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): secure Windows snapshot staging Co-authored-by: Gio Della-Libera <giodl73@gmail.com> * fix(sqlite): validate macOS ACL captures * test(sqlite): preserve stat uid type * style(sqlite): satisfy snapshot lint --------- Co-authored-by: Gio Della-Libera <giodl73@gmail.com>
This commit is contained in:
@@ -34,7 +34,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
},
|
||||
{
|
||||
name: "backup",
|
||||
description: "Create and verify local backup archives for OpenClaw state",
|
||||
description: "Create and verify backup archives and SQLite snapshots",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -719,6 +719,25 @@ describe("registerPreActionHooks", () => {
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses config guard for SQLite snapshot recovery commands", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["backup", "sqlite", "restore"],
|
||||
processArgv: [
|
||||
"node",
|
||||
"openclaw",
|
||||
"backup",
|
||||
"sqlite",
|
||||
"restore",
|
||||
"/tmp/snapshot",
|
||||
"--target",
|
||||
"/tmp/restore.sqlite",
|
||||
],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
|
||||
let stderrDuringPluginLoad = false;
|
||||
ensurePluginRegistryLoadedMock.mockImplementation(() => {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { registerBackupCommand } from "./register.backup.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
backupCreateCommand: vi.fn(),
|
||||
backupSqliteCreateCommand: vi.fn(),
|
||||
backupSqliteListCommand: vi.fn(),
|
||||
backupSqliteRestoreCommand: vi.fn(),
|
||||
backupSqliteVerifyCommand: vi.fn(),
|
||||
backupVerifyCommand: vi.fn(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
@@ -14,6 +18,10 @@ const mocks = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
const backupCreateCommand = mocks.backupCreateCommand;
|
||||
const backupSqliteCreateCommand = mocks.backupSqliteCreateCommand;
|
||||
const backupSqliteListCommand = mocks.backupSqliteListCommand;
|
||||
const backupSqliteRestoreCommand = mocks.backupSqliteRestoreCommand;
|
||||
const backupSqliteVerifyCommand = mocks.backupSqliteVerifyCommand;
|
||||
const backupVerifyCommand = mocks.backupVerifyCommand;
|
||||
const runtime = mocks.runtime;
|
||||
|
||||
@@ -25,6 +33,13 @@ vi.mock("../../commands/backup-verify.js", () => ({
|
||||
backupVerifyCommand: mocks.backupVerifyCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/backup-sqlite.js", () => ({
|
||||
backupSqliteCreateCommand: mocks.backupSqliteCreateCommand,
|
||||
backupSqliteListCommand: mocks.backupSqliteListCommand,
|
||||
backupSqliteRestoreCommand: mocks.backupSqliteRestoreCommand,
|
||||
backupSqliteVerifyCommand: mocks.backupSqliteVerifyCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
@@ -39,6 +54,10 @@ describe("registerBackupCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backupCreateCommand.mockResolvedValue(undefined);
|
||||
backupSqliteCreateCommand.mockResolvedValue(undefined);
|
||||
backupSqliteListCommand.mockResolvedValue(undefined);
|
||||
backupSqliteRestoreCommand.mockResolvedValue(undefined);
|
||||
backupSqliteVerifyCommand.mockResolvedValue(undefined);
|
||||
backupVerifyCommand.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
@@ -93,4 +112,91 @@ describe("registerBackupCommand", () => {
|
||||
expect(options.archive).toBe("/tmp/openclaw-backup.tar.gz");
|
||||
expect(options.json).toBe(true);
|
||||
});
|
||||
|
||||
it("registers the SQLite snapshot command group", () => {
|
||||
const program = new Command();
|
||||
|
||||
registerBackupCommand(program);
|
||||
|
||||
const backup = program.commands.find((command) => command.name() === "backup");
|
||||
const sqlite = backup?.commands.find((command) => command.name() === "sqlite");
|
||||
expect(sqlite?.commands.map((command) => command.name()).toSorted()).toEqual([
|
||||
"create",
|
||||
"list",
|
||||
"restore",
|
||||
"verify",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs SQLite snapshot create for named OpenClaw databases", async () => {
|
||||
await runCli([
|
||||
"backup",
|
||||
"sqlite",
|
||||
"create",
|
||||
"--global",
|
||||
"--repository",
|
||||
"/tmp/snapshots",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(backupSqliteCreateCommand).toHaveBeenCalledWith(runtime, {
|
||||
global: true,
|
||||
agent: undefined,
|
||||
repository: "/tmp/snapshots",
|
||||
json: true,
|
||||
});
|
||||
|
||||
await runCli([
|
||||
"backup",
|
||||
"sqlite",
|
||||
"create",
|
||||
"--agent",
|
||||
"main",
|
||||
"--repository",
|
||||
"/tmp/snapshots",
|
||||
]);
|
||||
|
||||
expect(backupSqliteCreateCommand).toHaveBeenLastCalledWith(runtime, {
|
||||
global: false,
|
||||
agent: "main",
|
||||
repository: "/tmp/snapshots",
|
||||
json: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs SQLite snapshot list, verify, and restore", async () => {
|
||||
await runCli(["backup", "sqlite", "list", "--repository", "/tmp/snapshots", "--json"]);
|
||||
expect(backupSqliteListCommand).toHaveBeenCalledWith(runtime, {
|
||||
repository: "/tmp/snapshots",
|
||||
json: true,
|
||||
});
|
||||
|
||||
await runCli([
|
||||
"backup",
|
||||
"sqlite",
|
||||
"verify",
|
||||
"/tmp/snapshots/one",
|
||||
"--scratch",
|
||||
"/tmp/private-scratch",
|
||||
"--json",
|
||||
]);
|
||||
expect(backupSqliteVerifyCommand).toHaveBeenCalledWith(runtime, "/tmp/snapshots/one", {
|
||||
scratch: "/tmp/private-scratch",
|
||||
json: true,
|
||||
});
|
||||
|
||||
await runCli([
|
||||
"backup",
|
||||
"sqlite",
|
||||
"restore",
|
||||
"/tmp/snapshots/one",
|
||||
"--target",
|
||||
"/tmp/restored.sqlite",
|
||||
"--json",
|
||||
]);
|
||||
expect(backupSqliteRestoreCommand).toHaveBeenCalledWith(runtime, "/tmp/snapshots/one", {
|
||||
target: "/tmp/restored.sqlite",
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
|
||||
import { theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import {
|
||||
backupSqliteCreateCommand,
|
||||
backupSqliteListCommand,
|
||||
backupSqliteRestoreCommand,
|
||||
backupSqliteVerifyCommand,
|
||||
} from "../../commands/backup-sqlite.js";
|
||||
import { backupVerifyCommand } from "../../commands/backup-verify.js";
|
||||
import { backupCreateCommand } from "../../commands/backup.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
@@ -12,7 +18,7 @@ import { formatHelpExamples } from "../help-format.js";
|
||||
export function registerBackupCommand(program: Command) {
|
||||
const backup = program
|
||||
.command("backup")
|
||||
.description("Create and verify local backup archives for OpenClaw state")
|
||||
.description("Create and verify backup archives and SQLite snapshots")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
@@ -91,4 +97,90 @@ export function registerBackupCommand(program: Command) {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
registerBackupSqliteCommands(backup);
|
||||
}
|
||||
|
||||
function registerBackupSqliteCommands(backup: Command): void {
|
||||
const sqlite = backup
|
||||
.command("sqlite")
|
||||
.description("Create, list, verify, and restore SQLite snapshots")
|
||||
.action(() => {
|
||||
sqlite.outputHelp();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
sqlite
|
||||
.command("create")
|
||||
.description("Create a compact, verified snapshot of an OpenClaw SQLite database")
|
||||
.option("--global", "Snapshot the shared OpenClaw state database", false)
|
||||
.option("--agent <id>", "Snapshot one per-agent OpenClaw database")
|
||||
.requiredOption("--repository <path>", "Snapshot repository directory")
|
||||
.option("--json", "Output JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
[
|
||||
"openclaw backup sqlite create --global --repository ~/Backups/openclaw-sqlite",
|
||||
"Snapshot the shared state database.",
|
||||
],
|
||||
[
|
||||
"openclaw backup sqlite create --agent main --repository ~/Backups/openclaw-sqlite",
|
||||
"Snapshot the main agent database.",
|
||||
],
|
||||
])}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await backupSqliteCreateCommand(defaultRuntime, {
|
||||
global: Boolean(opts.global),
|
||||
agent: opts.agent as string | undefined,
|
||||
repository: opts.repository as string,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sqlite
|
||||
.command("list")
|
||||
.description("List committed snapshots in a repository")
|
||||
.requiredOption("--repository <path>", "Snapshot repository directory")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await backupSqliteListCommand(defaultRuntime, {
|
||||
repository: opts.repository as string,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sqlite
|
||||
.command("verify <snapshot>")
|
||||
.description("Verify a snapshot manifest, artifact hash, SQLite integrity, and database owner")
|
||||
.option("--scratch <path>", "Existing private directory for verification copies")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (snapshot, opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await backupSqliteVerifyCommand(defaultRuntime, snapshot as string, {
|
||||
scratch: opts.scratch as string | undefined,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sqlite
|
||||
.command("restore <snapshot>")
|
||||
.description("Restore a verified snapshot to a new SQLite database path")
|
||||
.requiredOption("--target <path>", "Fresh target path; existing files and sidecars are refused")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (snapshot, opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await backupSqliteRestoreCommand(defaultRuntime, snapshot as string, {
|
||||
target: opts.target as string,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user