atualiza versão para 0.1.6; corrige contagem de arquivos antes do backup e garante que o container alvo seja parado durante o processo de backup de volumes

This commit is contained in:
Alexander Sabino 2026-05-10 11:13:22 +01:00
parent 9f7eacecb9
commit a58ee6299a
3 changed files with 95 additions and 96 deletions

View File

@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/VERSION-0.1.5-blue?style=flat-square" /> <img src="https://img.shields.io/badge/VERSION-0.1.6-blue?style=flat-square" />
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" /> <img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" /> <img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" /> <img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
@ -23,6 +23,12 @@ Versão atual: **0.1.4**
--- ---
## <20> Changelog ## <20> Changelog
### [0.1.6] — 2026-05-11
#### Corrigido
- Contagem de arquivos totais antes do inicio do backup para todos os escopos (container inteiro e volumes), corrigindo a barra de progresso que mostrava 0 no total
- Container alvo agora e parado antes do backup de volumes e reiniciado apos conclusao, evitando inconsistencias nos dados arquivados
### [0.1.5] — 2026-05-10 ### [0.1.5] — 2026-05-10
#### Adicionado #### Adicionado

View File

@ -1,6 +1,6 @@
{ {
"name": "dockerbackup-app", "name": "dockerbackup-app",
"version": "0.1.5", "version": "0.1.6",
"description": "Aplicacao web para backup e restauracao de volumes Docker", "description": "Aplicacao web para backup e restauracao de volumes Docker",
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {

View File

@ -390,77 +390,18 @@ class BackupService {
pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando'); pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando');
const originalRunning = inspect.State?.Running === true; const originalRunning = inspect.State?.Running === true;
let tempStarted = false;
try { // === Path A: escopo de volumes → helper container (container alvo fica parado) ===
if (backupScope === 'volumes') {
if (originalRunning) { if (originalRunning) {
pushLog('Container ativo detectado. Parando antes do backup.', 'preparando'); pushLog('Container ativo detectado. Parando antes do backup.', 'preparando');
await this.dockerService.stopContainer(containerId); await this.dockerService.stopContainer(containerId);
} }
pushLog('Iniciando container temporariamente para snapshot.', 'preparando');
await this.dockerService.repairAndStartContainer(containerId);
tempStarted = true;
const sourcePaths = backupScope === 'container'
? ['/']
: activeMounts.map((mount) => mount.destination);
const relSourcePaths = sourcePaths.map((item) => toContainerRelPath(item));
if (backupScope === 'volumes') {
pushLog('Contando arquivos para barra de progresso.', 'contando');
const countCmd = `set -eu; TOTAL=0; for p in ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}; do if [ -e \"/$p\" ]; then C=$(find \"/$p\" 2>/dev/null | wc -l | tr -d \" \" ); TOTAL=$((TOTAL + C)); fi; done; echo \"$TOTAL\"`;
const output = await this.dockerService.runContainerCommand(containerId, countCmd);
const parsed = Number(output.split(/\r?\n/).pop());
fileTotal = Number.isFinite(parsed) ? parsed : 0;
pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando');
}
const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath); const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath);
const snarInContainer = containerSnapshotPath(profile.id, containerId, backupScope);
const absoluteSnapshotPath = path.posix.join(backupRoot, snapshotRelativePath); const absoluteSnapshotPath = path.posix.join(backupRoot, snapshotRelativePath);
// Detecta se o container tem GNU tar (--listed-incremental é extensão GNU). try {
// Containers Alpine/BusyBox usam o fallback --newer-mtime.
const hasGnuTar = await this.dockerService.containerHasGnuTar(containerId);
pushLog(`GNU tar detectado no container: ${hasGnuTar ? 'sim' : 'nao (usando --newer-mtime como fallback)'}.`, 'preparando');
let tarIncrementalFlag = '';
if (hasGnuTar) {
// Gerencia o .snar assim como o script shell usa --listed-incremental=$dirbackup/backup.snar:
// - Full: remove o .snar anterior do container para forçar snapshot limpo.
// - Incremental: injeta o .snar salvo no diretório de backup de volta no container.
if (runMode === 'full') {
await this.dockerService.runContainerCommand(containerId, `rm -f ${shellQuote(snarInContainer)}`).catch(() => null);
pushLog('Backup full: snapshot incremental anterior removido.', 'preparando');
} else {
try {
await fs.access(absoluteSnapshotPath);
await this.dockerService.putSnarToContainer(containerId, absoluteSnapshotPath, snarInContainer);
pushLog('Backup incremental: snapshot anterior restaurado no container.', 'preparando');
} catch {
pushLog('Aviso: snapshot anterior nao encontrado, gerando backup completo.', 'preparando');
}
}
tarIncrementalFlag = `--listed-incremental=${shellQuote(snarInContainer)}`;
} else {
// Fallback para containers sem GNU tar: usa helper container com GNU tar
// montando os volumes diretamente — produz .snar como qualquer outro container.
if (runMode === 'full') {
// Full: remove .snar anterior para forçar snapshot limpo no helper.
await fs.rm(absoluteSnapshotPath, { force: true }).catch(() => null);
}
// O .snar é gerenciado pelo helper usando helperSnarPath calculado abaixo.
}
// 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();
// Quando rodando dentro do Docker, backupRoot é um path interno do container da app.
// Precisamos do source real (host path ou volume name) para passar ao helper container.
const selfBind = await this.dockerService.getSelfBindSource(backupRoot); const selfBind = await this.dockerService.getSelfBindSource(backupRoot);
if (!selfBind) { if (!selfBind) {
throw new Error( throw new Error(
@ -469,18 +410,40 @@ class BackupService {
); );
} }
// suffix é o subpath dentro do volume (ex: /backups quando mount=/app/data e backupRoot=/app/data/backups)
const helperBackupRoot = `/backuproot_base${selfBind.suffix}`; const helperBackupRoot = `/backuproot_base${selfBind.suffix}`;
const helperBinds = [`${selfBind.source}:/backuproot_base`]; const helperBinds = [`${selfBind.source}:/backuproot_base`];
const helperRelPaths = []; const helperRelPaths = [];
for (const mount of activeMounts) { for (const mount of activeMounts) {
const src = mount.type === 'volume' ? mount.name : mount.source; const src = mount.type === 'volume' ? mount.name : mount.source;
// Monta no path real do container (ex: /var/lib/gitea) para que o archive // Monta no path real do container para que o archive tenha entradas com paths corretos.
// gerado tenha entradas com os paths corretos (ex: var/lib/gitea/...).
// Isso garante compatibilidade com o restore via helper.
helperBinds.push(`${src}:${mount.destination}:ro`); helperBinds.push(`${src}:${mount.destination}:ro`);
helperRelPaths.push(toContainerRelPath(mount.destination)); helperRelPaths.push(toContainerRelPath(mount.destination));
} }
// Conta arquivos via helper antes de iniciar o tar (container alvo permanece parado).
pushLog('Contando arquivos para barra de progresso.', 'contando');
const countCmd = `set -eu; TOTAL=0; for p in ${helperRelPaths.map((p) => shellQuote('/' + p)).join(' ')}; do if [ -e "$p" ]; then C=$(find "$p" 2>/dev/null | wc -l | tr -d ' '); TOTAL=$((TOTAL + C)); fi; done; echo "__DBKP_TOTAL__=$TOTAL"`;
await this.dockerService.runHelper({
binds: helperBinds,
cmd: countCmd,
onOutput: (line) => {
const m = String(line || '').match(/__DBKP_TOTAL__=(\d+)/);
if (m) fileTotal = Number(m[1]) || 0;
},
});
pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando');
updateFileProgress();
if (runMode === 'full') {
await fs.rm(absoluteSnapshotPath, { force: true }).catch(() => null);
pushLog('Backup full: snapshot incremental anterior removido.', 'preparando');
} else {
const snarExists = await fs.access(absoluteSnapshotPath).then(() => true).catch(() => false);
pushLog(snarExists
? 'Backup incremental: snapshot anterior encontrado.'
: 'Aviso: snapshot anterior nao encontrado, gerando backup completo.', 'preparando');
}
const helperArchivePath = `${helperBackupRoot}/${archiveRelativePath}`; const helperArchivePath = `${helperBackupRoot}/${archiveRelativePath}`;
const helperSnarPath = `${helperBackupRoot}/${snapshotRelativePath}`; const helperSnarPath = `${helperBackupRoot}/${snapshotRelativePath}`;
const helperSnarDir = path.posix.dirname(helperSnarPath); const helperSnarDir = path.posix.dirname(helperSnarPath);
@ -491,17 +454,14 @@ class BackupService {
`tar --ignore-failed-read --listed-incremental=${shellQuote(helperSnarPath)} -czvf ${shellQuote(helperArchivePath)} -C / ${helperRelPaths.map((p) => shellQuote(p)).join(' ')}; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`, `tar --ignore-failed-read --listed-incremental=${shellQuote(helperSnarPath)} -czvf ${shellQuote(helperArchivePath)} -C / ${helperRelPaths.map((p) => shellQuote(p)).join(' ')}; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`,
].join('; '); ].join('; ');
pushLog('Iniciando compactacao tar dos volumes via helper.', 'gerando-tar');
await this.dockerService.runHelper({ await this.dockerService.runHelper({
binds: helperBinds, binds: helperBinds,
cmd: helperCmd, cmd: helperCmd,
maxOkExitCode: 1, maxOkExitCode: 1,
onOutput: (line, stream) => { onOutput: (line, stream) => {
const normalizedLine = String(line || '').trim(); const normalizedLine = String(line || '').trim();
if (!normalizedLine || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) { if (!normalizedLine || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) return;
return;
}
// No helper (tar escreve em arquivo): lista de arquivos vai para stdout;
// avisos do tar vão para stderr.
if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) { if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) {
fileCurrent += 1; fileCurrent += 1;
updateFileProgress(normalizedLine); updateFileProgress(normalizedLine);
@ -527,30 +487,67 @@ class BackupService {
file: { current: Math.max(fileCurrent, fileTotal), total: fileTotal, currentFile: null, percent: 100 }, file: { current: Math.max(fileCurrent, fileTotal), total: fileTotal, currentFile: null, percent: 100 },
}); });
return containerBackup; return containerBackup;
} finally {
if (originalRunning) {
pushLog('Reiniciando container (estava ativo antes do backup).', 'finalizando');
await this.dockerService.startContainer(containerId).catch(() => null);
}
}
}
// === Path B: escopo de container inteiro → iniciar temporariamente para exec ===
const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath);
const absoluteSnapshotPath = path.posix.join(backupRoot, snapshotRelativePath);
let tempStarted = false;
try {
if (originalRunning) {
pushLog('Container ativo detectado. Parando antes do backup.', 'preparando');
await this.dockerService.stopContainer(containerId);
} }
pushLog('Iniciando container temporariamente para snapshot.', 'preparando');
await this.dockerService.repairAndStartContainer(containerId);
tempStarted = true;
// Conta arquivos excluindo pseudo-filesystems antes de iniciar o tar.
pushLog('Contando arquivos para barra de progresso.', 'contando');
const countCmd = `find / \\( -path /proc -o -path /sys -o -path /dev -o -path /run -o -path /tmp \\) -prune -o -print 2>/dev/null | wc -l | tr -d ' '`;
const countOutput = await this.dockerService.runContainerCommand(containerId, countCmd);
const countParsed = Number(countOutput.split(/\r?\n/).filter((l) => l.trim()).pop());
fileTotal = Number.isFinite(countParsed) ? countParsed : 0;
pushLog(`Total de arquivos identificado: ${fileTotal}.`, 'contando');
const snarInContainer = containerSnapshotPath(profile.id, containerId, backupScope);
// Detecta se o container tem GNU tar (--listed-incremental é extensão GNU).
const hasGnuTar = await this.dockerService.containerHasGnuTar(containerId);
pushLog(`GNU tar detectado no container: ${hasGnuTar ? 'sim' : 'nao (usando --newer-mtime como fallback)'}.`, 'preparando');
let tarIncrementalFlag = '';
if (hasGnuTar) {
if (runMode === 'full') {
await this.dockerService.runContainerCommand(containerId, `rm -f ${shellQuote(snarInContainer)}`).catch(() => null);
pushLog('Backup full: snapshot incremental anterior removido.', 'preparando');
} else {
try {
await fs.access(absoluteSnapshotPath);
await this.dockerService.putSnarToContainer(containerId, absoluteSnapshotPath, snarInContainer);
pushLog('Backup incremental: snapshot anterior restaurado no container.', 'preparando');
} catch {
pushLog('Aviso: snapshot anterior nao encontrado, gerando backup completo.', 'preparando');
}
}
tarIncrementalFlag = `--listed-incremental=${shellQuote(snarInContainer)}`;
}
const gnuFlags = hasGnuTar ? '--ignore-failed-read' : '';
const tarParts = [ const tarParts = [
'set -u', 'set -u',
'umask 077', 'umask 077',
'echo "__DBKP_TAR_BEGIN__" 1>&2', 'echo "__DBKP_TAR_BEGIN__" 1>&2',
`tar ${gnuFlags} ${tarIncrementalFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`,
]; ];
// --ignore-failed-read é extensão GNU tar — não existe no BusyBox tar (Alpine).
// Usar condicionalmente para evitar aborto silencioso com 0 bytes no arquivo.
const gnuFlags = hasGnuTar ? '--ignore-failed-read' : '';
// GNU tar: exit 0 = ok, exit 1 = avisos (arquivos mudaram, permissão negada), exit 2 = erro fatal.
// Aceitamos exit 1 como sucesso para não descartar archives válidos com avisos menores.
if (backupScope === 'container') {
tarParts.push(
`tar ${gnuFlags} ${tarIncrementalFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`
);
} else {
tarParts.push(
`tar ${gnuFlags} ${tarIncrementalFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}; TAR_RC=$?; [ $TAR_RC -le 1 ] || exit $TAR_RC`
);
}
updateFileProgress(); updateFileProgress();
pushLog('Iniciando compactacao tar do container.', 'gerando-tar'); pushLog('Iniciando compactacao tar do container.', 'gerando-tar');
@ -558,10 +555,7 @@ class BackupService {
maxOkExitCode: 1, maxOkExitCode: 1,
onOutput: (line, stream) => { onOutput: (line, stream) => {
const normalizedLine = String(line || '').trim(); const normalizedLine = String(line || '').trim();
if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) { if (!normalizedLine || stream !== 'stderr' || normalizedLine.startsWith('__DBKP_TAR_BEGIN__')) return;
return;
}
if (!normalizedLine.startsWith('tar:')) { if (!normalizedLine.startsWith('tar:')) {
fileCurrent += 1; fileCurrent += 1;
updateFileProgress(normalizedLine); updateFileProgress(normalizedLine);
@ -573,8 +567,7 @@ class BackupService {
pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando'); pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando');
// Persiste o .snar atualizado no diretório de backup (como o script shell faz com $dirbackup/backup.snar) // Persiste o .snar atualizado para sobreviver a recriações do container.
// para que a cadeia incremental sobreviva a recriações do container.
if (hasGnuTar) { if (hasGnuTar) {
const snarSaved = await this.dockerService.getSnarFromContainer(containerId, snarInContainer, absoluteSnapshotPath).catch(() => false); const snarSaved = await this.dockerService.getSnarFromContainer(containerId, snarInContainer, absoluteSnapshotPath).catch(() => false);
if (snarSaved) { if (snarSaved) {