From a8036507bbed1ce9a12491cfe55a8e05af42df83 Mon Sep 17 00:00:00 2001 From: Alexander Sabino <32822107+asabino2@users.noreply.github.com> Date: Thu, 7 May 2026 10:32:43 +0100 Subject: [PATCH] Implementa suporte para snapshots incrementais e gerenciamento de arquivos .snar no BackupService --- src/backupService.js | 35 +++++++++++++++------- src/dockerService.js | 70 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/backupService.js b/src/backupService.js index e8e3fdc..b63490b 100644 --- a/src/backupService.js +++ b/src/backupService.js @@ -415,16 +415,22 @@ class BackupService { } const absoluteArchivePath = path.posix.join(backupRoot, archiveRelativePath); + const snarInContainer = containerSnapshotPath(profile.id, containerId, backupScope); + const absoluteSnapshotPath = path.posix.join(backupRoot, snapshotRelativePath); - let newerMtimeFlag = ''; - if (runMode === 'incremental') { - const lastTime = await this.store.getLastContainerBackupTime(profile.id, containerId); - if (lastTime) { - const unixSec = Math.floor(new Date(lastTime).getTime() / 1000); - newerMtimeFlag = `--newer-mtime=@${unixSec}`; - pushLog(`Backup incremental: incluindo arquivos modificados apos ${lastTime}.`, 'preparando'); - } else { - pushLog('Aviso: nenhum backup anterior encontrado, gerando full.', 'preparando'); + // 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'); } } @@ -436,11 +442,11 @@ class BackupService { if (backupScope === 'container') { tarParts.push( - `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .` + `tar --warning=no-file-changed --ignore-failed-read --listed-incremental=${shellQuote(snarInContainer)} -czvf - -C / --exclude=proc --exclude=sys --exclude=dev --exclude=run --exclude=tmp .` ); } else { tarParts.push( - `tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}` + `tar --warning=no-file-changed --ignore-failed-read --listed-incremental=${shellQuote(snarInContainer)} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}` ); } @@ -464,6 +470,13 @@ class BackupService { }); pushLog(`Arquivo gerado: ${absoluteArchivePath}`, 'finalizando'); + + // Persiste o .snar atualizado no diretório de backup (como o script shell faz com $dirbackup/backup.snar) + // para que a cadeia incremental sobreviva a recriações do container. + const snarSaved = await this.dockerService.getSnarFromContainer(containerId, snarInContainer, absoluteSnapshotPath).catch(() => false); + if (snarSaved) { + pushLog('Snapshot incremental salvo no diretorio de backup.', 'finalizando'); + } } finally { if (tempStarted) { pushLog('Encerrando container apos backup.', 'finalizando'); diff --git a/src/dockerService.js b/src/dockerService.js index 1257ed7..fdbeb02 100644 --- a/src/dockerService.js +++ b/src/dockerService.js @@ -1,5 +1,5 @@ const Docker = require('dockerode'); -const { PassThrough } = require('stream'); +const { PassThrough, Readable } = require('stream'); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); @@ -7,6 +7,40 @@ const zlib = require('zlib'); const { spawn } = require('child_process'); const { pipeline } = require('stream/promises'); +// Cria um arquivo tar POSIX em memória contendo um único arquivo. +// Usado para injetar o .snar no container sem depender do tar do sistema. +function buildSingleFileTar(filename, contentBuffer) { + const header = Buffer.alloc(512, 0); + Buffer.from(filename.slice(0, 100), 'ascii').copy(header, 0); + Buffer.from('0000644\0', 'ascii').copy(header, 100); // mode + Buffer.from('0000000\0', 'ascii').copy(header, 108); // uid + Buffer.from('0000000\0', 'ascii').copy(header, 116); // gid + Buffer.from(`${contentBuffer.length.toString(8).padStart(11, '0')} `, 'ascii').copy(header, 124); // size + Buffer.from(`${Math.floor(Date.now() / 1000).toString(8).padStart(11, '0')} `, 'ascii').copy(header, 136); // mtime + header[156] = 0x30; // type flag: regular file + Buffer.from('ustar\0', 'ascii').copy(header, 257); // magic + Buffer.from('00', 'ascii').copy(header, 263); // version + // Checksum: calcular com campo de checksum como espaços + for (let i = 148; i < 156; i++) header[i] = 0x20; + let sum = 0; + for (let i = 0; i < 512; i++) sum += header[i]; + Buffer.from(`${sum.toString(8).padStart(6, '0')}\0 `, 'ascii').copy(header, 148); + // Dados com padding para múltiplo de 512 + const paddedLength = Math.ceil(contentBuffer.length / 512) * 512 || 512; + const dataBlock = Buffer.alloc(paddedLength, 0); + contentBuffer.copy(dataBlock); + return Buffer.concat([header, dataBlock, Buffer.alloc(1024, 0)]); +} + +// Extrai o conteúdo do primeiro arquivo de um tar não comprimido em memória. +function extractFirstFileFromTar(tarBuffer) { + if (!tarBuffer || tarBuffer.length < 512) return null; + const sizeField = tarBuffer.slice(124, 136).toString('ascii').replace(/[\0 ]/g, '').trim(); + const size = parseInt(sizeField, 8); + if (!size || !Number.isFinite(size) || size <= 0 || size > tarBuffer.length - 512) return null; + return tarBuffer.slice(512, 512 + size); +} + function detectRunningInContainer() { if (fs.existsSync('/.dockerenv')) { return true; @@ -403,6 +437,40 @@ class DockerService { }); } + // Injeta um arquivo .snar local no container no caminho absoluto informado. + // Usa tar POSIX em memória, sem depender do tar do sistema. + async putSnarToContainer(containerId, localSnarPath, containerSnarPath) { + const content = await fsp.readFile(localSnarPath); + const filename = path.posix.basename(containerSnarPath); + const containerDir = path.posix.dirname(containerSnarPath); + const tarBuffer = buildSingleFileTar(filename, content); + const container = this.docker.getContainer(containerId); + await container.putArchive(Readable.from([tarBuffer]), { path: containerDir }); + } + + // Extrai o arquivo .snar do container e salva no caminho local informado. + // Retorna true se conseguiu, false se o arquivo não existe no container. + async getSnarFromContainer(containerId, containerSnarPath, localSnarPath) { + const container = this.docker.getContainer(containerId); + let archiveStream; + try { + archiveStream = await container.getArchive({ path: containerSnarPath }); + } catch { + return false; + } + const chunks = []; + await new Promise((resolve, reject) => { + archiveStream.on('data', (chunk) => chunks.push(chunk)); + archiveStream.on('end', resolve); + archiveStream.on('error', reject); + }); + const content = extractFirstFileFromTar(Buffer.concat(chunks)); + if (!content) return false; + await fsp.mkdir(path.dirname(localSnarPath), { recursive: true }); + await fsp.writeFile(localSnarPath, content); + return true; + } + async runHelper({ binds, cmd, onOutput }) { await this.ensureImage();