730 lines
24 KiB
JavaScript
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;
|