dockerbackup/src/dockerService.js

730 lines
24 KiB
JavaScript

const Docker = require('dockerode');
const { PassThrough, Readable } = 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');
// Cria um arquivo tar POSIX em memória contendo um único arquivo.
// Usado para injetar o .snar no container sem depender do tar do sistema.
function buildSingleFileTar(filename, contentBuffer) {
const header = Buffer.alloc(512, 0);
Buffer.from(filename.slice(0, 100), 'ascii').copy(header, 0);
Buffer.from('0000644\0', 'ascii').copy(header, 100); // mode
Buffer.from('0000000\0', 'ascii').copy(header, 108); // uid
Buffer.from('0000000\0', 'ascii').copy(header, 116); // gid
Buffer.from(`${contentBuffer.length.toString(8).padStart(11, '0')} `, 'ascii').copy(header, 124); // size
Buffer.from(`${Math.floor(Date.now() / 1000).toString(8).padStart(11, '0')} `, 'ascii').copy(header, 136); // mtime
header[156] = 0x30; // type flag: regular file
Buffer.from('ustar\0', 'ascii').copy(header, 257); // magic
Buffer.from('00', 'ascii').copy(header, 263); // version
// Checksum: calcular com campo de checksum como espaços
for (let i = 148; i < 156; i++) header[i] = 0x20;
let sum = 0;
for (let i = 0; i < 512; i++) sum += header[i];
Buffer.from(`${sum.toString(8).padStart(6, '0')}\0 `, 'ascii').copy(header, 148);
// Dados com padding para múltiplo de 512
const paddedLength = Math.ceil(contentBuffer.length / 512) * 512 || 512;
const dataBlock = Buffer.alloc(paddedLength, 0);
contentBuffer.copy(dataBlock);
return Buffer.concat([header, dataBlock, Buffer.alloc(1024, 0)]);
}
// Extrai o conteúdo do primeiro arquivo de um tar não comprimido em memória.
function extractFirstFileFromTar(tarBuffer) {
if (!tarBuffer || tarBuffer.length < 512) return null;
const sizeField = tarBuffer.slice(124, 136).toString('ascii').replace(/[\0 ]/g, '').trim();
const size = parseInt(sizeField, 8);
if (!size || !Number.isFinite(size) || size <= 0 || size > tarBuffer.length - 512) return null;
return tarBuffer.slice(512, 512 + size);
}
function detectRunningInContainer() {
if (fs.existsSync('/.dockerenv')) {
return true;
}
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();
this._selfMounts = null; // cache dos mounts do próprio container
}
isRunningInDocker() {
return this.runningInContainer;
}
// Retorna os mounts do container da própria aplicação.
// Lê /etc/hostname para obter o short ID, busca o container na lista e inspeciona.
async _getSelfMounts() {
if (this._selfMounts !== null) return this._selfMounts;
try {
const hostname = fs.readFileSync('/etc/hostname', 'utf8').trim();
const all = await this.docker.listContainers({ all: true });
const self = all.find((c) => c.Id.startsWith(hostname));
if (!self) { this._selfMounts = []; return []; }
const info = await this.docker.getContainer(self.Id).inspect();
this._selfMounts = info.Mounts || [];
} catch {
this._selfMounts = [];
}
return this._selfMounts;
}
// Dado um caminho absoluto dentro do container da app (ex: /app/data/backups),
// retorna { source, suffix } onde source é o bind/volume que cobre o caminho
// e suffix é o subpath dentro desse mount (ex: source='data_vol', suffix='/backups').
// Retorna null se não encontrar mount correspondente.
async getSelfBindSource(containerPath) {
const mounts = await this._getSelfMounts();
const normalized = containerPath.replace(/\/+$/, '');
// Ordena do mais específico para o mais genérico
const sorted = [...mounts].sort(
(a, b) => (b.Destination || '').length - (a.Destination || '').length
);
for (const mount of sorted) {
const dest = (mount.Destination || '').replace(/\/+$/, '');
if (normalized === dest || normalized.startsWith(dest + '/')) {
const source = mount.Type === 'volume' ? mount.Name : mount.Source;
const suffix = normalized.slice(dest.length) || '';
return { source, suffix };
}
}
return null;
}
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();
}
// Tenta iniciar o container. Se falhar por redes órfãs (network not found),
// desconecta as redes inexistentes e tenta iniciar novamente uma vez.
async repairAndStartContainer(containerId) {
try {
await this.startContainer(containerId);
return;
} catch (firstError) {
const msg = String(firstError.message || '');
const isNetworkError = /network.*not found|failed to set up container networking/i.test(msg);
if (!isNetworkError) {
throw firstError;
}
}
// Identifica as redes do container e remove as que não existem mais.
const inspect = await this.docker.getContainer(containerId).inspect();
const networkNames = Object.keys(inspect.NetworkSettings?.Networks || {});
let repaired = false;
for (const networkName of networkNames) {
try {
await this.docker.getNetwork(networkName).inspect();
} catch {
// Rede não existe — desconecta o container forçadamente.
try {
await this.docker.getNetwork(networkName).disconnect({ Container: containerId, Force: true });
repaired = true;
} catch {
// Ignora: a rede já foi removida do endpoint.
}
}
}
if (!repaired) {
// Tenta uma segunda vez mesmo sem reparação detectável.
}
await this.startContainer(containerId);
}
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', '-c', `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', '-c', 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 maxOkExitCode = typeof options.maxOkExitCode === 'number' ? options.maxOkExitCode : 0;
const container = this.docker.getContainer(containerId);
const exec = await container.exec({
AttachStdout: true,
AttachStderr: true,
Tty: false,
Cmd: ['sh', '-c', 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 > maxOkExitCode) {
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', '-c', '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', ['-c', 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);
});
});
}
// Retorna true se o container tem GNU tar (suporta --listed-incremental).
// Containers Alpine/BusyBox retornam false e devem usar --newer-mtime como fallback.
async containerHasGnuTar(containerId) {
try {
const output = await this.runContainerCommand(containerId, 'tar --version 2>/dev/null | head -1');
return /GNU tar/i.test(output);
} catch {
return false;
}
}
// Injeta um arquivo .snar local no container no caminho absoluto informado.
// Usa tar POSIX em memória, sem depender do tar do sistema.
async putSnarToContainer(containerId, localSnarPath, containerSnarPath) {
const content = await fsp.readFile(localSnarPath);
const filename = path.posix.basename(containerSnarPath);
const containerDir = path.posix.dirname(containerSnarPath);
const tarBuffer = buildSingleFileTar(filename, content);
const container = this.docker.getContainer(containerId);
await container.putArchive(Readable.from([tarBuffer]), { path: containerDir });
}
// Extrai o arquivo .snar do container e salva no caminho local informado.
// Retorna true se conseguiu, false se o arquivo não existe no container.
async getSnarFromContainer(containerId, containerSnarPath, localSnarPath) {
const container = this.docker.getContainer(containerId);
let archiveStream;
try {
archiveStream = await container.getArchive({ path: containerSnarPath });
} catch {
return false;
}
const chunks = [];
await new Promise((resolve, reject) => {
archiveStream.on('data', (chunk) => chunks.push(chunk));
archiveStream.on('end', resolve);
archiveStream.on('error', reject);
});
const content = extractFirstFileFromTar(Buffer.concat(chunks));
if (!content) return false;
await fsp.mkdir(path.dirname(localSnarPath), { recursive: true });
await fsp.writeFile(localSnarPath, content);
return true;
}
// Igual ao runHelper, mas redireciona stdout para um arquivo local.
// Usado para containers BusyBox (sem GNU tar): monta os volumes no helper que TEM GNU tar
// e gera o archive + .snar diretamente no diretório de backup.
async runHelperStreamToFile({ binds, cmd, targetFilePath, onOutput, maxOkExitCode = 0 }) {
await this.ensureImage();
const container = await this.docker.createContainer({
Image: this.helperImage,
Cmd: ['sh', '-c', 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);
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) {
if (typeof onOutput === 'function') onOutput(line, 'stderr');
}
});
stderrStream.on('end', () => {
if (buffer && typeof onOutput === 'function') onOutput(buffer, 'stderr');
resolve();
});
});
await container.start();
const result = await container.wait();
if (attachStream && typeof attachStream.destroy === 'function') {
attachStream.destroy();
}
stdoutStream.end();
stderrStream.end();
await Promise.all([
stderrDone,
new Promise((resolve, reject) => {
writeStream.on('finish', resolve);
writeStream.on('error', reject);
}),
]);
if (result.StatusCode > maxOkExitCode) {
throw new Error(`Helper (stream) terminou com codigo ${result.StatusCode}`);
}
} finally {
if (attachStream && typeof attachStream.destroy === 'function') {
try { attachStream.destroy(); } catch { /* ignore */ }
}
try { await container.remove({ force: true }); } catch { /* ignore */ }
}
}
async runHelper({ binds, cmd, onOutput, maxOkExitCode = 0 }) {
await this.ensureImage();
const container = await this.docker.createContainer({
Image: this.helperImage,
Cmd: ['sh', '-c', 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();
// Destruir o attachStream para que os PassThrough streams emitam 'end'
// e as outputPromises possam resolver. Sem isso o runHelper trava indefinidamente.
if (attachStream && typeof attachStream.destroy === 'function') {
attachStream.destroy();
}
stdoutStream.end();
stderrStream.end();
await Promise.all(outputPromises);
output = output.trim();
if (result.StatusCode > maxOkExitCode) {
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;