Files
incus-backup-ui/backend/src/executor.js
T

147 lines
3.6 KiB
JavaScript

import { spawn } from 'node:child_process';
import { PassThrough, Transform } from 'node:stream';
import { resticProcessEnv } from './config.js';
export class CommandError extends Error {
constructor(message, result) {
super(message);
this.name = 'CommandError';
this.result = result;
}
}
export function spawnCommand(command, args = [], options = {}) {
const {
env = process.env,
input = null,
log = null,
ignoreExitCode = false,
cwd = process.cwd(),
} = options;
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env,
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
const text = chunk.toString();
stdout += text;
log?.(text.trimEnd());
});
child.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderr += text;
log?.(text.trimEnd());
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (exitCode) => {
const result = { stdout, stderr, exitCode };
if (exitCode !== 0 && !ignoreExitCode) {
reject(new CommandError(stderr.trim() || `${command} exited with ${exitCode}`, result));
return;
}
resolve(result);
});
if (input) {
input.on('error', (error) => {
child.kill('SIGTERM');
reject(error);
});
input.pipe(child.stdin);
} else {
child.stdin.end();
}
});
}
export function runRestic(args, options = {}) {
return spawnCommand('restic', args, {
...options,
env: resticProcessEnv(),
});
}
export function createProgressStream(totalBytes, onProgress) {
let currentBytes = 0;
return new Transform({
transform(chunk, _encoding, callback) {
currentBytes += chunk.length;
onProgress?.({
currentBytes,
totalBytes,
percent: totalBytes ? (currentBytes / totalBytes) * 100 : 0,
});
callback(null, chunk);
},
});
}
export function streamResticDumpToDd(snapshotId, filename, outputPath, log, progress = null) {
return new Promise((resolve, reject) => {
const restic = spawn('restic', ['dump', snapshotId, filename], {
env: resticProcessEnv(),
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
const dd = spawn('dd', [`of=${outputPath}`, 'bs=4M', 'conv=sparse', 'status=none'], {
env: process.env,
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
const errors = [];
let resticClosed = false;
let ddClosed = false;
const pipe = new PassThrough();
let restoreStream = restic.stdout.pipe(pipe);
if (progress) {
restoreStream = restoreStream.pipe(createProgressStream(progress.totalBytes, progress.onProgress));
}
restoreStream.pipe(dd.stdin);
restic.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd()));
dd.stderr.on('data', (chunk) => log?.(chunk.toString().trimEnd()));
restic.on('error', reject);
dd.on('error', reject);
restic.on('close', (code) => {
resticClosed = true;
if (code !== 0) {
errors.push(`restic dump exited with ${code}`);
dd.stdin.destroy();
}
maybeFinish();
});
dd.on('close', (code) => {
ddClosed = true;
if (code !== 0) {
errors.push(`dd exited with ${code}`);
}
maybeFinish();
});
function maybeFinish() {
if (!resticClosed || !ddClosed) return;
if (errors.length) {
reject(new Error(errors.join('; ')));
return;
}
resolve();
}
});
}