978 lines
34 KiB
JavaScript
978 lines
34 KiB
JavaScript
const path = require('path');
|
|
const fs = require('fs/promises');
|
|
const { randomUUID } = require('crypto');
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replace(/'/g, `"'"'`)}'`;
|
|
}
|
|
|
|
function slugify(value) {
|
|
return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
|
|
}
|
|
|
|
function formatStamp(date = new Date()) {
|
|
return date.toISOString().replace(/[:.]/g, '-');
|
|
}
|
|
|
|
function normalizeDockerHostPath(inputPath) {
|
|
const value = String(inputPath || '').trim();
|
|
if (/^[A-Za-z]:[\\/]/.test(value)) {
|
|
const drive = value[0].toLowerCase();
|
|
const suffix = value.slice(2).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
return `/run/desktop/mnt/host/${drive}/${suffix}`;
|
|
}
|
|
|
|
return value.replace(/\\/g, '/');
|
|
}
|
|
|
|
function normalizeContainerPath(inputPath) {
|
|
const value = String(inputPath || '').trim().replace(/\\/g, '/');
|
|
if (!value.startsWith('/')) {
|
|
throw new Error('Em execucao via Docker, o diretorio de backup deve ser absoluto dentro do container (ex.: /app/data/backups).');
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function normalizeMounts(containerInspect) {
|
|
return (containerInspect.Mounts || [])
|
|
.filter((mount) => mount.Type === 'bind' || mount.Type === 'volume')
|
|
.map((mount) => ({
|
|
type: mount.Type,
|
|
name: mount.Name || null,
|
|
source: mount.Source,
|
|
destination: mount.Destination,
|
|
rw: mount.RW,
|
|
}))
|
|
.sort((left, right) => {
|
|
const leftKey = `${left.destination}|${left.type}|${left.name || left.source}`;
|
|
const rightKey = `${right.destination}|${right.type}|${right.name || right.source}`;
|
|
return leftKey.localeCompare(rightKey);
|
|
});
|
|
}
|
|
|
|
function sameMountSignature(leftMounts, rightMounts) {
|
|
if (leftMounts.length !== rightMounts.length) {
|
|
return false;
|
|
}
|
|
|
|
return leftMounts.every((left, index) => {
|
|
const right = rightMounts[index];
|
|
return (
|
|
left.type === right.type
|
|
&& left.name === right.name
|
|
&& left.source === right.source
|
|
&& left.destination === right.destination
|
|
);
|
|
});
|
|
}
|
|
|
|
function getMountBindingSource(mount) {
|
|
return mount.type === 'volume' ? mount.name : mount.source;
|
|
}
|
|
|
|
function normalizeBackupScope(value) {
|
|
return value === 'container' ? 'container' : 'volumes';
|
|
}
|
|
|
|
function containerSnapshotPath(profileId, containerId, scope) {
|
|
return `/tmp/dockerbackup-${slugify(profileId)}-${containerId.slice(0, 12)}-${scope}.snar`;
|
|
}
|
|
|
|
function toContainerRelPath(absPath) {
|
|
return String(absPath || '').replace(/^\/+/, '') || '.';
|
|
}
|
|
|
|
function parseManifestLines(rawOutput) {
|
|
const map = new Map();
|
|
for (const line of String(rawOutput || '').split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
const [relativePath, sizeRaw, mtimeRaw, scopeRaw] = trimmed.split('|');
|
|
if (!relativePath) {
|
|
continue;
|
|
}
|
|
|
|
const key = scopeRaw
|
|
? `${toContainerRelPath(scopeRaw)}/${relativePath}`.replace(/\/\//g, '/')
|
|
: relativePath;
|
|
|
|
map.set(key, {
|
|
size: Number(sizeRaw || 0),
|
|
mtime: Number(mtimeRaw || 0),
|
|
});
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function calculateManifestDiff(beforeMap, afterMap) {
|
|
let deleted = 0;
|
|
let created = 0;
|
|
let modified = 0;
|
|
|
|
for (const [filePath, beforeEntry] of beforeMap.entries()) {
|
|
const afterEntry = afterMap.get(filePath);
|
|
if (!afterEntry) {
|
|
deleted += 1;
|
|
continue;
|
|
}
|
|
|
|
if (beforeEntry.size !== afterEntry.size || beforeEntry.mtime !== afterEntry.mtime) {
|
|
modified += 1;
|
|
}
|
|
}
|
|
|
|
for (const filePath of afterMap.keys()) {
|
|
if (!beforeMap.has(filePath)) {
|
|
created += 1;
|
|
}
|
|
}
|
|
|
|
return { deleted, created, modified };
|
|
}
|
|
|
|
class BackupService {
|
|
constructor({ dockerService, store }) {
|
|
this.dockerService = dockerService;
|
|
this.store = store;
|
|
}
|
|
|
|
async runProfile(profileId, options = {}) {
|
|
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
const profile = await this.store.getProfile(profileId);
|
|
if (!profile) {
|
|
throw new Error('Profile nao encontrado.');
|
|
}
|
|
|
|
if (!profile.containerIds.length) {
|
|
throw new Error('Selecione ao menos um container no profile.');
|
|
}
|
|
|
|
const effectiveMode = ['full', 'incremental'].includes(options.mode)
|
|
? options.mode
|
|
: (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
|
|
|
|
const backupScope = normalizeBackupScope(profile.backupScope);
|
|
|
|
const backupRun = {
|
|
id: randomUUID(),
|
|
profileId: profile.id,
|
|
profileName: profile.name,
|
|
createdAt: new Date().toISOString(),
|
|
mode: effectiveMode,
|
|
backupScope,
|
|
backupDir: profile.backupDir,
|
|
status: 'ok',
|
|
containers: [],
|
|
};
|
|
|
|
const progress = {
|
|
profileId: profile.id,
|
|
profileName: profile.name,
|
|
startedAt: backupRun.createdAt,
|
|
status: 'running',
|
|
overall: {
|
|
total: profile.containerIds.length,
|
|
completed: 0,
|
|
pending: profile.containerIds.length,
|
|
percent: 0,
|
|
},
|
|
currentContainer: null,
|
|
};
|
|
|
|
const emitProgress = () => {
|
|
onProgress(JSON.parse(JSON.stringify(progress)));
|
|
};
|
|
|
|
emitProgress();
|
|
|
|
for (const [index, containerId] of profile.containerIds.entries()) {
|
|
progress.currentContainer = {
|
|
containerId,
|
|
index: index + 1,
|
|
total: profile.containerIds.length,
|
|
containerName: null,
|
|
status: 'running',
|
|
step: 'iniciando',
|
|
message: 'Preparando backup do container.',
|
|
logs: [],
|
|
percent: 0,
|
|
file: {
|
|
current: 0,
|
|
total: 0,
|
|
currentFile: null,
|
|
percent: 0,
|
|
},
|
|
};
|
|
emitProgress();
|
|
|
|
const containerBackup = await this.backupContainer(profile, containerId, backupRun.createdAt, {
|
|
mode: effectiveMode,
|
|
backupScope,
|
|
onProgress: (containerProgress) => {
|
|
progress.currentContainer = {
|
|
...progress.currentContainer,
|
|
...containerProgress,
|
|
};
|
|
emitProgress();
|
|
},
|
|
});
|
|
|
|
backupRun.containers.push(containerBackup);
|
|
if (containerBackup.status !== 'ok') {
|
|
backupRun.status = 'partial';
|
|
}
|
|
|
|
progress.overall.completed += 1;
|
|
progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
|
|
progress.overall.percent = progress.overall.total
|
|
? Math.round((progress.overall.completed / progress.overall.total) * 100)
|
|
: 100;
|
|
progress.currentContainer = {
|
|
...(progress.currentContainer || {}),
|
|
status: containerBackup.status,
|
|
percent: 100,
|
|
};
|
|
emitProgress();
|
|
}
|
|
|
|
await this.store.addBackup(backupRun);
|
|
|
|
progress.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
|
|
progress.finishedAt = new Date().toISOString();
|
|
progress.currentContainer = null;
|
|
progress.overall.percent = 100;
|
|
progress.overall.pending = 0;
|
|
emitProgress();
|
|
|
|
return backupRun;
|
|
}
|
|
|
|
async backupContainer(profile, containerId, runDateIso, options = {}) {
|
|
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
const runMode = ['full', 'incremental'].includes(options.mode)
|
|
? options.mode
|
|
: (['full', 'incremental'].includes(profile.mode) ? profile.mode : 'full');
|
|
const backupScope = normalizeBackupScope(options.backupScope || profile.backupScope);
|
|
|
|
let inspect;
|
|
let mounts = [];
|
|
let containerName = containerId.slice(0, 12);
|
|
|
|
try {
|
|
inspect = await this.dockerService.inspectContainer(containerId);
|
|
mounts = normalizeMounts(inspect);
|
|
containerName = inspect.Name.replace(/^\//, '');
|
|
} catch (error) {
|
|
onProgress({ containerName, status: 'error', percent: 100, step: 'erro', message: error.message });
|
|
return {
|
|
containerId,
|
|
containerName,
|
|
status: 'error',
|
|
mode: runMode,
|
|
error: `Falha ao inspecionar container: ${error.message}`,
|
|
};
|
|
}
|
|
|
|
if (backupScope === 'volumes' && !mounts.length) {
|
|
onProgress({
|
|
containerName,
|
|
status: 'skipped',
|
|
step: 'concluido',
|
|
message: 'Container sem volumes elegiveis.',
|
|
percent: 100,
|
|
file: { current: 0, total: 0, currentFile: null, percent: 100 },
|
|
});
|
|
return {
|
|
containerId: inspect.Id,
|
|
containerName,
|
|
status: 'skipped',
|
|
mode: runMode,
|
|
message: 'Container sem volumes ou bind mounts elegiveis.',
|
|
};
|
|
}
|
|
|
|
const runInDocker = this.dockerService.isRunningInDocker();
|
|
const backupRoot = runInDocker
|
|
? normalizeContainerPath(profile.backupDir)
|
|
: normalizeDockerHostPath(profile.backupDir);
|
|
const safeContainerName = slugify(containerName);
|
|
const safeProfileName = slugify(profile.name);
|
|
const stamp = formatStamp(new Date(runDateIso));
|
|
const archiveRelativePath = path.posix.join(safeProfileName, safeContainerName, `${stamp}-${runMode}.tar.gz`);
|
|
const snapshotRelativePath = path.posix.join(safeProfileName, safeContainerName, 'latest.snar');
|
|
|
|
// Filter mounts to only those the user selected (if volumeSelections is defined for this container)
|
|
const selectedPaths = profile.volumeSelections?.[containerId] || profile.volumeSelections?.[inspect.Id];
|
|
const activeMounts = (backupScope === 'volumes' && selectedPaths?.length)
|
|
? mounts.filter((m) => selectedPaths.includes(m.destination))
|
|
: mounts;
|
|
|
|
if (backupScope === 'volumes' && !activeMounts.length) {
|
|
onProgress({
|
|
containerName,
|
|
status: 'skipped',
|
|
step: 'concluido',
|
|
message: 'Nenhum volume selecionado para backup.',
|
|
percent: 100,
|
|
file: { current: 0, total: 0, currentFile: null, percent: 100 },
|
|
});
|
|
return {
|
|
containerId: inspect.Id,
|
|
containerName,
|
|
status: 'skipped',
|
|
mode: runMode,
|
|
message: 'Nenhum volume selecionado para backup.',
|
|
};
|
|
}
|
|
|
|
const containerBackup = {
|
|
containerId: inspect.Id,
|
|
containerName,
|
|
backupScope,
|
|
backupPaths: backupScope === 'container' ? ['/'] : activeMounts.map((mount) => mount.destination),
|
|
mountSignature: mounts,
|
|
archiveRelativePath,
|
|
snapshotRelativePath,
|
|
wasRunning: inspect.State?.Running === true,
|
|
mode: runMode,
|
|
status: 'ok',
|
|
};
|
|
|
|
const logs = [];
|
|
const pushLog = (message, step = 'processando') => {
|
|
const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
|
|
logs.push(line);
|
|
while (logs.length > 40) {
|
|
logs.shift();
|
|
}
|
|
|
|
onProgress({
|
|
containerName,
|
|
step,
|
|
message,
|
|
logs: [...logs],
|
|
});
|
|
};
|
|
|
|
let fileTotal = 0;
|
|
let fileCurrent = 0;
|
|
const updateFileProgress = (currentFile = null) => {
|
|
const filePercent = fileTotal > 0 ? Math.min(100, Math.round((fileCurrent / fileTotal) * 100)) : 0;
|
|
onProgress({
|
|
containerName,
|
|
status: 'running',
|
|
step: 'processando',
|
|
percent: filePercent,
|
|
file: {
|
|
current: fileCurrent,
|
|
total: fileTotal,
|
|
currentFile,
|
|
percent: filePercent,
|
|
},
|
|
});
|
|
};
|
|
|
|
try {
|
|
if (backupScope === 'container' && !runInDocker) {
|
|
throw new Error('Backup do container inteiro requer app executando via Docker.');
|
|
}
|
|
|
|
pushLog(`Escopo selecionado: ${backupScope === 'container' ? 'container inteiro' : 'somente volumes'}.`, 'preparando');
|
|
|
|
if (runInDocker) {
|
|
await this.dockerService.ensureLocalDirectory(backupRoot);
|
|
pushLog(`Diretorio de backup pronto em ${backupRoot}.`, 'preparando');
|
|
|
|
const originalRunning = inspect.State?.Running === true;
|
|
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;
|
|
|
|
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\" -type f 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 snarInContainer = containerSnapshotPath(profile.id, containerId, backupScope);
|
|
const absoluteSnapshotPath = path.posix.join(backupRoot, snapshotRelativePath);
|
|
|
|
// Detecta se o container tem GNU tar (--listed-incremental é extensão GNU).
|
|
// 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 === 'incremental') {
|
|
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 fs.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',
|
|
'echo "__DBKP_TAR_BEGIN__" 1>&2',
|
|
];
|
|
|
|
// --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();
|
|
pushLog('Iniciando compactacao tar do container.', 'gerando-tar');
|
|
|
|
await this.dockerService.streamContainerCommandToFile(containerId, tarParts.join('; '), absoluteArchivePath, {
|
|
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: ${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.
|
|
if (hasGnuTar) {
|
|
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');
|
|
await this.dockerService.stopContainer(containerId).catch(() => null);
|
|
}
|
|
|
|
if (originalRunning) {
|
|
pushLog('Reiniciando container (estava ativo antes do backup).', 'finalizando');
|
|
await this.dockerService.startContainer(containerId).catch(() => null);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
if (backupScope === 'container') {
|
|
throw new Error('Backup de container inteiro sem Docker nativo nao e suportado.');
|
|
}
|
|
|
|
await this.dockerService.ensureHostDirectory(backupRoot);
|
|
const wasRunning = inspect.State?.Running === true;
|
|
if (wasRunning) {
|
|
await this.dockerService.stopContainer(containerId);
|
|
}
|
|
|
|
const binds = [`${backupRoot}:/backuproot`];
|
|
for (const [index, mount] of activeMounts.entries()) {
|
|
binds.push(`${getMountBindingSource(mount)}:/payload/m${index}:ro`);
|
|
}
|
|
|
|
const archivePath = `/backuproot/${archiveRelativePath}`;
|
|
const snapshotPath = `/backuproot/${snapshotRelativePath}`;
|
|
const parentDir = path.posix.dirname(archivePath);
|
|
const cmdParts = ['set -eu', `mkdir -p ${shellQuote(parentDir)}`];
|
|
if (runMode === 'full') {
|
|
cmdParts.push(`rm -f ${shellQuote(snapshotPath)}`);
|
|
}
|
|
cmdParts.push('TOTAL_FILES=$(find /payload -type f | wc -l | tr -d " ")');
|
|
cmdParts.push('echo "__DBKP_TOTAL_FILES__=${TOTAL_FILES}"');
|
|
cmdParts.push(`tar --listed-incremental=${shellQuote(snapshotPath)} -czvf ${shellQuote(archivePath)} -C /payload .`);
|
|
|
|
await this.dockerService.runHelper({
|
|
binds,
|
|
cmd: cmdParts.join(' && '),
|
|
onOutput: (line, stream) => {
|
|
const normalizedLine = String(line || '').trim();
|
|
if (!normalizedLine) {
|
|
return;
|
|
}
|
|
|
|
if (normalizedLine.startsWith('__DBKP_TOTAL_FILES__=')) {
|
|
const parsed = Number(normalizedLine.split('=')[1]);
|
|
fileTotal = Number.isFinite(parsed) ? parsed : 0;
|
|
updateFileProgress();
|
|
return;
|
|
}
|
|
|
|
if (stream === 'stdout' && !normalizedLine.startsWith('tar:')) {
|
|
fileCurrent += 1;
|
|
updateFileProgress(normalizedLine);
|
|
}
|
|
},
|
|
});
|
|
|
|
if (wasRunning) {
|
|
await this.dockerService.startContainer(containerId).catch(() => null);
|
|
}
|
|
|
|
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;
|
|
} catch (error) {
|
|
onProgress({
|
|
containerName,
|
|
status: 'error',
|
|
step: 'erro',
|
|
message: error.message,
|
|
percent: 100,
|
|
});
|
|
return {
|
|
...containerBackup,
|
|
status: 'error',
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async restoreBackup(profileId, backupId, options = {}) {
|
|
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
const selectedContainerIds = options.selectedContainerIds;
|
|
|
|
const profile = await this.store.getProfile(profileId);
|
|
if (!profile) {
|
|
throw new Error('Profile nao encontrado.');
|
|
}
|
|
|
|
const backupRun = await this.store.getBackup(backupId);
|
|
if (!backupRun || backupRun.profileId !== profileId) {
|
|
throw new Error('Backup nao encontrado para este profile.');
|
|
}
|
|
|
|
const allowedContainerIds = new Set(Array.isArray(selectedContainerIds) && selectedContainerIds.length
|
|
? selectedContainerIds
|
|
: backupRun.containers.map((item) => item.containerId));
|
|
|
|
const targets = backupRun.containers.filter((item) => item.status === 'ok' && allowedContainerIds.has(item.containerId));
|
|
if (!targets.length) {
|
|
throw new Error('Nenhum container valido foi selecionado para restore.');
|
|
}
|
|
|
|
const progress = {
|
|
profileId: profile.id,
|
|
profileName: profile.name,
|
|
operation: 'restore',
|
|
startedAt: new Date().toISOString(),
|
|
status: 'running',
|
|
overall: {
|
|
total: targets.length,
|
|
completed: 0,
|
|
pending: targets.length,
|
|
percent: 0,
|
|
},
|
|
currentContainer: null,
|
|
};
|
|
|
|
const emitProgress = () => {
|
|
onProgress(JSON.parse(JSON.stringify(progress)));
|
|
};
|
|
|
|
emitProgress();
|
|
|
|
const results = [];
|
|
|
|
for (const containerEntry of targets) {
|
|
const logs = [];
|
|
const pushLog = (message, step = 'restaurando') => {
|
|
const line = `[${new Date().toLocaleTimeString('pt-BR')}] ${message}`;
|
|
logs.push(line);
|
|
while (logs.length > 40) {
|
|
logs.shift();
|
|
}
|
|
|
|
progress.currentContainer = {
|
|
...(progress.currentContainer || {}),
|
|
message,
|
|
step,
|
|
logs: [...logs],
|
|
};
|
|
emitProgress();
|
|
};
|
|
|
|
progress.currentContainer = {
|
|
containerId: containerEntry.containerId,
|
|
containerName: containerEntry.containerName,
|
|
status: 'running',
|
|
step: 'preparando',
|
|
message: 'Preparando restauracao do container.',
|
|
logs: [],
|
|
percent: 0,
|
|
file: {
|
|
current: 0,
|
|
total: 0,
|
|
currentFile: null,
|
|
percent: 0,
|
|
},
|
|
};
|
|
emitProgress();
|
|
|
|
try {
|
|
const chain = await this.store.getBackupsForContainer(profileId, containerEntry.containerId, backupId);
|
|
if (!chain.length || chain[0].mode !== 'full') {
|
|
throw new Error(`Nao existe cadeia full + incremental valida para ${containerEntry.containerName}.`);
|
|
}
|
|
|
|
pushLog(`Cadeia de restore encontrada com ${chain.length} arquivo(s).`, 'preparando');
|
|
|
|
const restoreInfo = await this.restoreContainer(profile, containerEntry, chain, {
|
|
onProgress: (snapshot) => {
|
|
progress.currentContainer = {
|
|
...progress.currentContainer,
|
|
...snapshot,
|
|
};
|
|
emitProgress();
|
|
},
|
|
pushLog,
|
|
});
|
|
|
|
progress.currentContainer = {
|
|
...progress.currentContainer,
|
|
status: 'ok',
|
|
step: 'concluido',
|
|
message: 'Restore concluido com sucesso.',
|
|
percent: 100,
|
|
file: {
|
|
...(progress.currentContainer?.file || {}),
|
|
percent: 100,
|
|
},
|
|
};
|
|
emitProgress();
|
|
|
|
results.push({
|
|
containerId: containerEntry.containerId,
|
|
containerName: containerEntry.containerName,
|
|
status: 'ok',
|
|
stats: restoreInfo?.stats || null,
|
|
});
|
|
} catch (error) {
|
|
pushLog(`Falha no restore: ${error.message}`, 'erro');
|
|
progress.currentContainer = {
|
|
...progress.currentContainer,
|
|
status: 'error',
|
|
step: 'erro',
|
|
message: error.message,
|
|
percent: 100,
|
|
};
|
|
emitProgress();
|
|
|
|
results.push({
|
|
containerId: containerEntry.containerId,
|
|
containerName: containerEntry.containerName,
|
|
status: 'error',
|
|
error: error.message,
|
|
});
|
|
}
|
|
|
|
progress.overall.completed += 1;
|
|
progress.overall.pending = Math.max(0, progress.overall.total - progress.overall.completed);
|
|
progress.overall.percent = progress.overall.total
|
|
? Math.round((progress.overall.completed / progress.overall.total) * 100)
|
|
: 100;
|
|
emitProgress();
|
|
}
|
|
|
|
progress.status = results.every((item) => item.status === 'ok') ? 'completed' : 'completed-with-errors';
|
|
progress.finishedAt = new Date().toISOString();
|
|
progress.currentContainer = null;
|
|
progress.overall.percent = 100;
|
|
progress.overall.pending = 0;
|
|
emitProgress();
|
|
|
|
return {
|
|
backupId,
|
|
status: results.every((item) => item.status === 'ok') ? 'ok' : 'partial',
|
|
containers: results,
|
|
};
|
|
}
|
|
|
|
async restoreContainer(profile, targetEntry, chain, options = {}) {
|
|
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
const pushLog = typeof options.pushLog === 'function' ? options.pushLog : () => {};
|
|
|
|
const inspect = await this.dockerService.inspectContainer(targetEntry.containerId);
|
|
const backupScope = normalizeBackupScope(targetEntry.backupScope || profile.backupScope);
|
|
const currentMounts = normalizeMounts(inspect);
|
|
|
|
if (backupScope === 'volumes' && !sameMountSignature(targetEntry.mountSignature, currentMounts)) {
|
|
throw new Error(`Os mounts atuais do container ${targetEntry.containerName} nao batem com o backup selecionado.`);
|
|
}
|
|
|
|
const runInDocker = this.dockerService.isRunningInDocker();
|
|
const useNativeRestore = runInDocker;
|
|
|
|
if (useNativeRestore) {
|
|
const backupRoot = normalizeContainerPath(profile.backupDir);
|
|
const originalWasRunning = inspect.State?.Running === true;
|
|
const restoreStats = { deleted: 0, created: 0, modified: 0 };
|
|
|
|
try {
|
|
if (originalWasRunning) {
|
|
pushLog('Container ativo detectado. Parando antes do restore.', 'preparando');
|
|
await this.dockerService.stopContainer(targetEntry.containerId);
|
|
}
|
|
|
|
if (backupScope === 'volumes') {
|
|
const restorePaths = (chain[0]?.backupPaths && chain[0].backupPaths.length)
|
|
? chain[0].backupPaths
|
|
: currentMounts.map((mount) => mount.destination);
|
|
|
|
// Validar que todos os archives existem antes de tocar nos dados.
|
|
for (const entry of chain) {
|
|
await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
|
|
}
|
|
|
|
// Restaurar via Docker API (putArchive) — funciona com container parado.
|
|
// O archive foi gerado com -C / incluindo os caminhos relativos dos volumes,
|
|
// portanto o destino do putArchive e sempre /.
|
|
for (const [index, entry] of chain.entries()) {
|
|
const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
|
|
|
|
onProgress({
|
|
step: 'restaurando',
|
|
file: {
|
|
current: index + 1,
|
|
total: chain.length,
|
|
currentFile: entry.archiveRelativePath,
|
|
percent: Math.round(((index + 1) / chain.length) * 100),
|
|
},
|
|
percent: Math.round(((index + 1) / chain.length) * 100),
|
|
});
|
|
pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
|
|
|
|
await this.dockerService.putCompressedArchiveFromFile(
|
|
targetEntry.containerId,
|
|
'/',
|
|
absoluteArchivePath,
|
|
);
|
|
}
|
|
|
|
pushLog('Restore de volumes concluido.', 'finalizando');
|
|
} else {
|
|
// Escopo container inteiro.
|
|
for (const entry of chain) {
|
|
await fs.access(path.posix.join(backupRoot, entry.archiveRelativePath));
|
|
}
|
|
|
|
for (const [index, entry] of chain.entries()) {
|
|
const absoluteArchivePath = path.posix.join(backupRoot, entry.archiveRelativePath);
|
|
|
|
onProgress({
|
|
step: 'restaurando',
|
|
file: {
|
|
current: index + 1,
|
|
total: chain.length,
|
|
currentFile: entry.archiveRelativePath,
|
|
percent: Math.round(((index + 1) / chain.length) * 100),
|
|
},
|
|
percent: Math.round(((index + 1) / chain.length) * 100),
|
|
});
|
|
pushLog(`Aplicando arquivo ${index + 1}/${chain.length}: ${entry.archiveRelativePath}`, 'restaurando');
|
|
|
|
await this.dockerService.putCompressedArchiveFromFile(targetEntry.containerId, '/', absoluteArchivePath);
|
|
}
|
|
|
|
pushLog('Restore do container concluido.', 'finalizando');
|
|
}
|
|
} finally {
|
|
if (originalWasRunning) {
|
|
pushLog('Reiniciando container (estava ativo antes do restore).', 'finalizando');
|
|
await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
|
|
}
|
|
}
|
|
|
|
return { stats: restoreStats };
|
|
}
|
|
|
|
if (backupScope === 'container') {
|
|
throw new Error('Restore do container inteiro requer app executando via Docker.');
|
|
}
|
|
|
|
const backupRoot = normalizeDockerHostPath(profile.backupDir);
|
|
const wasRunning = inspect.State?.Running === true;
|
|
const binds = [`${backupRoot}:/backuproot:ro`];
|
|
for (const [index, mount] of currentMounts.entries()) {
|
|
binds.push(`${getMountBindingSource(mount)}:/restore/m${index}`);
|
|
}
|
|
|
|
const cleanupCommands = currentMounts.map((_mount, index) => (
|
|
`find ${shellQuote(`/restore/m${index}`)} -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +`
|
|
));
|
|
|
|
const restoreCommands = chain.map((entry) => (
|
|
`tar --listed-incremental=/dev/null -xzf ${shellQuote(`/backuproot/${entry.archiveRelativePath}`)} -C /restore`
|
|
));
|
|
|
|
try {
|
|
if (wasRunning) {
|
|
await this.dockerService.stopContainer(targetEntry.containerId);
|
|
}
|
|
|
|
const cmd = ['set -eu', ...cleanupCommands, ...restoreCommands].join(' && ');
|
|
await this.dockerService.runHelper({ binds, cmd });
|
|
} finally {
|
|
if (wasRunning) {
|
|
await this.dockerService.startContainer(targetEntry.containerId).catch(() => null);
|
|
}
|
|
}
|
|
|
|
return { stats: null };
|
|
}
|
|
}
|
|
|
|
module.exports = BackupService;
|