added progress bar and scheduler

This commit is contained in:
Philipp committed 2026-05-21 08:53:21 +02:00
1 parent 3d46f8543b
commit f9350587fe
18 files changed
+624 -165

No files matched your search

+46 -7
View File
@@ -2,8 +2,8 @@ import { Router } from 'express';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { config } from '../config.js';
import { spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobRunning, setJobStep } from '../jobs.js';
import { createProgressStream, spawnCommand, runRestic } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateVmExists } from '../validators.js';
export const backupRouter = Router();
@@ -11,37 +11,55 @@ export const backupRouter = Router();
backupRouter.post('/:vmName', async (req, res, next) => {
try {
const vmName = req.params.vmName;
await validateVmExists(vmName);
const job = createJob('backup', vmName);
runBackupJob(job).catch(() => {});
const job = await startBackupForVm(vmName);
res.status(202).json({ jobId: job.id, message: 'Backup job started.' });
} catch (error) {
next(error);
}
});
async function runBackupJob(job) {
export async function startBackupForVm(vmName) {
await validateVmExists(vmName);
const job = createJob('backup', vmName);
runBackupJob(job).catch(() => {});
return job;
}
export async function runBackupJob(job) {
const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const snapshotName = `s3-backup-${timestamp}`;
const zvol = `${config.zfsPoolName}/virtual-machines/${job.vmName}.block`;
const snapshotDevice = `/dev/zvol/${zvol}@snapshot-${snapshotName}`;
try {
const totalBytes = await zfsVolumeSize(zvol);
setJobRunning(job, 'Creating Incus snapshot');
setJobProgress(job, { percent: 2 });
await spawnCommand('incus', ['snapshot', 'create', job.vmName, snapshotName], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Making ZFS snapshot device visible');
setJobProgress(job, { percent: 5 });
await spawnCommand('zfs', ['set', 'snapdev=visible', zvol], { log: (line) => appendJobLog(job, line) });
setJobStep(job, 'Waiting for snapshot device');
setJobProgress(job, { percent: 8 });
await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Streaming block device to Restic');
const snapshotStream = await deviceReadStream(snapshotDevice);
const progressStream = createProgressStream(totalBytes, ({ currentBytes, totalBytes: bytesTotal, percent }) => {
setJobProgress(job, {
currentBytes,
totalBytes: bytesTotal,
percent: 10 + percent * 0.78,
detail: `Streaming ${formatBytes(currentBytes)} of ${formatBytes(bytesTotal)}`,
});
});
try {
await spawnCommand('restic', ['backup', '--stdin', '--stdin-filename', `${job.vmName}.raw`, '--tag', job.vmName], {
env: { ...process.env, ...config.resticEnv },
input: snapshotStream,
input: snapshotStream.pipe(progressStream),
log: (line) => appendJobLog(job, line),
});
} finally {
@@ -52,13 +70,16 @@ async function runBackupJob(job) {
}
setJobStep(job, 'Hiding ZFS snapshot device');
setJobProgress(job, { percent: 90 });
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], { log: (line) => appendJobLog(job, line) });
await settleUdev(job);
setJobStep(job, 'Deleting temporary Incus snapshot');
setJobProgress(job, { percent: 93 });
await deleteIncusSnapshotWithRetry(job, snapshotName);
setJobStep(job, 'Applying Restic retention policy');
setJobProgress(job, { percent: 96 });
await runRestic(['forget', '--tag', job.vmName, '--keep-daily', '7', '--prune'], {
log: (line) => appendJobLog(job, line),
});
@@ -76,6 +97,24 @@ async function deviceReadStream(path) {
return createReadStream(path);
}
async function zfsVolumeSize(zvol) {
const result = await spawnCommand('zfs', ['get', '-Hp', '-o', 'value', 'volsize', zvol]);
const size = Number(result.stdout.trim());
return Number.isFinite(size) && size > 0 ? size : null;
}
function formatBytes(value) {
if (!value) return '-';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let size = Number(value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
async function cleanupBackup(job, zvol, snapshotName) {
setJobStep(job, 'Running cleanup');
await spawnCommand('zfs', ['set', 'snapdev=hidden', zvol], {
+36 -2
View File
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { config } from '../config.js';
import { spawnCommand, streamResticDumpToDd } from '../executor.js';
import { appendJobLog, createJob, finishJob, setJobRunning, setJobStep } from '../jobs.js';
import { appendJobLog, createJob, finishJob, setJobProgress, setJobRunning, setJobStep } from '../jobs.js';
import { validateSnapshotForVm, validateVmExists } from '../validators.js';
export const restoreRouter = Router();
@@ -34,29 +34,45 @@ async function runRestoreJob(job, snapshotId) {
try {
setJobRunning(job, 'Stopping VM');
setJobProgress(job, { percent: 3 });
await spawnCommand('incus', ['stop', job.vmName, '--force'], {
ignoreExitCode: true,
log: (line) => appendJobLog(job, line),
});
setJobStep(job, 'Setting ZFS volume to device mode');
setJobProgress(job, { percent: 8 });
await spawnCommand('zfs', ['set', 'volmode=dev', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = true;
setJobStep(job, 'Settling device nodes');
setJobProgress(job, { percent: 12 });
await spawnCommand('udevadm', ['trigger'], { log: (line) => appendJobLog(job, line) });
await spawnCommand('udevadm', ['settle'], { log: (line) => appendJobLog(job, line) });
await new Promise((resolve) => setTimeout(resolve, 2000));
setJobStep(job, 'Writing Restic snapshot to block device');
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line));
const totalBytes = await zfsVolumeSize(zvol);
await streamResticDumpToDd(snapshotId, `${job.vmName}.raw`, device, (line) => appendJobLog(job, line), {
totalBytes,
onProgress: ({ currentBytes, totalBytes: bytesTotal, percent }) => {
setJobProgress(job, {
currentBytes,
totalBytes: bytesTotal,
percent: 15 + percent * 0.78,
detail: `Writing ${formatBytes(currentBytes)} of ${formatBytes(bytesTotal)}`,
});
},
});
diskWriteOk = true;
setJobStep(job, 'Restoring ZFS volume mode');
setJobProgress(job, { percent: 94 });
await spawnCommand('zfs', ['set', 'volmode=none', zvol], { log: (line) => appendJobLog(job, line) });
volmodeDev = false;
setJobStep(job, 'Starting VM');
setJobProgress(job, { percent: 97 });
await spawnCommand('incus', ['start', job.vmName], { log: (line) => appendJobLog(job, line) });
finishJob(job, 'success');
@@ -75,3 +91,21 @@ async function runRestoreJob(job, snapshotId) {
finishJob(job, 'failed', error);
}
}
async function zfsVolumeSize(zvol) {
const result = await spawnCommand('zfs', ['get', '-Hp', '-o', 'value', 'volsize', zvol]);
const size = Number(result.stdout.trim());
return Number.isFinite(size) && size > 0 ? size : null;
}
function formatBytes(value) {
if (!value) return '-';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let size = Number(value);
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { listSchedules, replaceSchedules } from '../scheduler.js';
export const schedulesRouter = Router();
schedulesRouter.get('/', (_req, res) => {
res.json(listSchedules());
});
schedulesRouter.put('/', async (req, res, next) => {
try {
const schedules = await replaceSchedules(req.body?.schedules || []);
res.json(schedules);
} catch (error) {
next(error);
}
});
+1
View File
@@ -28,5 +28,6 @@ function summarizeJob(job) {
type: job.type,
status: job.status,
currentStep: job.currentStep,
progress: job.progress,
};
}