/** * Docker Client * * Thin wrapper around Docker CLI via execa for preview lifecycle management. * No SDK dependency — uses `docker compose` subprocess calls. */ import { execa } from 'execa'; import { createModuleLogger } from '../logger/index.js'; import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js'; const log = createModuleLogger('preview:docker'); /** * Service status from `docker compose ps`. */ export interface ServiceStatus { name: string; state: string; health: string; } /** * Compose project from `docker compose ls`. */ export interface ComposeProject { Name: string; Status: string; ConfigFiles: string; } /** * Check if Docker is available and running. */ export async function isDockerAvailable(): Promise { try { await execa('docker', ['info'], { timeout: 10000 }); return true; } catch { return false; } } /** * Ensure a Docker network exists. Creates it if missing. */ export async function ensureDockerNetwork(name: string): Promise { try { await execa('docker', ['network', 'create', '--driver', 'bridge', name], { timeout: 15000, }); log.info({ name }, 'created docker network'); } catch (error) { // Ignore "already exists" error if ((error as Error).message?.includes('already exists')) { log.debug({ name }, 'docker network already exists'); return; } throw error; } } /** * Remove a Docker network. Ignores errors (e.g., network in use or not found). */ export async function removeDockerNetwork(name: string): Promise { try { await execa('docker', ['network', 'rm', name], { timeout: 15000 }); log.info({ name }, 'removed docker network'); } catch { log.debug({ name }, 'failed to remove docker network (may not exist or be in use)'); } } /** * Check if a Docker network exists. */ export async function dockerNetworkExists(name: string): Promise { try { await execa('docker', ['network', 'inspect', name], { timeout: 15000 }); return true; } catch { return false; } } /** * Start a compose project (build and run in background). */ export async function composeUp(composePath: string, projectName: string): Promise { log.info({ composePath, projectName }, 'starting compose project'); const cwd = composePath.substring(0, composePath.lastIndexOf('/')); await execa('docker', [ 'compose', '-p', projectName, '-f', composePath, 'up', '--build', '-d', ], { cwd, timeout: 600000, // 10 minutes for builds }); } /** * Stop and remove a compose project. */ export async function composeDown(projectName: string): Promise { log.info({ projectName }, 'stopping compose project'); await execa('docker', [ 'compose', '-p', projectName, 'down', '--volumes', '--remove-orphans', ], { timeout: 60000, }); } /** * Execute a command inside a running service container. * Used for seed commands (migrations, fixture loading, etc.) after health checks pass. */ export async function execInContainer( projectName: string, serviceName: string, command: string, ): Promise<{ stdout: string; stderr: string }> { const result = await execa('docker', [ 'compose', '-p', projectName, 'exec', '-T', serviceName, 'sh', '-c', command, ], { timeout: 300000 }); // 5 min per seed command return { stdout: result.stdout, stderr: result.stderr }; } /** * Get service statuses for a compose project. */ export async function composePs(projectName: string): Promise { try { const result = await execa('docker', [ 'compose', '-p', projectName, 'ps', '--format', 'json', ], { timeout: 15000, }); if (!result.stdout.trim()) { return []; } // docker compose ps --format json outputs one JSON object per line const lines = result.stdout.trim().split('\n'); return lines.map((line) => { const container = JSON.parse(line); return { name: container.Service || container.Name || '', state: container.State || 'unknown', health: container.Health || 'none', }; }); } catch (error) { log.warn({ projectName, err: error }, 'failed to get compose ps'); return []; } } /** * List all preview compose projects. */ export async function listPreviewProjects(): Promise { try { const result = await execa('docker', [ 'compose', 'ls', '--filter', `name=${COMPOSE_PROJECT_PREFIX}`, '--format', 'json', ], { timeout: 15000, }); if (!result.stdout.trim()) { return []; } return JSON.parse(result.stdout); } catch (error) { log.warn({ err: error }, 'failed to list preview projects'); return []; } } /** * Get container labels for a compose project. * Returns labels from the first container that has cw.preview=true. */ export async function getContainerLabels(projectName: string): Promise> { try { const result = await execa('docker', [ 'ps', '--filter', `label=${PREVIEW_LABELS.preview}=true`, '--filter', `label=com.docker.compose.project=${projectName}`, '--format', '{{json .Labels}}', ], { timeout: 15000, }); if (!result.stdout.trim()) { return {}; } // Parse the first line's label string: "key=val,key=val,..." const firstLine = result.stdout.trim().split('\n')[0]; const labelStr = firstLine.replace(/^"|"$/g, ''); const labels: Record = {}; for (const pair of labelStr.split(',')) { const eqIdx = pair.indexOf('='); if (eqIdx > 0) { const key = pair.substring(0, eqIdx); const value = pair.substring(eqIdx + 1); if (key.startsWith('cw.')) { labels[key] = value; } } } return labels; } catch (error) { log.warn({ projectName, err: error }, 'failed to get container labels'); return {}; } }