diff --git a/src/backupService.js b/src/backupService.js index bbdf1f4..6a7ebf0 100644 --- a/src/backupService.js +++ b/src/backupService.js @@ -443,19 +443,78 @@ class BackupService { } tarIncrementalFlag = `--listed-incremental=${shellQuote(snarInContainer)}`; } 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') { - const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId); - if (lastTime) { - const unixSec = Math.floor(new Date(lastTime).getTime() / 1000); - tarIncrementalFlag = `--newer-mtime=@${unixSec}`; - pushLog(`Backup incremental (--newer-mtime): arquivos modificados apos ${lastTime}.`, 'preparando'); - } else { - pushLog('Aviso: sem backup anterior, gerando full.', 'preparando'); + try { + await fs.access(absoluteSnapshotPath); + tarIncrementalFlag = `--listed-incremental=${shellQuote('/backuproot/' + snapshotRelativePath)}`; + pushLog('Backup incremental via helper: snapshot anterior encontrado.', 'preparando'); + } catch { + pushLog('Aviso: snapshot anterior nao encontrado, gerando backup completo via helper.', '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 = [ 'set -u', 'umask 077', diff --git a/src/dockerService.js b/src/dockerService.js index a4e6c14..25b1cc9 100644 --- a/src/dockerService.js +++ b/src/dockerService.js @@ -523,7 +523,10 @@ class DockerService { 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(); const container = await this.docker.createContainer({ @@ -539,6 +542,76 @@ class DockerService { 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 { attachStream = await container.attach({ stream: true, stdout: true, stderr: true }); const stdoutStream = new PassThrough(); @@ -585,7 +658,7 @@ class DockerService { output = output.trim(); - if (result.StatusCode !== 0) { + if (result.StatusCode > maxOkExitCode) { throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`); }