first commit

This commit is contained in:
Alexander Sabino 2026-05-04 17:15:51 +01:00
commit f8e9aca195
16 changed files with 4718 additions and 0 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
npm-debug.log
data/*.json
data/tmpnode_modules
npm-debug.log
data/store.json

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
data/
node_modules/

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY public ./public
COPY src ./src
#COPY data ./data
ENV PORT=3000
ENV DATA_DIR=/app/data
ENV HELPER_IMAGE=dockerbackup-app
EXPOSE 3000
CMD ["npm", "start"]

39
README.md Normal file
View File

@ -0,0 +1,39 @@
# Docker Backup App
⚠️ **AVISO CRÍTICO:** Esta é uma aplicação em estágio inicial de desenvolvimento, não use em produção de forma alguma, há risco de perda de dados ⚠️
Aplicacao web para cadastrar profiles de backup de containers Docker, executar backup full ou incremental e restaurar snapshots de volumes e bind mounts.
## Como funciona
- O app lista os containers via Docker socket.
- Cada backup processa um container por vez: para, executa o backup dos mounts elegiveis e sobe novamente se ele estava rodando.
- O backup usa um container auxiliar com GNU tar e `--listed-incremental` para gerar arquivos compactados `.tar.gz`.
- Quando o app roda dentro de Docker, backup e restore sao feitos via Docker API (`getArchive`/`putArchive`) sem criar helper e sem mapear o root do host.
- Ha dois escopos por profile: `somente volumes` (comportamento tradicional) e `container inteiro` (tar unico por container a partir de `/`).
- O restore aplica a cadeia full + incrementais sobre os mounts atuais do container, limpando o conteudo antes de reconstituir o snapshot escolhido.
- Ao restaurar um backup, e possivel escolher quais containers do backup serao restaurados.
## Requisitos
- Docker Engine com acesso ao socket em `/var/run/docker.sock`.
- O diretorio de backup informado no profile precisa ser visivel para o Docker daemon.
- Em Docker Desktop no Windows, quando o app roda fora de container, paths como `C:\backups` sao convertidos automaticamente para `/run/desktop/mnt/host/c/backups`.
- Quando o app roda dentro de container, use um caminho absoluto interno do container (ex.: `/app/data/backups`).
- O escopo `container inteiro` exige que o app esteja rodando em Docker para usar backup/restore nativos sem helper.
## Executando com Docker Compose
```bash
docker compose up --build
```
Abra `http://localhost:3000`.
O arquivo `docker-compose.example.yml` foi mantido como referencia equivalente ao compose principal.
## Observacoes
- O restore valida se o conjunto de mounts do container continua igual ao do backup selecionado.
- O catalogo de profiles e historico de backups fica salvo em `./data/store.json`.
- Os arquivos `.tar.gz` sao gravados no diretorio de backup configurado em cada profile.

View File

@ -0,0 +1,15 @@
services:
dockerbackup:
image: dockerbackup-app
build:
context: .
ports:
- "3000:3000"
environment:
PORT: 3000
DATA_DIR: /app/data
HELPER_IMAGE: dockerbackup-app
volumes:
- ./data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
services:
dockerbackup:
image: dockerbackup-app
build:
context: .
ports:
- "33000:3000"
environment:
PORT: 3000
DATA_DIR: /app/data
HELPER_IMAGE: dockerbackup-app
volumes:
- /mnt/f/ChromeDownloads/dockerbackup/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock
# restart: unless-stopped

1573
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "dockerbackup-app",
"version": "1.0.0",
"description": "Aplicacao web para backup e restauracao de volumes Docker",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"check": "node --check src/server.js && node --check src/config.js && node --check src/dockerService.js && node --check src/store.js && node --check src/backupService.js && node --check public/app.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"dockerode": "^4.0.7",
"express": "^4.21.2"
}
}

596
public/app.js Normal file
View File

@ -0,0 +1,596 @@
const state = {
containers: [],
profiles: [],
activeRuns: new Map(),
};
const elements = {
containerCount: document.querySelector('#containerCount'),
profileCount: document.querySelector('#profileCount'),
profileForm: document.querySelector('#profileForm'),
profileId: document.querySelector('#profileId'),
profileName: document.querySelector('#profileName'),
backupDir: document.querySelector('#backupDir'),
containerOptions: document.querySelector('#containerOptions'),
profilesList: document.querySelector('#profilesList'),
formModeBadge: document.querySelector('#formModeBadge'),
toast: document.querySelector('#toast'),
restoreModal: document.querySelector('#restoreModal'),
restoreModalSubtitle: document.querySelector('#restoreModalSubtitle'),
restoreContainerOptions: document.querySelector('#restoreContainerOptions'),
restoreModalConfirm: document.querySelector('#restoreModalConfirm'),
restoreModalClose: document.querySelector('#restoreModalClose'),
restoreModalSelectAll: document.querySelector('#restoreModalSelectAll'),
};
async function api(path, options = {}) {
const response = await fetch(path, {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
...options,
});
if (response.status === 204) {
return null;
}
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Falha na requisicao');
}
return payload;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function showToast(message, isError = false) {
elements.toast.textContent = message;
elements.toast.classList.remove('hidden', 'error');
if (isError) {
elements.toast.classList.add('error');
}
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => {
elements.toast.classList.add('hidden');
}, 3200);
}
function getSelectedContainerIds() {
return [...document.querySelectorAll('input[name="containerIds"]:checked')].map((input) => input.value);
}
function renderContainers() {
elements.containerCount.textContent = String(state.containers.length);
if (!state.containers.length) {
elements.containerOptions.innerHTML = '<p class="empty-state">Nenhum container encontrado.</p>';
return;
}
const selected = new Set(getSelectedContainerIds());
elements.containerOptions.innerHTML = state.containers.map((container) => `
<label class="container-option">
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
<span>
<strong>${escapeHtml(container.name)}</strong>
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}</small>
</span>
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
</label>
`).join('');
}
function backupButtons(profile) {
const isRunning = state.activeRuns.has(profile.id);
return `
<div class="run-actions">
<label class="mode-picker" for="runMode-${escapeHtml(profile.id)}">
<span>Modo do backup</span>
<select id="runMode-${escapeHtml(profile.id)}" data-run-mode="${escapeHtml(profile.id)}" class="mode-select" ${isRunning ? 'disabled' : ''}>
<option value="full">Full</option>
<option value="incremental">Incremental</option>
</select>
</label>
<div class="card-actions">
<button data-action="run" data-profile-id="${escapeHtml(profile.id)}" class="primary-button small" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Executando...' : 'Run'}</button>
<button data-action="edit" data-profile-id="${escapeHtml(profile.id)}" class="secondary-button small">Editar</button>
<button data-action="delete" data-profile-id="${escapeHtml(profile.id)}" class="ghost-button small">Excluir</button>
</div>
</div>
`;
}
function getRunMode(profileId) {
const selector = document.querySelector(`select[data-run-mode="${profileId}"]`);
const value = selector?.value;
return value === 'incremental' ? 'incremental' : 'full';
}
function getProfileScopeLabel(scope) {
return scope === 'container' ? 'container inteiro' : 'somente volumes';
}
function askRestoreContainerSelection(profile, backup) {
const restorable = (backup.containers || []).filter((item) => item.status === 'ok');
if (!restorable.length) {
throw new Error('Nao ha containers validos neste backup para restaurar.');
}
elements.restoreModalSubtitle.textContent = `${profile.name} - ${new Date(backup.createdAt).toLocaleString('pt-BR')}`;
elements.restoreContainerOptions.innerHTML = restorable.map((item) => `
<label class="modal-option">
<input type="checkbox" name="restoreContainerIds" value="${escapeHtml(item.containerId)}" checked />
<span>
<strong>${escapeHtml(item.containerName)}</strong>
<small>${escapeHtml(item.status)}</small>
</span>
</label>
`).join('');
elements.restoreModal.classList.remove('hidden');
elements.restoreModal.setAttribute('aria-hidden', 'false');
return new Promise((resolve, reject) => {
const closeModal = () => {
elements.restoreModal.classList.add('hidden');
elements.restoreModal.setAttribute('aria-hidden', 'true');
elements.restoreContainerOptions.innerHTML = '';
};
const cleanup = () => {
elements.restoreModalConfirm.removeEventListener('click', onConfirm);
elements.restoreModalClose.removeEventListener('click', onCancel);
elements.restoreModalSelectAll.removeEventListener('click', onSelectAll);
elements.restoreModal.removeEventListener('click', onBackdropClick);
};
const onConfirm = () => {
const selected = [...elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]:checked')]
.map((input) => input.value);
if (!selected.length) {
showToast('Selecione ao menos um container para restaurar.', true);
return;
}
cleanup();
closeModal();
resolve(selected);
};
const onCancel = () => {
cleanup();
closeModal();
resolve(null);
};
const onSelectAll = () => {
for (const input of elements.restoreContainerOptions.querySelectorAll('input[name="restoreContainerIds"]')) {
input.checked = true;
}
};
const onBackdropClick = (event) => {
const closeTrigger = event.target.closest('[data-action="close-restore-modal"]');
if (closeTrigger) {
onCancel();
}
};
elements.restoreModalConfirm.addEventListener('click', onConfirm);
elements.restoreModalClose.addEventListener('click', onCancel);
elements.restoreModalSelectAll.addEventListener('click', onSelectAll);
elements.restoreModal.addEventListener('click', onBackdropClick);
});
}
function formatBackupFailures(backup) {
const failures = (backup.containers || []).filter((item) => item.status === 'error');
if (!failures.length) {
return '';
}
return `
<small class="backup-error">
Falhas: ${failures.map((item) => `${escapeHtml(item.containerName || item.containerId || 'container')}: ${escapeHtml(item.error || 'erro desconhecido')}`).join(' | ')}
</small>
`;
}
function progressBar(percent) {
const normalized = Math.max(0, Math.min(100, Number(percent) || 0));
return `
<div class="progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${normalized}">
<span class="progress-fill" style="width: ${normalized}%;"></span>
</div>
`;
}
function renderRunProgress(profileId) {
const run = state.activeRuns.get(profileId);
const host = document.querySelector(`[data-run-progress="${profileId}"]`);
if (!host) {
return;
}
if (!run || !run.progress) {
host.innerHTML = '';
host.classList.add('hidden');
return;
}
const overall = run.progress.overall || { total: 0, completed: 0, pending: 0, percent: 0 };
const currentContainer = run.progress.currentContainer;
const file = currentContainer?.file || { current: 0, total: 0, percent: 0, currentFile: null };
const containerPercent = Number.isFinite(currentContainer?.percent) ? currentContainer.percent : 0;
const stepLabel = currentContainer?.step || 'aguardando';
const stepMessage = currentContainer?.message || 'Aguardando processamento de arquivo...';
const logs = Array.isArray(currentContainer?.logs) ? currentContainer.logs.slice(-8) : [];
const operation = run?.kind === 'restore' || run?.progress?.operation === 'restore' ? 'restore' : 'backup';
const operationTitle = operation === 'restore' ? 'Progresso do restore' : 'Progresso do backup';
host.classList.remove('hidden');
host.innerHTML = `
<div class="progress-card">
<div class="progress-header">
<strong>${escapeHtml(operationTitle)}</strong>
<small>${escapeHtml(run.status)}</small>
</div>
<div class="progress-block">
<div class="progress-label-row">
<span>Containers: ${escapeHtml(String(overall.completed))}/${escapeHtml(String(overall.total))} concluido(s)</span>
<span>Faltam ${escapeHtml(String(overall.pending))}</span>
</div>
${progressBar(overall.percent)}
</div>
<div class="progress-block">
<div class="progress-label-row">
<span>Container atual: ${escapeHtml(currentContainer?.containerName || currentContainer?.containerId || '-')}</span>
<span>${escapeHtml(String(Math.round(containerPercent)))}%</span>
</div>
${progressBar(containerPercent)}
</div>
<div class="progress-block">
<div class="progress-label-row">
<span>Arquivos: ${escapeHtml(String(file.current || 0))}/${escapeHtml(String(file.total || 0))}</span>
<span>${escapeHtml(String(Math.round(file.percent || 0)))}%</span>
</div>
${progressBar(file.percent || 0)}
<small class="current-file">Etapa: ${escapeHtml(stepLabel)} · ${escapeHtml(file.currentFile || stepMessage)}</small>
</div>
<div class="progress-block">
<div class="progress-label-row">
<span>Log detalhado</span>
<span>${escapeHtml(String(logs.length))} evento(s)</span>
</div>
<div class="progress-log">
${logs.length
? logs.map((line) => `<small>${escapeHtml(line)}</small>`).join('')
: '<small>Nenhum evento detalhado ainda.</small>'}
</div>
</div>
</div>
`;
}
function renderAllRunProgress() {
for (const profile of state.profiles) {
renderRunProgress(profile.id);
}
}
async function pollRun(profileId, runId) {
const doneStatus = new Set(['completed', 'completed-with-errors', 'error']);
while (true) {
const run = await api(`/api/runs/${runId}`);
state.activeRuns.set(profileId, run);
renderRunProgress(profileId);
if (doneStatus.has(run.status)) {
return run;
}
await new Promise((resolve) => {
window.setTimeout(resolve, 700);
});
}
}
function restoreButtons(profile, backups) {
if (!backups.length) {
return '<p class="empty-inline">Nenhum backup executado ainda.</p>';
}
return `
<div class="backup-history">
${backups.map((backup) => `
<button
class="backup-item"
data-action="restore"
data-profile-id="${escapeHtml(profile.id)}"
data-backup-id="${escapeHtml(backup.id)}"
>
<span>
<strong>${escapeHtml(new Date(backup.createdAt).toLocaleString('pt-BR'))}</strong>
<small>${escapeHtml(backup.mode)} · ${escapeHtml(getProfileScopeLabel(backup.backupScope))} · ${escapeHtml(backup.status)} · ${escapeHtml(backup.containers.map((item) => item.containerName).join(', '))}</small>
${formatBackupFailures(backup)}
</span>
<em>Restore</em>
</button>
`).join('')}
</div>
`;
}
async function renderProfiles() {
elements.profileCount.textContent = String(state.profiles.length);
if (!state.profiles.length) {
elements.profilesList.innerHTML = '<p class="empty-state">Nenhum profile salvo.</p>';
return;
}
const backupsByProfile = await Promise.all(
state.profiles.map(async (profile) => [profile.id, await api(`/api/profiles/${profile.id}/backups`)])
);
const backupMap = new Map(backupsByProfile);
elements.profilesList.innerHTML = state.profiles.map((profile) => `
<article class="profile-card">
<div class="profile-card-top">
<div>
<h3>${escapeHtml(profile.name)}</h3>
<p>${escapeHtml(String(profile.containerIds.length))} container(es) · ${escapeHtml(getProfileScopeLabel(profile.backupScope))}</p>
<code>${escapeHtml(profile.backupDir)}</code>
</div>
${backupButtons(profile)}
</div>
<div class="chips">
${profile.containerIds.map((containerId) => {
const container = state.containers.find((item) => item.id === containerId);
return `<span class="chip">${escapeHtml(container ? container.name : containerId.slice(0, 12))}</span>`;
}).join('')}
</div>
<div class="run-progress hidden" data-run-progress="${escapeHtml(profile.id)}"></div>
<div class="restore-block">
<h4>Restaurar</h4>
${restoreButtons(profile, backupMap.get(profile.id) || [])}
</div>
</article>
`).join('');
renderAllRunProgress();
}
function resetForm() {
elements.profileForm.reset();
elements.profileId.value = '';
elements.formModeBadge.textContent = 'criar';
renderContainers();
}
function fillForm(profile) {
elements.profileId.value = profile.id;
elements.profileName.value = profile.name;
elements.backupDir.value = profile.backupDir;
const backupScope = profile.backupScope === 'container' ? 'container' : 'volumes';
document.querySelector(`input[name="backupScope"][value="${backupScope}"]`).checked = true;
elements.formModeBadge.textContent = 'editar';
renderContainers();
for (const containerId of profile.containerIds) {
const input = document.querySelector(`input[name="containerIds"][value="${containerId}"]`);
if (input) {
input.checked = true;
}
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function loadContainers() {
state.containers = await api('/api/containers');
renderContainers();
}
async function loadProfiles() {
state.profiles = await api('/api/profiles');
await renderProfiles();
}
async function saveProfile(event) {
event.preventDefault();
const payload = {
id: elements.profileId.value || undefined,
name: elements.profileName.value,
backupDir: elements.backupDir.value,
containerIds: getSelectedContainerIds(),
backupScope: document.querySelector('input[name="backupScope"]:checked').value,
};
try {
await api('/api/profiles', {
method: 'POST',
body: JSON.stringify(payload),
});
await loadProfiles();
resetForm();
showToast('Profile salvo.');
} catch (error) {
showToast(error.message, true);
}
}
async function handleProfileAction(event) {
const button = event.target.closest('button[data-action]');
if (!button) {
return;
}
const { action, profileId, backupId } = button.dataset;
const profile = state.profiles.find((item) => item.id === profileId);
if (!profile) {
return;
}
try {
if (action === 'edit') {
fillForm(profile);
return;
}
if (action === 'delete') {
if (!window.confirm(`Excluir o profile ${profile.name}?`)) {
return;
}
await api(`/api/profiles/${profileId}`, { method: 'DELETE' });
await loadProfiles();
showToast('Profile removido.');
return;
}
if (action === 'run') {
button.disabled = true;
button.textContent = 'Executando...';
const mode = getRunMode(profileId);
const start = await api(`/api/profiles/${profileId}/run`, { method: 'POST', body: JSON.stringify({ mode }) });
state.activeRuns.set(profileId, {
id: start.runId,
profileId,
status: 'running',
progress: {
overall: { total: profile.containerIds.length, completed: 0, pending: profile.containerIds.length, percent: 0 },
currentContainer: null,
},
});
renderRunProgress(profileId);
const run = await pollRun(profileId, start.runId);
state.activeRuns.delete(profileId);
await loadProfiles();
if (run.status === 'error') {
showToast(run.error || 'Falha durante a execucao do backup.', true);
} else {
const backupStatus = run.result?.status;
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
if (failures.length) {
const details = failures
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
.join(' | ');
showToast(`Backup com falhas. ${details}`, true);
} else {
showToast(
backupStatus === 'ok' ? 'Backup concluido.' : 'Backup concluido com falhas parciais.',
backupStatus !== 'ok',
);
}
}
return;
}
if (action === 'restore') {
if (!window.confirm(`Restaurar o backup selecionado para o profile ${profile.name}?`)) {
return;
}
const backups = await api(`/api/profiles/${profileId}/backups`);
const selectedBackup = backups.find((item) => item.id === backupId);
if (!selectedBackup) {
throw new Error('Backup selecionado nao encontrado.');
}
const selectedContainerIds = await askRestoreContainerSelection(profile, selectedBackup);
if (!selectedContainerIds) {
return;
}
button.disabled = true;
button.textContent = 'Restaurando...';
const start = await api(`/api/profiles/${profileId}/restore`, {
method: 'POST',
body: JSON.stringify({ backupId, containerIds: selectedContainerIds }),
});
state.activeRuns.set(profileId, {
id: start.runId,
kind: 'restore',
profileId,
status: 'running',
progress: {
operation: 'restore',
overall: {
total: selectedContainerIds.length,
completed: 0,
pending: selectedContainerIds.length,
percent: 0,
},
currentContainer: null,
},
});
renderRunProgress(profileId);
const run = await pollRun(profileId, start.runId);
state.activeRuns.delete(profileId);
await loadProfiles();
if (run.status === 'error') {
showToast(run.error || 'Falha durante a execucao do restore.', true);
} else {
const restoreStatus = run.result?.status;
const restoreStatsLines = (run.result?.containers || [])
.filter((item) => item.status === 'ok' && item.stats)
.map((item) => `${item.containerName}: apagados ${item.stats.deleted}, criados ${item.stats.created}, modificados ${item.stats.modified}`);
const failures = (run.result?.containers || []).filter((item) => item.status === 'error');
if (failures.length) {
const details = failures
.map((item) => `${item.containerName || item.containerId || 'container'}: ${item.error || 'erro desconhecido'}`)
.join(' | ');
const statsSummary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
showToast(`Restore com falhas. ${details}.${statsSummary}`, true);
} else {
const summary = restoreStatsLines.length ? ` ${restoreStatsLines.join(' | ')}` : '';
showToast(
`${restoreStatus === 'ok' ? 'Restore concluido.' : 'Restore concluido com falhas parciais.'}${summary}`,
restoreStatus !== 'ok',
);
}
}
}
} catch (error) {
showToast(error.message, true);
} finally {
button.disabled = false;
}
}
async function init() {
try {
await Promise.all([loadContainers(), loadProfiles()]);
} catch (error) {
showToast(error.message, true);
}
}
elements.profileForm.addEventListener('submit', saveProfile);
document.querySelector('#refreshContainers').addEventListener('click', init);
document.querySelector('#reloadProfiles').addEventListener('click', loadProfiles);
document.querySelector('#clearForm').addEventListener('click', resetForm);
elements.profilesList.addEventListener('click', handleProfileAction);
init();

113
public/index.html Normal file
View File

@ -0,0 +1,113 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Docker Backup Profiles</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Docker volume backup</p>
<h1>Profiles de backup com parada controlada por container.</h1>
<p class="hero-copy">
O app usa a Docker API para parar cada container, gerar backup compactado dos mounts e religar apenas quem estava rodando antes.
</p>
</div>
<div class="hero-card">
<div>
<span class="metric-label">Containers detectados</span>
<strong id="containerCount">0</strong>
</div>
<div>
<span class="metric-label">Profiles salvos</span>
<strong id="profileCount">0</strong>
</div>
</div>
</header>
<main class="layout">
<section class="panel form-panel">
<div class="panel-header">
<h2>Novo profile</h2>
<span id="formModeBadge" class="badge">criar</span>
</div>
<form id="profileForm">
<input type="hidden" id="profileId" />
<label>
<span>Nome do profile</span>
<input id="profileName" name="name" type="text" placeholder="Backup banco principal" required />
</label>
<label>
<span>Diretorio de backup no host Docker</span>
<input id="backupDir" name="backupDir" type="text" placeholder="/srv/docker-backups" required />
</label>
<fieldset>
<legend>Escopo do backup</legend>
<div class="radio-grid">
<label class="radio-card">
<input type="radio" name="backupScope" value="volumes" checked />
<span>Somente volumes</span>
<small>mantem o comportamento atual de backup de volumes e binds</small>
</label>
<label class="radio-card">
<input type="radio" name="backupScope" value="container" />
<span>Container inteiro</span>
<small>gera um unico tar por container a partir de /</small>
</label>
</div>
</fieldset>
<div class="container-picker">
<div class="picker-header">
<h3>Containers</h3>
<button id="refreshContainers" type="button" class="secondary-button">Atualizar lista</button>
</div>
<div id="containerOptions" class="container-options"></div>
</div>
<div class="form-actions">
<button class="primary-button" type="submit">Salvar profile</button>
<button class="secondary-button" type="button" id="clearForm">Limpar</button>
</div>
</form>
</section>
<section class="panel list-panel">
<div class="panel-header">
<h2>Profiles salvos</h2>
<button id="reloadProfiles" type="button" class="secondary-button">Recarregar</button>
</div>
<div id="profilesList" class="profiles-list"></div>
</section>
</main>
</div>
<aside id="toast" class="toast hidden"></aside>
<div id="restoreModal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" data-action="close-restore-modal"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="restoreModalTitle">
<div class="modal-header">
<h3 id="restoreModalTitle">Selecionar containers para restore</h3>
<button id="restoreModalClose" class="ghost-button small" type="button">Fechar</button>
</div>
<p id="restoreModalSubtitle" class="modal-subtitle"></p>
<div id="restoreContainerOptions" class="modal-options"></div>
<div class="modal-actions">
<button id="restoreModalSelectAll" class="secondary-button" type="button">Marcar todos</button>
<button id="restoreModalConfirm" class="primary-button" type="button">Restaurar selecionados</button>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

548
public/styles.css Normal file
View File

@ -0,0 +1,548 @@
:root {
--bg: #f3efe6;
--bg-soft: #fffaf2;
--surface: rgba(255, 252, 245, 0.78);
--line: rgba(69, 55, 37, 0.14);
--text: #2e2418;
--muted: #7b6c58;
--accent: #156669;
--accent-strong: #0b4d50;
--danger: #a03838;
--shadow: 0 20px 60px rgba(63, 43, 17, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: 'Space Grotesk', sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(21, 102, 105, 0.18), transparent 30%),
radial-gradient(circle at 85% 20%, rgba(206, 131, 76, 0.18), transparent 22%),
linear-gradient(180deg, #fbf7ef 0%, #efe6d5 100%);
}
button,
input {
font: inherit;
}
.page-shell {
max-width: 1240px;
margin: 0 auto;
padding: 32px 20px 64px;
}
.hero {
display: grid;
grid-template-columns: 1.6fr 0.8fr;
gap: 24px;
margin-bottom: 28px;
align-items: stretch;
}
.eyebrow {
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
font-size: 0.78rem;
}
.hero h1 {
margin: 0;
font-size: clamp(2.2rem, 4vw, 4.4rem);
line-height: 0.96;
max-width: 10ch;
}
.hero-copy {
max-width: 64ch;
color: var(--muted);
font-size: 1.02rem;
margin-top: 18px;
}
.hero-card,
.panel,
.toast {
backdrop-filter: blur(14px);
background: var(--surface);
border: 1px solid var(--line);
box-shadow: var(--shadow);
}
.hero-card {
border-radius: 28px;
padding: 24px;
display: grid;
gap: 18px;
align-content: center;
}
.metric-label {
display: block;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.hero-card strong {
font-size: 2.6rem;
}
.layout {
display: grid;
grid-template-columns: minmax(360px, 420px) 1fr;
gap: 20px;
}
.panel {
border-radius: 28px;
padding: 22px;
}
.panel-header,
.picker-header,
.profile-card-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.panel-header h2,
.picker-header h3,
.profile-card h3,
.restore-block h4 {
margin: 0;
}
.badge,
.chip,
.state,
.profile-card code {
font-family: 'IBM Plex Mono', monospace;
}
.badge,
.chip {
border-radius: 999px;
padding: 6px 10px;
background: rgba(21, 102, 105, 0.1);
color: var(--accent-strong);
}
form,
.restore-block,
.profiles-list {
display: grid;
gap: 18px;
}
label,
fieldset {
display: grid;
gap: 8px;
border: 0;
padding: 0;
margin: 0;
}
label span,
legend {
font-weight: 700;
}
input[type='text'] {
width: 100%;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
}
.radio-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.radio-card,
.container-option,
.backup-item {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.65);
border-radius: 18px;
}
.radio-card {
padding: 14px;
}
.radio-card input {
margin-bottom: 10px;
}
.radio-card small,
.container-option small,
.profile-card p,
.empty-state,
.empty-inline {
color: var(--muted);
}
.container-options,
.chips,
.backup-history {
display: grid;
gap: 10px;
}
.container-option {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 14px;
}
.state {
text-transform: uppercase;
font-size: 0.78rem;
}
.state.running {
color: var(--accent-strong);
}
.state.exited,
.state.created {
color: var(--muted);
}
.primary-button,
.secondary-button,
.ghost-button,
.backup-item {
border: 0;
border-radius: 16px;
cursor: pointer;
transition: transform 180ms ease, opacity 180ms ease, background 180ms ease;
}
.primary-button,
.secondary-button,
.ghost-button {
padding: 12px 14px;
}
.small {
padding: 10px 12px;
font-size: 0.92rem;
}
.primary-button {
background: var(--accent);
color: white;
}
.secondary-button {
background: rgba(21, 102, 105, 0.12);
color: var(--accent-strong);
}
.ghost-button {
background: rgba(160, 56, 56, 0.08);
color: var(--danger);
}
.primary-button:hover,
.secondary-button:hover,
.ghost-button:hover,
.backup-item:hover {
transform: translateY(-1px);
}
.primary-button:disabled,
.secondary-button:disabled,
.ghost-button:disabled,
.backup-item:disabled {
opacity: 0.6;
cursor: wait;
}
.form-actions,
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.run-actions {
display: grid;
gap: 10px;
justify-items: end;
}
.mode-picker {
display: grid;
gap: 6px;
min-width: 180px;
}
.mode-picker span {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-weight: 700;
}
.mode-select {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
color: var(--text);
padding: 8px 10px;
}
.profile-card {
border: 1px solid var(--line);
border-radius: 22px;
padding: 18px;
background: rgba(255, 255, 255, 0.68);
display: grid;
gap: 16px;
}
.profile-card p,
.profile-card code {
margin: 6px 0 0;
}
.profile-card code {
display: inline-block;
padding: 4px 0;
}
.backup-item {
padding: 14px 16px;
display: flex;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.backup-item em {
align-self: center;
color: var(--accent-strong);
font-style: normal;
font-weight: 700;
}
.backup-error {
display: block;
margin-top: 6px;
color: var(--danger);
}
.run-progress {
display: grid;
}
.progress-card {
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
background: rgba(240, 252, 252, 0.72);
display: grid;
gap: 12px;
}
.progress-header,
.progress-label-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.progress-header small,
.progress-label-row,
.current-file {
color: var(--muted);
}
.progress-block {
display: grid;
gap: 7px;
}
.progress-track {
width: 100%;
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(21, 102, 105, 0.16);
}
.progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #17a2a8 0%, #156669 100%);
transition: width 220ms ease;
}
.current-file {
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-log {
max-height: 140px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.55);
padding: 8px;
display: grid;
gap: 4px;
}
.progress-log small {
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.76rem;
line-height: 1.35;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
max-width: 360px;
padding: 14px 18px;
border-radius: 18px;
}
.toast.error {
border-color: rgba(160, 56, 56, 0.24);
color: var(--danger);
}
.modal {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(20, 16, 10, 0.45);
}
.modal-card {
position: relative;
width: min(640px, calc(100vw - 24px));
max-height: min(80vh, 620px);
overflow: auto;
border-radius: 18px;
border: 1px solid var(--line);
background: var(--bg-soft);
box-shadow: var(--shadow);
padding: 18px;
display: grid;
gap: 12px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.modal-header h3,
.modal-subtitle {
margin: 0;
}
.modal-subtitle {
color: var(--muted);
}
.modal-options {
display: grid;
gap: 8px;
}
.modal-option {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.66);
}
.modal-option small {
color: var(--muted);
}
.modal-actions {
display: flex;
justify-content: space-between;
gap: 10px;
}
.hidden {
display: none;
}
@media (max-width: 960px) {
.hero,
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.page-shell {
padding-inline: 14px;
}
.panel,
.hero-card {
border-radius: 22px;
}
.radio-grid {
grid-template-columns: 1fr;
}
.container-option,
.profile-card-top,
.backup-item {
grid-template-columns: 1fr;
display: grid;
}
.run-actions,
.mode-picker {
justify-items: stretch;
}
}

850
src/backupService.js Normal file
View File

@ -0,0 +1,850 @@
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');
const containerBackup = {
containerId: inspect.Id,
containerName,
backupScope,
backupPaths: backupScope === 'container' ? ['/'] : mounts.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.startContainer(containerId);
tempStarted = true;
const sourcePaths = backupScope === 'container'
? ['/']
: mounts.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);
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');
}
}
const tarParts = [
'set -eu',
'umask 077',
'echo "__DBKP_TAR_BEGIN__" 1>&2',
];
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 .`
);
} else {
tarParts.push(
`tar --warning=no-file-changed --ignore-failed-read ${newerMtimeFlag} -czvf - -C / ${relSourcePaths.map((item) => shellQuote(item)).join(' ')}`
);
}
updateFileProgress();
pushLog('Iniciando compactacao tar do container.', 'gerando-tar');
await this.dockerService.streamContainerCommandToFile(containerId, tarParts.join(' && '), absoluteArchivePath, {
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');
} 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 mounts.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;

11
src/config.js Normal file
View File

@ -0,0 +1,11 @@
const path = require('path');
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
module.exports = {
port: Number(process.env.PORT || 3000),
dataDir,
storePath: path.join(dataDir, 'store.json'),
dockerSocketPath: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
helperImage: process.env.HELPER_IMAGE || 'node:20-bookworm-slim',
};

486
src/dockerService.js Normal file
View File

@ -0,0 +1,486 @@
const Docker = require('dockerode');
const { PassThrough } = require('stream');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const zlib = require('zlib');
const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
function detectRunningInContainer() {
if (fs.existsSync('/.dockerenv')) {
return true;
}
try {
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf8');
return /(docker|containerd|kubepods|cri-o)/i.test(cgroup);
} catch {
return false;
}
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `"'"'`)}'`;
}
class DockerService {
constructor({ socketPath, helperImage }) {
this.docker = new Docker({ socketPath });
this.helperImage = helperImage;
this.runningInContainer = detectRunningInContainer();
}
isRunningInDocker() {
return this.runningInContainer;
}
async listContainers() {
const containers = await this.docker.listContainers({ all: true });
return containers
.map((container) => ({
id: container.Id,
name: (container.Names[0] || '').replace(/^\//, ''),
image: container.Image,
state: container.State,
status: container.Status,
}))
.sort((left, right) => left.name.localeCompare(right.name));
}
async inspectContainer(containerId) {
return this.docker.getContainer(containerId).inspect();
}
async stopContainer(containerId) {
await this.docker.getContainer(containerId).stop();
}
async startContainer(containerId) {
await this.docker.getContainer(containerId).start();
}
async ensureImage(imageName = this.helperImage) {
try {
await this.docker.getImage(imageName).inspect();
return;
} catch {
const stream = await this.docker.pull(imageName);
await new Promise((resolve, reject) => {
this.docker.modem.followProgress(stream, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
async ensureHostDirectory(hostPath) {
const normalized = String(hostPath || '').trim().replace(/\\/g, '/');
if (!normalized || !normalized.startsWith('/')) {
throw new Error(`Diretorio de backup invalido: ${hostPath}`);
}
if (this.runningInContainer) {
throw new Error('App rodando em container nao deve criar helper para backup/restore.');
}
await this.ensureImage();
const container = await this.docker.createContainer({
Image: this.helperImage,
Cmd: ['sh', '-lc', `mkdir -p ${shellQuote(`/hostfs${normalized}`)}`],
Tty: false,
HostConfig: {
Binds: ['/:/hostfs'],
AutoRemove: false,
NetworkMode: 'none',
},
});
try {
await container.start();
const [result, logs] = await Promise.all([
container.wait(),
container.logs({ stdout: true, stderr: true, follow: false }),
]);
if (result.StatusCode !== 0) {
throw new Error(logs.toString('utf8').trim() || 'Falha ao criar diretorio de backup no host.');
}
} finally {
try {
await container.remove({ force: true });
} catch {
}
}
}
async ensureLocalDirectory(localPath) {
const normalized = String(localPath || '').trim().replace(/\\/g, '/');
if (!normalized || !normalized.startsWith('/')) {
throw new Error(`Diretorio de backup invalido: ${localPath}`);
}
await fsp.mkdir(normalized, { recursive: true });
}
async exportContainerPathArchive(containerId, containerPath, targetFilePath) {
const container = this.docker.getContainer(containerId);
const archiveStream = await container.getArchive({ path: containerPath });
await fsp.mkdir(path.dirname(targetFilePath), { recursive: true });
await pipeline(archiveStream, fs.createWriteStream(targetFilePath));
}
async putArchiveFromFile(containerId, destinationPath, sourceFilePath) {
const container = this.docker.getContainer(containerId);
const source = fs.createReadStream(sourceFilePath);
await container.putArchive(source, { path: destinationPath });
}
async putCompressedArchiveFromFile(containerId, destinationPath, sourceFilePath) {
const container = this.docker.getContainer(containerId);
const source = fs.createReadStream(sourceFilePath).pipe(zlib.createGunzip());
await container.putArchive(source, { path: destinationPath });
}
async runContainerCommand(containerId, cmd) {
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
AttachStdout: true,
AttachStderr: true,
Tty: false,
Cmd: ['sh', '-lc', cmd],
});
const stream = await exec.start({ hijack: true, stdin: false });
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
let output = '';
stdoutStream.on('data', (chunk) => {
output += chunk.toString('utf8');
});
stderrStream.on('data', (chunk) => {
output += chunk.toString('utf8');
});
let info = await exec.inspect();
while (info.Running) {
await new Promise((resolve) => setTimeout(resolve, 120));
info = await exec.inspect();
}
if (typeof stream.destroy === 'function') {
stream.destroy();
}
stdoutStream.end();
stderrStream.end();
const trimmed = output.trim();
if (info.ExitCode !== 0) {
throw new Error(output.trim() || `Comando em container terminou com codigo ${info.ExitCode}`);
}
return trimmed;
}
async streamContainerCommandToFile(containerId, cmd, targetFilePath, options = {}) {
const onOutput = typeof options.onOutput === 'function' ? options.onOutput : () => {};
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
AttachStdout: true,
AttachStderr: true,
Tty: false,
Cmd: ['sh', '-lc', cmd],
});
const stream = await exec.start({ hijack: true, stdin: false });
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
this.docker.modem.demuxStream(stream, 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) {
onOutput(line, 'stderr');
}
});
stderrStream.on('end', () => {
if (buffer) {
onOutput(buffer, 'stderr');
}
resolve();
});
});
let info = await exec.inspect();
while (info.Running) {
await new Promise((resolve) => setTimeout(resolve, 120));
info = await exec.inspect();
}
if (typeof stream.destroy === 'function') {
stream.destroy();
}
stdoutStream.end();
stderrStream.end();
await Promise.all([
stderrDone,
new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
}),
]);
if (info.ExitCode !== 0) {
throw new Error(`Comando de stream em container terminou com codigo ${info.ExitCode}`);
}
}
async copyFileToContainer(containerId, sourceFilePath, destinationDirInContainer) {
const container = this.docker.getContainer(containerId);
const tarProc = spawn('tar', ['-cf', '-', '-C', path.dirname(sourceFilePath), path.basename(sourceFilePath)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
tarProc.stderr.on('data', (chunk) => {
stderr += chunk.toString('utf8');
});
await container.putArchive(tarProc.stdout, { path: destinationDirInContainer });
await new Promise((resolve, reject) => {
tarProc.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `Falha ao empacotar arquivo para copia (codigo ${code})`));
});
tarProc.on('error', reject);
});
}
async streamArchiveToContainer(containerId, archiveFilePath) {
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
AttachStdin: true,
AttachStdout: false,
AttachStderr: true,
Tty: false,
Cmd: ['sh', '-lc', 'tar --listed-incremental=/dev/null -xzf - -C /'],
});
const attachStream = await exec.start({ hijack: true, stdin: true });
const stderrStream = new PassThrough();
this.docker.modem.demuxStream(attachStream, new PassThrough(), stderrStream);
let stderrOutput = '';
stderrStream.on('data', (chunk) => {
stderrOutput += chunk.toString('utf8');
});
// Pipar o arquivo diretamente para o stdin do tar no container
const fileReadStream = fs.createReadStream(archiveFilePath);
await new Promise((resolve, reject) => {
fileReadStream.on('error', reject);
fileReadStream.on('end', () => {
try { attachStream.end(); } catch { /* ignore */ }
resolve();
});
fileReadStream.pipe(attachStream, { end: false });
});
// Aguardar tar finalizar
let info = await exec.inspect();
while (info.Running) {
await new Promise((r) => setTimeout(r, 120));
info = await exec.inspect();
}
if (typeof attachStream.destroy === 'function') {
attachStream.destroy();
}
if (info.ExitCode !== 0) {
throw new Error(stderrOutput.trim() || `Falha ao restaurar archive no container (codigo ${info.ExitCode})`);
}
}
async copyFileFromContainer(containerId, containerFilePath, localFilePath) {
const container = this.docker.getContainer(containerId);
let archiveStream;
try {
archiveStream = await container.getArchive({ path: containerFilePath });
} catch {
return false;
}
await fsp.mkdir(path.dirname(localFilePath), { recursive: true });
await new Promise((resolve, reject) => {
const tarProc = spawn('tar', ['-xOf', '-'], { stdio: ['pipe', 'pipe', 'pipe'] });
const writeStream = fs.createWriteStream(localFilePath);
archiveStream.pipe(tarProc.stdin);
tarProc.stdout.pipe(writeStream);
tarProc.on('close', (code) => {
if (code === 0) { resolve(); return; }
reject(new Error(`Falha ao extrair arquivo do container (codigo ${code})`));
});
tarProc.on('error', reject);
writeStream.on('error', reject);
});
return true;
}
async runHostCommand({ cmd, onOutput }) {
await new Promise((resolve, reject) => {
const child = spawn('sh', ['-lc', cmd], { stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
const streamOutput = (stream, streamName) => {
let buffer = '';
stream.on('data', (chunk) => {
const text = chunk.toString('utf8');
output += text;
if (typeof onOutput !== 'function') {
return;
}
buffer += text;
const parts = buffer.split(/\r?\n/);
buffer = parts.pop() || '';
for (const line of parts) {
onOutput(line, streamName);
}
});
stream.on('end', () => {
if (buffer && typeof onOutput === 'function') {
onOutput(buffer, streamName);
}
});
};
streamOutput(child.stdout, 'stdout');
streamOutput(child.stderr, 'stderr');
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
const trimmedOutput = output.trim();
if (code !== 0) {
reject(new Error(trimmedOutput || `Comando local terminou com codigo ${code}`));
return;
}
resolve(trimmedOutput);
});
});
}
async runHelper({ binds, cmd, onOutput }) {
await this.ensureImage();
const container = await this.docker.createContainer({
Image: this.helperImage,
Cmd: ['sh', '-lc', 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();
const stderrStream = new PassThrough();
this.docker.modem.demuxStream(attachStream, stdoutStream, stderrStream);
const parseOutput = (stream, streamName) => new Promise((resolve) => {
let buffer = '';
stream.on('data', (chunk) => {
const text = chunk.toString('utf8');
output += text;
if (typeof onOutput !== 'function') {
return;
}
buffer += text;
const parts = buffer.split(/\r?\n/);
buffer = parts.pop() || '';
for (const line of parts) {
onOutput(line, streamName);
}
});
stream.on('end', () => {
if (buffer && typeof onOutput === 'function') {
onOutput(buffer, streamName);
}
resolve();
});
});
let output = '';
const outputPromises = [
parseOutput(stdoutStream, 'stdout'),
parseOutput(stderrStream, 'stderr'),
];
await container.start();
const result = await container.wait();
await Promise.all(outputPromises);
output = output.trim();
if (result.StatusCode !== 0) {
throw new Error(output || `Helper container terminou com codigo ${result.StatusCode}`);
}
return output;
} finally {
if (attachStream && typeof attachStream.destroy === 'function') {
attachStream.destroy();
}
try {
await container.remove({ force: true });
} catch {
}
}
}
}
module.exports = DockerService;

271
src/server.js Normal file
View File

@ -0,0 +1,271 @@
const express = require('express');
const path = require('path');
const fs = require('fs/promises');
const crypto = require('crypto');
const config = require('./config');
const JsonStore = require('./store');
const DockerService = require('./dockerService');
const BackupService = require('./backupService');
async function main() {
await fs.mkdir(config.dataDir, { recursive: true });
const store = new JsonStore(config.storePath);
await store.init();
const dockerService = new DockerService({
socketPath: config.dockerSocketPath,
helperImage: config.helperImage,
});
const backupService = new BackupService({ dockerService, store });
const runJobs = new Map();
const app = express();
app.use(express.json());
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('/api/health', (_request, response) => {
response.json({ ok: true });
});
app.get('/api/containers', async (_request, response) => {
try {
const containers = await dockerService.listContainers();
response.json(containers);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.get('/api/profiles', async (_request, response) => {
try {
const profiles = await store.listProfiles();
response.json(profiles);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.post('/api/profiles', async (request, response) => {
try {
const payload = request.body || {};
if (!payload.name || !payload.backupDir || !Array.isArray(payload.containerIds) || !payload.containerIds.length) {
response.status(400).json({ error: 'Informe nome, diretorio de backup e ao menos um container.' });
return;
}
if (payload.backupScope && !['volumes', 'container'].includes(payload.backupScope)) {
response.status(400).json({ error: 'Tipo de backup invalido.' });
return;
}
const existing = payload.id ? await store.getProfile(payload.id) : null;
const profile = await store.saveProfile({
id: payload.id,
createdAt: existing?.createdAt,
name: payload.name.trim(),
backupDir: payload.backupDir.trim(),
containerIds: payload.containerIds,
mode: existing?.mode || 'full',
backupScope: payload.backupScope || existing?.backupScope || 'volumes',
});
response.status(payload.id ? 200 : 201).json(profile);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.delete('/api/profiles/:profileId', async (request, response) => {
try {
const profile = await store.getProfile(request.params.profileId);
await store.deleteProfile(request.params.profileId);
if (profile?.backupDir) {
const slugify = (value) => value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'item';
const profileBackupDir = path.join(profile.backupDir, slugify(profile.name));
await fs.rm(profileBackupDir, { recursive: true, force: true });
}
response.status(204).end();
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.get('/api/profiles/:profileId/backups', async (request, response) => {
try {
const backups = await store.listBackups(request.params.profileId);
response.json(backups);
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.post('/api/profiles/:profileId/run', async (request, response) => {
try {
const profileId = request.params.profileId;
const requestedMode = request.body?.mode;
if (requestedMode && !['full', 'incremental'].includes(requestedMode)) {
response.status(400).json({ error: 'Modo de backup invalido.' });
return;
}
const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
if (runningJob) {
response.status(409).json({ error: 'Ja existe um backup em execucao para este profile.', runId: runningJob.id });
return;
}
const runId = crypto.randomUUID();
const job = {
id: runId,
profileId,
kind: 'backup',
status: 'running',
startedAt: new Date().toISOString(),
progress: null,
result: null,
error: null,
};
runJobs.set(runId, job);
void backupService.runProfile(profileId, {
mode: requestedMode,
onProgress: (progressSnapshot) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.progress = progressSnapshot;
},
}).then((backupRun) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.status = backupRun.status === 'ok' ? 'completed' : 'completed-with-errors';
currentJob.result = backupRun;
currentJob.finishedAt = new Date().toISOString();
}).catch((error) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.status = 'error';
currentJob.error = error.message;
currentJob.finishedAt = new Date().toISOString();
});
response.status(202).json({ runId, status: 'running' });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.get('/api/runs/:runId', (request, response) => {
const job = runJobs.get(request.params.runId);
if (!job) {
response.status(404).json({ error: 'Execucao nao encontrada.' });
return;
}
response.json({
id: job.id,
profileId: job.profileId,
kind: job.kind || 'backup',
status: job.status,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
progress: job.progress,
result: job.result,
error: job.error,
});
});
app.post('/api/profiles/:profileId/restore', async (request, response) => {
try {
const profileId = request.params.profileId;
if (!request.body?.backupId) {
response.status(400).json({ error: 'Informe o backup a ser restaurado.' });
return;
}
const selectedContainerIds = request.body?.containerIds;
if (selectedContainerIds && (!Array.isArray(selectedContainerIds) || !selectedContainerIds.length)) {
response.status(400).json({ error: 'Selecione ao menos um container para restaurar.' });
return;
}
const runningJob = [...runJobs.values()].find((job) => job.profileId === profileId && job.status === 'running');
if (runningJob) {
response.status(409).json({ error: 'Ja existe uma execucao em andamento para este profile.', runId: runningJob.id });
return;
}
const runId = crypto.randomUUID();
const job = {
id: runId,
profileId,
kind: 'restore',
status: 'running',
startedAt: new Date().toISOString(),
progress: null,
result: null,
error: null,
};
runJobs.set(runId, job);
void backupService.restoreBackup(profileId, request.body.backupId, {
selectedContainerIds,
onProgress: (progressSnapshot) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.progress = progressSnapshot;
},
}).then((restoreResult) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.status = restoreResult.status === 'ok' ? 'completed' : 'completed-with-errors';
currentJob.result = restoreResult;
currentJob.finishedAt = new Date().toISOString();
}).catch((error) => {
const currentJob = runJobs.get(runId);
if (!currentJob) {
return;
}
currentJob.status = 'error';
currentJob.error = error.message;
currentJob.finishedAt = new Date().toISOString();
});
response.status(202).json({ runId, status: 'running', kind: 'restore' });
} catch (error) {
response.status(500).json({ error: error.message });
}
});
app.listen(config.port, () => {
console.log(`Docker Backup app ouvindo na porta ${config.port}`);
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

157
src/store.js Normal file
View File

@ -0,0 +1,157 @@
const fs = require('fs/promises');
const path = require('path');
const { randomUUID } = require('crypto');
class JsonStore {
constructor(filePath) {
this.filePath = filePath;
this.writeQueue = Promise.resolve();
}
async init() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
try {
await fs.access(this.filePath);
} catch {
await fs.writeFile(this.filePath, JSON.stringify({ profiles: [], backups: [] }, null, 2));
}
}
async read() {
const raw = await fs.readFile(this.filePath, 'utf8');
const parsed = JSON.parse(raw);
parsed.profiles ||= [];
parsed.backups ||= [];
return parsed;
}
async write(mutator) {
this.writeQueue = this.writeQueue.then(async () => {
const current = await this.read();
const next = await mutator(current);
await fs.writeFile(this.filePath, JSON.stringify(next, null, 2));
return next;
});
return this.writeQueue;
}
async listProfiles() {
const data = await this.read();
return data.profiles;
}
async getProfile(profileId) {
const data = await this.read();
return data.profiles.find((profile) => profile.id === profileId) || null;
}
async saveProfile(profileInput) {
const now = new Date().toISOString();
const profile = {
id: profileInput.id || randomUUID(),
name: profileInput.name,
containerIds: profileInput.containerIds,
mode: profileInput.mode,
backupScope: profileInput.backupScope || 'volumes',
backupDir: profileInput.backupDir,
updatedAt: now,
createdAt: profileInput.createdAt || now,
};
await this.write((data) => {
const index = data.profiles.findIndex((item) => item.id === profile.id);
if (index >= 0) {
data.profiles[index] = profile;
} else {
data.profiles.push(profile);
}
return data;
});
return profile;
}
async deleteProfile(profileId) {
await this.write((data) => {
data.profiles = data.profiles.filter((profile) => profile.id !== profileId);
data.backups = data.backups.filter((backup) => backup.profileId !== profileId);
return data;
});
}
async addBackup(backupRun) {
await this.write((data) => {
data.backups.unshift(backupRun);
return data;
});
return backupRun;
}
async listBackups(profileId) {
const data = await this.read();
return data.backups.filter((backup) => backup.profileId === profileId);
}
async getBackup(backupId) {
const data = await this.read();
return data.backups.find((backup) => backup.id === backupId) || null;
}
async getBackupsForContainer(profileId, containerId, upToBackupId) {
const backups = await this.listBackups(profileId);
const ordered = backups
.slice()
.sort((left, right) => new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime());
const chain = [];
for (const backup of ordered) {
const containerBackup = backup.containers.find(
(item) => item.containerId === containerId && item.status === 'ok' && item.archiveRelativePath,
);
if (!containerBackup) {
if (backup.id === upToBackupId) {
return chain;
}
continue;
}
if (containerBackup.mode === 'full') {
chain.length = 0;
}
chain.push({
backupId: backup.id,
createdAt: backup.createdAt,
...containerBackup,
});
if (backup.id === upToBackupId) {
return chain;
}
}
return [];
}
async getLastContainerBackupTime(profileId, containerId) {
const backups = await this.listBackups(profileId);
const ordered = backups
.slice()
.sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime());
for (const backup of ordered) {
const found = backup.containers.find(
(item) => item.containerId === containerId && item.status === 'ok',
);
if (found) {
return backup.createdAt;
}
}
return null;
}
}
module.exports = JsonStore;