diff --git a/README.md b/README.md index 64f5f71..b079453 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Dark-mode control plane for Incus VM backups using ZFS block devices, Restic, and S3-compatible storage. +Backups include the instance disk or dataset plus Incus metadata captured with `incus config show`, `incus config show --expanded`, `incus info`, and snapshot listing. VM disks are stored as `.raw`; containers are stored as ZFS send streams (`.zfs`). + ## Layout - `management/`: Central Express API with cookie login, SQLite storage, node registry, central schedules, and proxy calls to node agents. diff --git a/backend/src/routes/backup.js b/backend/src/routes/backup.js index 3121ec4..2d46881 100644 --- a/backend/src/routes/backup.js +++ b/backend/src/routes/backup.js @@ -1,5 +1,8 @@ import { Router } from 'express'; import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { finished } from 'node:stream/promises'; import { setTimeout as delay } from 'node:timers/promises'; import { config } from '../config.js'; @@ -86,8 +89,12 @@ export async function runBackupJob(job, instance = null) { setJobProgress(job, { percent: 93 }); await deleteIncusSnapshotWithRetry(job, snapshotName); + setJobStep(job, 'Backing up Incus metadata'); + setJobProgress(job, { percent: 95 }); + await backupInstanceMetadata(job, 'virtual-machine'); + setJobStep(job, 'Applying Restic retention policy'); - setJobProgress(job, { percent: 96 }); + setJobProgress(job, { percent: 97 }); await runRestic(retentionArgs(job.vmName), { log: (line) => appendJobLog(job, line), }); @@ -142,8 +149,12 @@ async function runContainerBackupJob(job) { setJobProgress(job, { percent: 93 }); await deleteIncusSnapshotWithRetry(job, snapshotName); + setJobStep(job, 'Backing up Incus metadata'); + setJobProgress(job, { percent: 95 }); + await backupInstanceMetadata(job, 'container'); + setJobStep(job, 'Applying Restic retention policy'); - setJobProgress(job, { percent: 96 }); + setJobProgress(job, { percent: 97 }); await runRestic(retentionArgs(job.vmName), { log: (line) => appendJobLog(job, line), }); @@ -173,6 +184,46 @@ async function zfsDatasetUsed(dataset) { return Number.isFinite(size) && size > 0 ? size : null; } +async function backupInstanceMetadata(job, instanceType) { + const tempDir = await mkdtemp(path.join(tmpdir(), `incus-backup-${job.vmName}-`)); + try { + const metadataDir = path.join(tempDir, job.vmName); + await writeMetadataFile(metadataDir, 'README.txt', metadataReadme(job.vmName, instanceType)); + await writeCommandOutput(metadataDir, 'config.yaml', ['config', 'show', job.vmName]); + await writeCommandOutput(metadataDir, 'config-expanded.yaml', ['config', 'show', job.vmName, '--expanded']); + await writeCommandOutput(metadataDir, 'info.txt', ['info', job.vmName]); + await writeCommandOutput(metadataDir, 'snapshots.json', ['snapshot', 'list', job.vmName, '--format', 'json']); + await runRestic(['backup', metadataDir, '--tag', job.vmName, '--tag', 'metadata', '--tag', instanceType], { + log: (line) => appendJobLog(job, line), + }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +async function writeCommandOutput(directory, filename, args) { + const result = await spawnCommand('incus', args); + await writeMetadataFile(directory, filename, result.stdout); +} + +async function writeMetadataFile(directory, filename, content) { + const { mkdir } = await import('node:fs/promises'); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, filename), content || '', { mode: 0o600 }); +} + +function metadataReadme(instanceName, instanceType) { + return [ + `Instance: ${instanceName}`, + `Type: ${instanceType}`, + `Created: ${new Date().toISOString()}`, + '', + 'This directory contains Incus metadata captured alongside the disk/dataset backup.', + 'It is intended for disaster-recovery reconstruction and restore preflight workflows.', + '', + ].join('\n'); +} + function waitForProcess(child, label) { return new Promise((resolve, reject) => { child.on('error', reject);