Adiciona suporte para execução de containers BusyBox sem GNU tar, redirecionando stdout para um arquivo local e gerando o archive diretamente no diretório de backup.

This commit is contained in:
Alexander Sabino 2026-05-07 11:40:22 +01:00
parent 612ea0abe7
commit 424f19c2e2
2 changed files with 142 additions and 10 deletions

View File

@ -443,19 +443,78 @@ class BackupService {
} }
tarIncrementalFlag = `--listed-incremental=${shellQuote(snarInContainer)}`; tarIncrementalFlag = `--listed-incremental=${shellQuote(snarInContainer)}`;
} else { } else {
// Fallback para containers sem GNU tar: --newer-mtime baseado no timestamp do último backup. // Fallback para containers sem GNU tar: usa helper container com GNU tar
// montando os volumes diretamente — produz .snar como qualquer outro container.
if (runMode === 'incremental') { if (runMode === 'incremental') {
const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId); try {
if (lastTime) { await fs.access(absoluteSnapshotPath);
const unixSec = Math.floor(new Date(lastTime).getTime() / 1000); tarIncrementalFlag = `--listed-incremental=${shellQuote('/backuproot/' + snapshotRelativePath)}`;
tarIncrementalFlag = `--newer-mtime=@${unixSec}`; pushLog('Backup incremental via helper: snapshot anterior encontrado.', 'preparando');
pushLog(`Backup incremental (--newer-mtime): arquivos modificados apos ${lastTime}.`, 'preparando'); } catch {
} else { pushLog('Aviso: snapshot anterior nao encontrado, gerando backup completo via helper.', 'preparando');
pushLog('Aviso: sem backup anterior, gerando full.', 'preparando'); tarIncrementalFlag = `--listed-incremental=${shellQuote('/backuproot/' + snapshotRelativePath)}`;
} }
} else {
// Full: remove .snar anterior para forçar snapshot limpo.
await fsp.rm(absoluteSnapshotPath, { force: true }).catch(() => null);
tarIncrementalFlag = `--listed-incremental=${shellQuote('/backuproot/' + snapshotRelativePath)}`;
} }
} }
// Containers BusyBox sem GNU tar: roda o tar num helper container que TEM GNU tar,
// montando os volumes do container alvo diretamente.
if (!hasGnuTar && backupScope === 'volumes' && activeMounts.length) {
pushLog('Container sem GNU tar: usando helper com GNU tar para gerar archive.', 'gerando-tar');
updateFileProgress();
const helperBinds = [`${backupRoot}:/backuproot`];
const helperRelPaths = [];
for (const [index, mount] of activeMounts.entries()) {
const src = mount.type === 'volume' ? mount.name : mount.source;
helperBinds.push(`${src}:/payload/m${index}:ro`);
helperRelPaths.push(`payload/m${index}`);
}
const helperArchivePath = `/backuproot/${archiveRelativePath}`;
const helperSnarDir = path.posix.dirname(`/backuproot/${snapshotRelativePath}`);
const helperCmd = [
'set -u',
`mkdir -p ${shellQuote(path.posix.dirname(helperArchivePath))} ${shellQuote(helperSnarDir)}`,
`echo "__DBKP_TAR_BEGIN__" 1>&2`,
`tar --ignore-failed-read ${tarIncrementalFlag} -czvf ${shellQuote(helperArchivePath)} -C / ${helperRelPaths.map((p) => shellQuote(p)).join(' ')}; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`,
].join('; ');
await this.dockerService.runHelper({
binds: helperBinds,
cmd: helperCmd,
maxOkExitCode: 1,
onOutput: (line, stream) => {
const normalizedLine = String(line || '').trim();
if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) {
return;
}
if (!normalizedLine.startsWith('tar:')) {
fileCurrent += 1;
updateFileProgress(normalizedLine);
} else {
pushLog(`Aviso do tar: ${normalizedLine}`, 'gerando-tar');
}
},
});
pushLog(`Arquivo gerado via helper: ${absoluteArchivePath}`, 'finalizando');
pushLog('Snapshot incremental salvo no diretorio de backup.', 'finalizando');
onProgress({
containerName,
status: 'ok',
step: 'concluido',
message: 'Backup concluido com sucesso.',
percent: 100,
file: { current: Math.max(fileCurrent, fileTotal), total: fileTotal, currentFile: null, percent: 100 },
});
return containerBackup;
}
const tarParts = [ const tarParts = [
'set -u', 'set -u',
'umask 077', 'umask 077',

View File

@ -523,7 +523,10 @@ class DockerService {
return true; return true;
} }
async runHelper({ binds, cmd, onOutput }) { // Igual ao runHelper, mas redireciona stdout para um arquivo local.
// Usado para containers BusyBox (sem GNU tar): monta os volumes no helper que TEM GNU tar
// e gera o archive + .snar diretamente no diretório de backup.
async runHelperStreamToFile({ binds, cmd, targetFilePath, onOutput, maxOkExitCode = 0 }) {
await this.ensureImage(); await this.ensureImage();
const container = await this.docker.createContainer({ const container = await this.docker.createContainer({
@ -539,6 +542,76 @@ class DockerService {
let attachStream; let attachStream;
try {
attachStream = await container.attach({ stream: true, stdout: true, stderr: true });
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
this.docker.modem.demuxStream(attachStream, stdoutStream, stderrStream);
await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
const writeStream = fs.createWriteStream(targetFilePath);
stdoutStream.pipe(writeStream);
const stderrDone = new Promise((resolve) => {
let buffer = '';
stderrStream.on('data', (chunk) => {
const text = chunk.toString('utf8');
buffer += text;
const parts = buffer.split(/\r?\n/);
buffer = parts.pop() || '';
for (const line of parts) {
if (typeof onOutput === 'function') onOutput(line, 'stderr');
}
});
stderrStream.on('end', () => {
if (buffer && typeof onOutput === 'function') onOutput(buffer, 'stderr');
resolve();
});
});
await container.start();
const result = await container.wait();
if (attachStream && typeof attachStream.destroy === 'function') {
attachStream.destroy();
}
stdoutStream.end();
stderrStream.end();
await Promise.all([
stderrDone,
new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
}),
]);
if (result.StatusCode > maxOkExitCode) {
throw new Error(`Helper (stream) terminou com codigo ${result.StatusCode}`);
}
} finally {
if (attachStream && typeof attachStream.destroy === 'function') {
try { attachStream.destroy(); } catch { /* ignore */ }
}
try { await container.remove({ force: true }); } catch { /* ignore */ }
}
}
async runHelper({ binds, cmd, onOutput }) { await this.ensureImage();
const container = await this.docker.createContainer({
Image: this.helperImage,
Cmd: ['sh', '-c', cmd],
Tty: false,
HostConfig: {
Binds: binds,
AutoRemove: false,
NetworkMode: 'none',
},
});
let attachStream;
try { try {
attachStream = await container.attach({ stream: true, stdout: true, stderr: true }); attachStream = await container.attach({ stream: true, stdout: true, stderr: true });
const stdoutStream = new PassThrough(); const stdoutStream = new PassThrough();
@ -585,7 +658,7 @@ class DockerService {
output = output.trim(); output = output.trim();
if (result.StatusCode !== 0) { if (result.StatusCode > maxOkExitCode) {
throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`); throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`);
} }