import { exec as execCb } from 'child_process'; import { promisify } from 'util'; import http from 'http'; import { logger } from '../utils/logger'; const exec = promisify(execCb); const EXEC_TIMEOUT = 120_000; // 2 minutes const DOCKER_SOCKET = '/var/run/docker.sock'; /** Validate a service/project name to prevent shell injection. */ function validateName(name: string, label: string): string { if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { throw new Error(`Invalid ${label}: ${name}`); } return name; } /** Validate a Docker duration format (e.g., "1h", "30m", "24h"). */ function validateDuration(value: string): string { if (!/^\d+[smhd]$/.test(value)) { throw new Error(`Invalid duration: ${value}`); } return value; } /** Validate a tail count (positive integer, capped). */ function validateTail(value: number): number { const n = Math.max(1, Math.min(value, 5000)); return Math.floor(n); } /** Parsed container status from `docker compose ps --format json` */ export interface ContainerInfo { name: string; service: string; status: string; state: string; health: string; ports: string; createdAt: string; exitCode: number; } /** * Execute a shell command with timeout and proper error handling. */ async function execCmd( command: string, cwd: string, timeoutMs = EXEC_TIMEOUT ): Promise<{ stdout: string; stderr: string }> { logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`); try { const result = await exec(command, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, // 10MB env: { ...process.env, COMPOSE_ANSI: 'never' }, }); return result; } catch (err: unknown) { const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean }; if (error.killed) { throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`); } // Include stderr in the error for debugging const msg = error.stderr || error.message || 'Unknown exec error'; throw new Error(`Command failed: ${command}\n${msg}`); } } /** * Build the base compose command with project name. */ function composeCmd(project: string): string { return `docker compose -p ${validateName(project, 'project')}`; } // ─── Docker Compose CLI Operations ─────────────────────────────────── export async function composeUp( projectDir: string, project: string, services?: string[] ): Promise { const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : ''; const orphanFlag = services?.length ? '' : ' --remove-orphans'; const { stdout, stderr } = await execCmd( `${composeCmd(project)} up -d${orphanFlag}${svc}`, projectDir ); return stdout || stderr; } export async function composeDown( projectDir: string, project: string, removeVolumes = false ): Promise { const flags = removeVolumes ? ' -v' : ''; const { stdout, stderr } = await execCmd( `${composeCmd(project)} down${flags}`, projectDir ); return stdout || stderr; } export async function composeStop( projectDir: string, project: string ): Promise { const { stdout, stderr } = await execCmd( `${composeCmd(project)} stop`, projectDir ); return stdout || stderr; } export async function composeRestart( projectDir: string, project: string, service?: string ): Promise { const svc = service ? ` ${validateName(service, 'service')}` : ''; const { stdout, stderr } = await execCmd( `${composeCmd(project)} restart${svc}`, projectDir ); return stdout || stderr; } export async function composePull( projectDir: string, project: string ): Promise { const { stdout, stderr } = await execCmd( `${composeCmd(project)} pull`, projectDir, 300_000 // 5 min for pulls ); return stdout || stderr; } export async function composeBuild( projectDir: string, project: string ): Promise { const { stdout, stderr } = await execCmd( `${composeCmd(project)} build`, projectDir, 600_000 // 10 min for builds ); return stdout || stderr; } /** * List containers with status. Returns parsed container info. */ export async function composePs( projectDir: string, project: string ): Promise { const { stdout } = await execCmd( `${composeCmd(project)} ps --format json`, projectDir ); if (!stdout.trim()) return []; // docker compose ps --format json outputs one JSON object per line const containers: ContainerInfo[] = []; for (const line of stdout.trim().split('\n')) { if (!line.trim()) continue; try { const raw = JSON.parse(line); containers.push({ name: raw.Name || raw.name || '', service: raw.Service || raw.service || '', status: raw.Status || raw.status || '', state: raw.State || raw.state || '', health: raw.Health || raw.health || '', ports: raw.Ports || raw.ports || '', createdAt: raw.CreatedAt || raw.created_at || '', exitCode: raw.ExitCode ?? raw.exit_code ?? 0, }); } catch { logger.warn(`[docker] Failed to parse container line: ${line}`); } } return containers; } /** * Get logs from a specific service. */ export async function composeLogs( projectDir: string, project: string, service?: string, tail = 200, since?: string ): Promise { const parts = [composeCmd(project), 'logs', '--no-color']; if (tail > 0) parts.push(`--tail=${validateTail(tail)}`); if (since) parts.push(`--since=${validateDuration(since)}`); if (service) parts.push(validateName(service, 'service')); const { stdout, stderr } = await execCmd(parts.join(' '), projectDir); return stdout || stderr; } /** * Execute a command inside a running service container. * Optionally pass environment variables via -e flags. */ export async function composeExec( projectDir: string, project: string, service: string, command: string, timeoutMs = EXEC_TIMEOUT, envVars?: Record ): Promise { const envFlags = envVars ? Object.entries(envVars).map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`).join(' ') + ' ' : ''; const { stdout, stderr } = await execCmd( `${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`, projectDir, timeoutMs ); return stdout || stderr; } /** * Run a one-off command in a service container (docker compose run). * Uses --entrypoint "" to skip the service's entrypoint script. * Useful for running setup commands (prisma db push, seed) without the entrypoint. */ export async function composeRun( projectDir: string, project: string, service: string, command: string, timeoutMs = EXEC_TIMEOUT ): Promise { const { stdout, stderr } = await execCmd( `${composeCmd(project)} run --rm --no-deps -T --entrypoint "" ${validateName(service, 'service')} ${command}`, projectDir, timeoutMs ); return stdout || stderr; } // ─── Docker Socket API ─────────────────────────────────────────────── /** * Make a request to the Docker Engine API via Unix socket. */ function dockerSocketRequest(path: string): Promise { return new Promise((resolve, reject) => { const req = http.request( { socketPath: DOCKER_SOCKET, path, method: 'GET', headers: { 'Content-Type': 'application/json' }, }, (res) => { let data = ''; res.on('data', (chunk) => (data += chunk)); res.on('end', () => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`Docker API returned ${res.statusCode}: ${data}`)); } else { resolve(data); } }); } ); req.on('error', reject); req.setTimeout(10_000, () => { req.destroy(); reject(new Error('Docker socket request timed out')); }); req.end(); }); } /** * Get status of a specific container by name via Docker socket API. */ export async function getContainerStatus( containerName: string ): Promise<{ state: string; health: string; running: boolean } | null> { try { const data = await dockerSocketRequest( `/containers/${encodeURIComponent(containerName)}/json` ); const info = JSON.parse(data); return { state: info.State?.Status || 'unknown', health: info.State?.Health?.Status || 'none', running: info.State?.Running === true, }; } catch { return null; } } /** * Poll until a container reaches healthy state or timeout expires. */ export async function waitForHealthy( containerName: string, timeoutMs = 60_000, pollIntervalMs = 2_000 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const status = await getContainerStatus(containerName); if (status?.health === 'healthy') return true; if (status?.state === 'exited' || status?.state === 'dead') { throw new Error(`Container ${containerName} exited unexpectedly`); } await new Promise((r) => setTimeout(r, pollIntervalMs)); } throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`); } /** * Wait for an HTTP endpoint to respond with 200. */ export async function waitForHttp( url: string, timeoutMs = 120_000, pollIntervalMs = 3_000 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); if (response.ok) return true; } catch { // Expected while service is starting } await new Promise((r) => setTimeout(r, pollIntervalMs)); } throw new Error(`HTTP endpoint ${url} did not respond within ${timeoutMs}ms`); }