/** * Docker Compose Generator * * Generates docker-compose.preview.yml and Caddyfile for preview deployments. * All services share a Docker network; only Caddy publishes a host port. */ import yaml from 'js-yaml'; import type { PreviewConfig, PreviewServiceConfig } from './types.js'; import { PREVIEW_LABELS } from './types.js'; export interface ComposeGeneratorOptions { projectPath: string; port: number; deploymentId: string; labels: Record; } interface ComposeService { build?: { context: string; dockerfile: string } | string; image?: string; environment?: Record; volumes?: string[]; labels?: Record; networks?: string[]; depends_on?: string[]; } interface ComposeFile { services: Record; networks: Record; } /** * Generate a Docker Compose YAML string for the preview deployment. * * Structure: * - User-defined services with build contexts * - Caddy reverse proxy publishing the single host port * - Shared `preview` network */ export function generateComposeFile( config: PreviewConfig, opts: ComposeGeneratorOptions, ): string { const compose: ComposeFile = { services: {}, networks: { preview: { driver: 'bridge' }, }, }; const serviceNames: string[] = []; // Add user-defined services for (const [name, svc] of Object.entries(config.services)) { serviceNames.push(name); const service: ComposeService = { labels: { ...opts.labels }, networks: ['preview'], }; // Build config if (svc.build) { if (typeof svc.build === 'string') { service.build = { context: opts.projectPath, dockerfile: svc.build === '.' ? 'Dockerfile' : svc.build, }; } else { service.build = { context: svc.build.context.startsWith('/') ? svc.build.context : `${opts.projectPath}/${svc.build.context}`, dockerfile: svc.build.dockerfile, }; } } else if (svc.image) { service.image = svc.image; } // Environment if (svc.env && Object.keys(svc.env).length > 0) { service.environment = svc.env; } // Volumes if (svc.volumes && svc.volumes.length > 0) { service.volumes = svc.volumes; } compose.services[name] = service; } // Generate and add Caddy proxy service const caddyfile = generateCaddyfile(config); const caddyService: ComposeService = { image: 'caddy:2-alpine', networks: ['preview'], labels: { ...opts.labels }, }; // Caddy publishes the single host port (caddyService as Record).ports = [`${opts.port}:80`]; // Mount Caddyfile via inline config (caddyService as Record).command = ['caddy', 'run', '--config', '/etc/caddy/Caddyfile']; // Caddy config will be written to the deployment directory and mounted (caddyService as Record).volumes = ['./Caddyfile:/etc/caddy/Caddyfile:ro']; if (serviceNames.length > 0) { caddyService.depends_on = serviceNames; } compose.services['caddy-proxy'] = caddyService; return yaml.dump(compose, { lineWidth: 120, noRefs: true }); } /** * Generate a Caddyfile from route mappings in the preview config. * * Routes are sorted by specificity (longest path first) to ensure * more specific routes match before catch-all. */ export function generateCaddyfile(config: PreviewConfig): string { const routes: Array<{ name: string; route: string; port: number }> = []; for (const [name, svc] of Object.entries(config.services)) { if (svc.internal) continue; routes.push({ name, route: svc.route ?? '/', port: svc.port, }); } // Sort by route specificity (longer paths first, root last) routes.sort((a, b) => { if (a.route === '/') return 1; if (b.route === '/') return -1; return b.route.length - a.route.length; }); const lines: string[] = [':80 {']; for (const route of routes) { if (route.route === '/') { lines.push(` handle {`); lines.push(` reverse_proxy ${route.name}:${route.port}`); lines.push(` }`); } else { // Strip trailing slash for handle_path const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; lines.push(` handle_path ${path}/* {`); lines.push(` reverse_proxy ${route.name}:${route.port}`); lines.push(` }`); } } lines.push('}'); return lines.join('\n'); } /** * Generate compose labels for a preview deployment. */ export function generateLabels(opts: { initiativeId: string; phaseId?: string; projectId: string; branch: string; port: number; previewId: string; }): Record { const labels: Record = { [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: opts.initiativeId, [PREVIEW_LABELS.projectId]: opts.projectId, [PREVIEW_LABELS.branch]: opts.branch, [PREVIEW_LABELS.port]: String(opts.port), [PREVIEW_LABELS.previewId]: opts.previewId, }; if (opts.phaseId) { labels[PREVIEW_LABELS.phaseId] = opts.phaseId; } return labels; }