Implementa suporte para snapshots incrementais e gerenciamento de arquivos .snar no BackupService

This commit is contained in:
Alexander Sabino 2026-05-07 10:32:43 +01:00
parent f34ce0ead3
commit a8036507bb
2 changed files with 93 additions and 12 deletions

View File

@ -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');

View File

@ -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();