147 lines
3.6 KiB
JavaScript
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();
|
|
}
|
|
});
|
|
}
|