UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

253 lines (252 loc) • 10.7 kB
import { a as writeRuntimeJson } from "./runtime-DlO3PTAd.mjs"; import { Fn as object, Jn as string, Pn as number, xn as literal } from "./schemas-BOYIvvln.mjs"; import { r as getRuntimeConfig } from "./io.runtime-BsUXSsD4.mjs"; import "./config-DLycWFvB.mjs"; import { r as loadOrCreateProcessDeviceIdentityAsync } from "./device-identity-async-CWlunAqt.mjs"; import { n as finishBackupScratch, t as createBackupScratchDirectory } from "./backup-scratch-CTd0Pvbc.mjs"; import { a as selectBackupRetention, i as resolveBackupNamespace, n as normalizeBackupRetention, r as parseRemoteBackupTimestamp, t as createRemoteBackupKey } from "./backup-retention-D0JLSuDB.mjs"; import { r as openStorageLocation } from "./locations-CbEWbeW_.mjs"; import { createReadStream, createWriteStream } from "node:fs"; import path from "node:path"; import fs$1 from "node:fs/promises"; import os from "node:os"; import { pipeline } from "node:stream/promises"; //#region src/commands/backup-namespace.ts const OWNER_KEY = "owner.json"; const ownerSchema = object({ version: literal(1), deviceId: string().min(1), hostname: string().min(1), claimedAt: number().int().nonnegative() }); async function readNamespaceOwner(location) { const body = await location.getObject(OWNER_KEY); if (!body) return; const chunks = []; let size = 0; for await (const chunk of body) { size += chunk.byteLength; if (size > 4096) throw new Error("Backup namespace owner exceeds 4096 bytes."); chunks.push(Buffer.from(chunk)); } try { return ownerSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { throw new Error("Invalid backup namespace owner.json."); } } function assertOwner(location, namespace, deviceId, owner) { const name = location.describe().name; if (!owner) throw new Error(`Backup namespace "${namespace}" in ${name} lost its ownership claim. Retry the backup.`); if (owner.deviceId !== deviceId) throw new Error(`Backup namespace "${namespace}" in ${name} belongs to another OpenClaw installation (${owner.hostname}, device ${owner.deviceId.slice(0, 12)}). Pass --namespace <name> to use a separate namespace, or --claim-namespace to take it over deliberately (for example after moving to new hardware).`); } async function claimBackupNamespace(location, namespace, deviceId, takeover) { if (takeover) await location.delete(OWNER_KEY); let owner = await readNamespaceOwner(location); if (!owner) { const bytes = Buffer.from(JSON.stringify({ version: 1, deviceId, hostname: os.hostname(), claimedAt: Date.now() })); try { await location.putObject(OWNER_KEY, (async function* () { yield bytes; })(), { sizeBytes: bytes.length }); } catch (error) { owner = await readNamespaceOwner(location); if (!owner) throw error; } owner ??= await readNamespaceOwner(location); } assertOwner(location, namespace, deviceId, owner); } async function assertBackupNamespaceOwner(location, namespace, deviceId) { assertOwner(location, namespace, deviceId, await readNamespaceOwner(location)); } /** Discovery is read-only and incomplete claim metadata must never prevent recovery. */ async function listBackupNamespaces(backups) { const namespaces = /* @__PURE__ */ new Set(); for await (const object of backups.list(void 0, { acceptKey: (key) => { const [namespace, file, extra] = key.split("/"); if (!namespace || !file || extra !== void 0 || file !== OWNER_KEY && parseRemoteBackupTimestamp(file) === void 0) return false; try { resolveBackupNamespace(namespace); } catch { return false; } namespaces.add(namespace); return file !== OWNER_KEY; } })) namespaces.add(object.key.slice(0, object.key.indexOf("/"))); const result = []; for (const namespace of [...namespaces].toSorted()) { const owner = await readNamespaceOwner(backups.scope(namespace)).catch(() => void 0); result.push({ namespace, ...owner ? { hostname: owner.hostname } : {} }); } return result; } //#endregion //#region src/commands/backup-remote.ts async function openBackupLocation(name, namespace) { const location = await openStorageLocation({ name, config: getRuntimeConfig() }); try { const probe = await location.probe(); if (probe.state !== "ok") throw new Error(probe.message ?? `Storage location ${name}: ${probe.state}. Run \`openclaw storage test ${name}\`.`); return location.scope(namespace === void 0 ? "backups" : `backups/${namespace}`); } catch (error) { await location.close(); throw error; } } async function listBackupObjects(location) { const objects = []; for await (const object of location.list(void 0, { acceptKey: (key) => parseRemoteBackupTimestamp(key) !== void 0 })) objects.push(object); return objects.toSorted((a, b) => b.key.localeCompare(a.key)); } async function createOffsiteBackupArchive(opts) { const namespace = resolveBackupNamespace(opts.namespace); const retention = normalizeBackupRetention(opts); const location = await openBackupLocation(opts.to, namespace); let scratch; let output; try { const { createBackupArchive } = await import("./backup-create-11RpCqA9.mjs"); if (opts.dryRun) return await createBackupArchive(opts); const { deviceId } = await loadOrCreateProcessDeviceIdentityAsync(); await claimBackupNamespace(location, namespace, deviceId, opts.claimNamespace === true); if (!opts.output) scratch = await createBackupScratchDirectory(os.tmpdir()); const result = await createBackupArchive({ ...opts, output: scratch ? path.join(scratch.directory, "archive.tar.gz") : opts.output }); const { verifyBackupArchive } = await import("./backup-verify-CKkznT6y.mjs"); await verifyBackupArchive(result.archivePath); result.verified = true; const plaintextBytes = (await fs$1.stat(result.archivePath)).size; const key = createRemoteBackupKey(Date.parse(result.createdAt)); await assertBackupNamespaceOwner(location, namespace, deviceId); const uploaded = await location.putObject(key, createReadStream(result.archivePath), { sizeBytes: plaintextBytes, precondition: () => assertBackupNamespaceOwner(location, namespace, deviceId) }); const stored = await location.stat(key); if (!stored || stored.sizeBytes !== plaintextBytes || stored.storedBytes !== uploaded.storedBytes) throw new Error(`Stored backup size could not be confirmed for ${opts.to}/${namespace}/${key}. Run \`openclaw storage test ${opts.to}\`.`); const description = location.describe(); output = { ...result, archivePath: opts.output ? result.archivePath : `storage://${opts.to}/backups/${namespace}/${key}`, localArchiveRetained: Boolean(opts.output), location: { name: description.name, provider: description.provider, locationId: description.locationId, key, namespace, plaintextBytes, storedBytes: stored.storedBytes } }; if (Object.values(retention).some((value) => value !== void 0)) { const selected = selectBackupRetention((await listBackupObjects(location)).map((object) => object.key), retention); for (const expired of selected.deleted) { await assertBackupNamespaceOwner(location, namespace, deviceId); await location.delete(expired, { precondition: () => assertBackupNamespaceOwner(location, namespace, deviceId) }); } output.retention = { kept: selected.kept.length, deleted: selected.deleted.length }; } return output; } finally { try { if (scratch) { const warning = await finishBackupScratch(scratch, opts.log); if (warning && output) output.warnings = [...output.warnings ?? [], warning]; } } finally { await location.close(); } } } async function backupListCommand(runtime, opts) { const namespace = resolveBackupNamespace(opts.namespace); const backupsLocation = await openBackupLocation(opts.from); const location = backupsLocation.scope(namespace); try { const namespaces = opts.namespace === void 0 ? await listBackupNamespaces(backupsLocation) : void 0; const backups = (await listBackupObjects(location)).map((object) => ({ key: object.key, sizeBytes: object.sizeBytes, storedBytes: object.storedBytes, createdAt: new Date(parseRemoteBackupTimestamp(object.key)).toISOString() })); const result = { location: location.describe(), namespace, backups, ...namespaces ? { namespaces } : {} }; if (opts.json) writeRuntimeJson(runtime, result); else { if (namespaces) runtime.log(namespaces.length ? `Available namespaces in ${opts.from}:\n${namespaces.map((entry) => `${entry.namespace}${entry.hostname ? ` (${entry.hostname})` : ""}`).join("\n")}` : `No backup namespaces in ${opts.from}.`); runtime.log(backups.length ? backups.map((backup) => `${backup.key} ${backup.sizeBytes} bytes (${backup.storedBytes} stored)`).join("\n") : `No backups in ${opts.from}/${namespace}.`); } return result; } finally { await location.close(); } } async function withDownloadedBackup(runtime, opts, consume) { const namespace = resolveBackupNamespace(opts.namespace); if (opts.archive !== "latest" && parseRemoteBackupTimestamp(opts.archive) === void 0) throw new Error("Expected a backup key from `openclaw backup list --from <location>` or latest."); const location = await openBackupLocation(opts.from, namespace); let scratch; try { const key = opts.archive === "latest" ? (await listBackupObjects(location))[0]?.key : opts.archive; if (!key) throw new Error(`No backups in ${opts.from}/${namespace}.`); const body = await location.getObject(key); if (!body) throw new Error(`Backup ${key} was not found in ${opts.from}/${namespace}.`); scratch = await createBackupScratchDirectory(os.tmpdir()); const archive = path.join(scratch.directory, "archive.tar.gz"); await pipeline(body, createWriteStream(archive, { flags: "wx", mode: 384 })); return await consume(archive); } finally { try { if (scratch) await finishBackupScratch(scratch, (message) => runtime.error(message)); } finally { await location.close(); } } } async function backupRemoteVerifyCommand(runtime, opts) { return await withDownloadedBackup(runtime, opts, async (archive) => { const { backupVerifyCommand } = await import("./backup-verify-CKkznT6y.mjs"); return await backupVerifyCommand(runtime, { archive, json: opts.json }); }); } async function backupRemoteRestoreCommand(runtime, opts) { return await withDownloadedBackup(runtime, opts, async (archive) => { const { backupRestoreCommand } = await import("./backup-restore-C6Q3R-y1.mjs"); return await backupRestoreCommand(runtime, { archive, target: opts.target, json: opts.json }); }); } //#endregion export { backupListCommand, backupRemoteRestoreCommand, backupRemoteVerifyCommand, createOffsiteBackupArchive };