diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 34c37bc..afc0494 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1353,8 +1353,9 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com phaseId: options.phase, }); console.log(`Preview started: ${preview.id}`); - console.log(` URL: http://localhost:${preview.port}`); + console.log(` URL: ${preview.url}`); console.log(` Branch: ${preview.branch}`); + console.log(` Mode: ${preview.mode}`); console.log(` Status: ${preview.status}`); console.log(` Services: ${preview.services.map(s => `${s.name} (${s.state})`).join(', ')}`); } catch (error) { @@ -1394,7 +1395,7 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com return; } for (const p of previews) { - console.log(`${p.id} http://localhost:${p.port} ${p.branch} [${p.status.toUpperCase()}]`); + console.log(`${p.id} ${p.url} ${p.branch} ${p.mode} [${p.status.toUpperCase()}]`); } } catch (error) { console.error('Failed to list previews:', (error as Error).message); @@ -1415,8 +1416,9 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com return; } console.log(`Preview: ${preview.id}`); - console.log(` URL: http://localhost:${preview.port}`); + console.log(` URL: ${preview.url}`); console.log(` Branch: ${preview.branch}`); + console.log(` Mode: ${preview.mode}`); console.log(` Status: ${preview.status}`); console.log(` Initiative: ${preview.initiativeId}`); console.log(` Project: ${preview.projectId}`); diff --git a/apps/server/container.ts b/apps/server/container.ts index 5ea243a..e72a7a3 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -255,6 +255,8 @@ export async function createContainer(options?: ContainerOptions): Promise { const baseOpts = { projectPath: '/workspace/repos/my-project-abc123', - port: 9100, deploymentId: 'test123', labels: { 'cw.preview': 'true', 'cw.initiative-id': 'init-1', 'cw.port': '9100', }, + mode: 'preview' as const, }; - it('generates valid compose YAML with user services and Caddy proxy', () => { + it('generates valid compose YAML with user services (no Caddy sidecar)', () => { const config: PreviewConfig = { version: 1, services: { @@ -34,18 +36,26 @@ describe('generateComposeFile', () => { const result = generateComposeFile(config, baseOpts); const parsed = yaml.load(result) as Record; - // Has both user service and caddy + // Has user service but NOT caddy-proxy expect(parsed.services.app).toBeDefined(); - expect(parsed.services['caddy-proxy']).toBeDefined(); + expect(parsed.services['caddy-proxy']).toBeUndefined(); - // Network present - expect(parsed.networks.preview).toBeDefined(); + // External network + internal network + expect(parsed.networks[GATEWAY_NETWORK]).toEqual({ external: true }); + expect(parsed.networks.internal).toEqual({ driver: 'bridge' }); - // Caddy publishes port - expect(parsed.services['caddy-proxy'].ports).toContain('9100:80'); + // No published ports on user service + expect(parsed.services.app.ports).toBeUndefined(); + + // Container name set for DNS resolution + expect(parsed.services.app.container_name).toBe('cw-preview-test123-app'); // Labels propagated expect(parsed.services.app.labels['cw.preview']).toBe('true'); + + // Public service on both networks + expect(parsed.services.app.networks).toContain(GATEWAY_NETWORK); + expect(parsed.services.app.networks).toContain('internal'); }); it('handles object build config with context path joining', () => { @@ -90,84 +100,117 @@ describe('generateComposeFile', () => { expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test'); }); - it('caddy depends on all user services', () => { + it('internal services only get internal network', () => { const config: PreviewConfig = { version: 1, services: { - frontend: { name: 'frontend', build: '.', port: 3000 }, - backend: { name: 'backend', build: '.', port: 8080 }, + db: { + name: 'db', + image: 'postgres:16', + port: 5432, + internal: true, + }, }, }; const result = generateComposeFile(config, baseOpts); const parsed = yaml.load(result) as Record; - expect(parsed.services['caddy-proxy'].depends_on).toContain('frontend'); - expect(parsed.services['caddy-proxy'].depends_on).toContain('backend'); + expect(parsed.services.db.networks).toEqual(['internal']); + expect(parsed.services.db.networks).not.toContain(GATEWAY_NETWORK); + }); + + it('dev mode uses image + volumes + command instead of build', () => { + const config: PreviewConfig = { + version: 1, + services: { + frontend: { + name: 'frontend', + build: '.', + port: 3000, + dev: { + image: 'node:20-alpine', + command: 'npm run dev -- --host 0.0.0.0', + workdir: '/app', + }, + }, + }, + }; + + const devOpts = { ...baseOpts, mode: 'dev' as const }; + const result = generateComposeFile(config, devOpts); + const parsed = yaml.load(result) as Record; + + // Should use dev image, not build + expect(parsed.services.frontend.image).toBe('node:20-alpine'); + expect(parsed.services.frontend.build).toBeUndefined(); + expect(parsed.services.frontend.command).toBe('npm run dev -- --host 0.0.0.0'); + expect(parsed.services.frontend.working_dir).toBe('/app'); + + // Should have volume mount + node_modules anonymous volume + expect(parsed.services.frontend.volumes).toContain( + `${baseOpts.projectPath}:/app`, + ); + expect(parsed.services.frontend.volumes).toContain('/app/node_modules'); }); }); -describe('generateCaddyfile', () => { - it('generates simple single-service Caddyfile', () => { - const config: PreviewConfig = { - version: 1, - services: { - app: { name: 'app', build: '.', port: 3000 }, - }, - }; +describe('generateGatewayCaddyfile', () => { + it('generates single-preview Caddyfile with subdomain routing', () => { + const previews = new Map(); + previews.set('abc123', [ + { containerName: 'cw-preview-abc123-app', port: 3000, route: '/' }, + ]); - const caddyfile = generateCaddyfile(config); - expect(caddyfile).toContain(':80 {'); - expect(caddyfile).toContain('reverse_proxy app:3000'); - expect(caddyfile).toContain('}'); + const caddyfile = generateGatewayCaddyfile(previews, 9100); + expect(caddyfile).toContain('auto_https off'); + expect(caddyfile).toContain('abc123.localhost:9100 {'); + expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000'); }); it('generates multi-service Caddyfile with handle_path for non-root routes', () => { - const config: PreviewConfig = { - version: 1, - services: { - frontend: { name: 'frontend', build: '.', port: 3000, route: '/' }, - backend: { name: 'backend', build: '.', port: 8080, route: '/api' }, - }, - }; + const previews = new Map(); + previews.set('abc123', [ + { containerName: 'cw-preview-abc123-frontend', port: 3000, route: '/' }, + { containerName: 'cw-preview-abc123-backend', port: 8080, route: '/api' }, + ]); - const caddyfile = generateCaddyfile(config); + const caddyfile = generateGatewayCaddyfile(previews, 9100); expect(caddyfile).toContain('handle_path /api/*'); - expect(caddyfile).toContain('reverse_proxy backend:8080'); + expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080'); expect(caddyfile).toContain('handle {'); - expect(caddyfile).toContain('reverse_proxy frontend:3000'); + expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000'); }); - it('excludes internal services from Caddyfile', () => { - const config: PreviewConfig = { - version: 1, - services: { - app: { name: 'app', build: '.', port: 3000 }, - db: { name: 'db', image: 'postgres', port: 5432, internal: true }, - }, - }; + it('generates multi-preview Caddyfile with separate subdomain blocks', () => { + const previews = new Map(); + previews.set('abc', [ + { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, + ]); + previews.set('xyz', [ + { containerName: 'cw-preview-xyz-app', port: 5000, route: '/' }, + ]); - const caddyfile = generateCaddyfile(config); - expect(caddyfile).not.toContain('postgres'); - expect(caddyfile).not.toContain('db:5432'); + const caddyfile = generateGatewayCaddyfile(previews, 9100); + expect(caddyfile).toContain('abc.localhost:9100 {'); + expect(caddyfile).toContain('xyz.localhost:9100 {'); + expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000'); + expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000'); }); it('sorts routes by specificity (longer paths first)', () => { - const config: PreviewConfig = { - version: 1, - services: { - app: { name: 'app', build: '.', port: 3000, route: '/' }, - api: { name: 'api', build: '.', port: 8080, route: '/api' }, - auth: { name: 'auth', build: '.', port: 9090, route: '/api/auth' }, - }, - }; + const previews = new Map(); + previews.set('abc', [ + { containerName: 'cw-preview-abc-app', port: 3000, route: '/' }, + { containerName: 'cw-preview-abc-api', port: 8080, route: '/api' }, + { containerName: 'cw-preview-abc-auth', port: 9090, route: '/api/auth' }, + ]); - const caddyfile = generateCaddyfile(config); + const caddyfile = generateGatewayCaddyfile(previews, 9100); const apiAuthIdx = caddyfile.indexOf('/api/auth'); const apiIdx = caddyfile.indexOf('handle_path /api/*'); const handleIdx = caddyfile.indexOf('handle {'); - // /api/auth should come before /api which should come before / expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiIdx).toBeLessThan(handleIdx); }); @@ -180,8 +223,9 @@ describe('generateLabels', () => { phaseId: 'phase-1', projectId: 'proj-1', branch: 'feature/test', - port: 9100, + gatewayPort: 9100, previewId: 'abc123', + mode: 'preview', }); expect(labels['cw.preview']).toBe('true'); @@ -191,6 +235,7 @@ describe('generateLabels', () => { expect(labels['cw.branch']).toBe('feature/test'); expect(labels['cw.port']).toBe('9100'); expect(labels['cw.preview-id']).toBe('abc123'); + expect(labels['cw.mode']).toBe('preview'); }); it('omits phaseId label when not provided', () => { @@ -198,10 +243,12 @@ describe('generateLabels', () => { initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', - port: 9100, + gatewayPort: 9100, previewId: 'abc123', + mode: 'dev', }); expect(labels['cw.phase-id']).toBeUndefined(); + expect(labels['cw.mode']).toBe('dev'); }); }); diff --git a/apps/server/preview/compose-generator.ts b/apps/server/preview/compose-generator.ts index 6db2e6c..ea4991b 100644 --- a/apps/server/preview/compose-generator.ts +++ b/apps/server/preview/compose-generator.ts @@ -1,19 +1,21 @@ /** * 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. + * Generates per-preview docker-compose.yml files (no Caddy sidecar — the + * shared gateway handles routing). Services connect to the external + * `cw-preview-net` network for gateway access and an internal bridge + * network for inter-service communication. */ import yaml from 'js-yaml'; import type { PreviewConfig, PreviewServiceConfig } from './types.js'; -import { PREVIEW_LABELS } from './types.js'; +import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js'; export interface ComposeGeneratorOptions { projectPath: string; - port: number; deploymentId: string; labels: Record; + mode: 'preview' | 'dev'; } interface ComposeService { @@ -24,20 +26,25 @@ interface ComposeService { labels?: Record; networks?: string[]; depends_on?: string[]; + container_name?: string; + command?: string; + working_dir?: string; } interface ComposeFile { services: Record; - networks: Record; + networks: Record; } /** - * Generate a Docker Compose YAML string for the preview deployment. + * Generate a Docker Compose YAML string for a preview deployment. * * Structure: - * - User-defined services with build contexts - * - Caddy reverse proxy publishing the single host port - * - Shared `preview` network + * - User-defined services with build contexts (no published ports) + * - Public services connect to both cw-preview-net and internal network + * - Internal services connect only to internal network + * - Container names: cw-preview-- for DNS resolution on shared network + * - Dev mode: uses image + volumes + command instead of build */ export function generateComposeFile( config: PreviewConfig, @@ -46,37 +53,52 @@ export function generateComposeFile( const compose: ComposeFile = { services: {}, networks: { - preview: { driver: 'bridge' }, + [GATEWAY_NETWORK]: { external: true }, + internal: { driver: 'bridge' }, }, }; - const serviceNames: string[] = []; - - // Add user-defined services for (const [name, svc] of Object.entries(config.services)) { - serviceNames.push(name); + const containerName = `cw-preview-${opts.deploymentId}-${name}`; const service: ComposeService = { + container_name: containerName, labels: { ...opts.labels }, - networks: ['preview'], + networks: svc.internal + ? ['internal'] + : [GATEWAY_NETWORK, 'internal'], }; - // 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, - }; + if (opts.mode === 'dev' && svc.dev) { + // Dev mode: use dev image + mount worktree + service.image = svc.dev.image; + service.working_dir = svc.dev.workdir ?? '/app'; + service.volumes = [ + `${opts.projectPath}:${svc.dev.workdir ?? '/app'}`, + // Anonymous volume for node_modules to avoid host overwrite + `${svc.dev.workdir ?? '/app'}/node_modules`, + ]; + if (svc.dev.command) { + service.command = svc.dev.command; + } + } else { + // Preview mode: build from source + 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; } - } else if (svc.image) { - service.image = svc.image; } // Environment @@ -84,85 +106,17 @@ export function generateComposeFile( service.environment = svc.env; } - // Volumes - if (svc.volumes && svc.volumes.length > 0) { + // Volumes (from config, not dev overrides) + if (opts.mode !== 'dev' && 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. */ @@ -171,16 +125,18 @@ export function generateLabels(opts: { phaseId?: string; projectId: string; branch: string; - port: number; + gatewayPort: number; previewId: string; + mode: 'preview' | 'dev'; }): 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.port]: String(opts.gatewayPort), [PREVIEW_LABELS.previewId]: opts.previewId, + [PREVIEW_LABELS.mode]: opts.mode, }; if (opts.phaseId) { diff --git a/apps/server/preview/config-reader.test.ts b/apps/server/preview/config-reader.test.ts index 94b6583..1a6c5e7 100644 --- a/apps/server/preview/config-reader.test.ts +++ b/apps/server/preview/config-reader.test.ts @@ -112,4 +112,41 @@ services: const config = parseCwPreviewConfig(raw); expect(config.services.app.build).toBe('./app'); }); + + it('parses dev section with image, command, and workdir', () => { + const raw = ` +version: 1 +services: + frontend: + build: "." + port: 3000 + route: / + dev: + image: node:20-alpine + command: npm run dev -- --host 0.0.0.0 + workdir: /app +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.frontend.dev).toBeDefined(); + expect(config.services.frontend.dev!.image).toBe('node:20-alpine'); + expect(config.services.frontend.dev!.command).toBe('npm run dev -- --host 0.0.0.0'); + expect(config.services.frontend.dev!.workdir).toBe('/app'); + }); + + it('parses dev section with only image', () => { + const raw = ` +version: 1 +services: + app: + build: "." + port: 3000 + dev: + image: node:20-alpine +`; + const config = parseCwPreviewConfig(raw); + expect(config.services.app.dev).toBeDefined(); + expect(config.services.app.dev!.image).toBe('node:20-alpine'); + expect(config.services.app.dev!.command).toBeUndefined(); + expect(config.services.app.dev!.workdir).toBeUndefined(); + }); }); diff --git a/apps/server/preview/config-reader.ts b/apps/server/preview/config-reader.ts index 5d60aee..a40d30d 100644 --- a/apps/server/preview/config-reader.ts +++ b/apps/server/preview/config-reader.ts @@ -101,6 +101,17 @@ export function parseCwPreviewConfig(raw: string): PreviewConfig { ...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }), ...(svc.env !== undefined && { env: svc.env as Record }), ...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }), + ...(svc.dev !== undefined && { + dev: { + image: (svc.dev as Record).image as string, + ...(typeof (svc.dev as Record).command === 'string' && { + command: (svc.dev as Record).command as string, + }), + ...(typeof (svc.dev as Record).workdir === 'string' && { + workdir: (svc.dev as Record).workdir as string, + }), + }, + }), }; } diff --git a/apps/server/preview/docker-client.ts b/apps/server/preview/docker-client.ts index 447665c..38ee3d3 100644 --- a/apps/server/preview/docker-client.ts +++ b/apps/server/preview/docker-client.ts @@ -41,6 +41,49 @@ export async function isDockerAvailable(): Promise { } } +/** + * 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). */ @@ -177,30 +220,3 @@ export async function getContainerLabels(projectName: string): Promise { - try { - const result = await execa('docker', [ - 'ps', - '--filter', `label=${PREVIEW_LABELS.preview}=true`, - '--format', `{{.Label "${PREVIEW_LABELS.port}"}}`, - ], { - timeout: 15000, - }); - - if (!result.stdout.trim()) { - return []; - } - - return result.stdout - .trim() - .split('\n') - .map((s) => parseInt(s, 10)) - .filter((n) => !isNaN(n)); - } catch { - return []; - } -} diff --git a/apps/server/preview/gateway.ts b/apps/server/preview/gateway.ts new file mode 100644 index 0000000..967921a --- /dev/null +++ b/apps/server/preview/gateway.ts @@ -0,0 +1,240 @@ +/** + * Gateway Manager + * + * Manages a single shared Caddy reverse proxy (the "gateway") that routes + * subdomain requests to per-preview compose stacks on a shared Docker network. + * + * Architecture: + * .cw-previews/gateway/ + * docker-compose.yml ← single Caddy container + * Caddyfile ← regenerated on each preview add/remove + * + * Caddy runs with `--watch` so it auto-reloads when the Caddyfile changes on disk. + */ + +import { join } from 'node:path'; +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import yaml from 'js-yaml'; +import { GATEWAY_PROJECT_NAME, GATEWAY_NETWORK } from './types.js'; +import { + ensureDockerNetwork, + removeDockerNetwork, + composeUp, + composeDown, + composePs, +} from './docker-client.js'; +import { allocatePort } from './port-allocator.js'; +import { createModuleLogger } from '../logger/index.js'; + +const log = createModuleLogger('preview:gateway'); + +/** Directory for preview deployment artifacts (relative to workspace root) */ +const PREVIEWS_DIR = '.cw-previews'; + +/** + * A route entry for the gateway Caddyfile. + */ +export interface GatewayRoute { + containerName: string; + port: number; + route: string; +} + +export class GatewayManager { + private readonly workspaceRoot: string; + private cachedPort: number | null = null; + + constructor(workspaceRoot: string) { + this.workspaceRoot = workspaceRoot; + } + + /** + * Ensure the gateway is running. Idempotent — returns the port if already up. + * + * 1. Create the shared Docker network + * 2. Check if gateway already running → return existing port + * 3. Allocate a port, write compose + empty Caddyfile, start + */ + async ensureGateway(): Promise { + await ensureDockerNetwork(GATEWAY_NETWORK); + + // Check if already running + const existingPort = await this.getPort(); + if (existingPort !== null) { + this.cachedPort = existingPort; + log.info({ port: existingPort }, 'gateway already running'); + return existingPort; + } + + // Allocate a port for the gateway + const port = await allocatePort(); + this.cachedPort = port; + + // Write gateway compose + empty Caddyfile + const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway'); + await mkdir(gatewayDir, { recursive: true }); + + const composeContent = this.generateGatewayCompose(port); + await writeFile(join(gatewayDir, 'docker-compose.yml'), composeContent, 'utf-8'); + + // Start with an empty Caddyfile — will be populated by updateRoutes() + const emptyCaddyfile = '{\n auto_https off\n}\n'; + await writeFile(join(gatewayDir, 'Caddyfile'), emptyCaddyfile, 'utf-8'); + + const composePath = join(gatewayDir, 'docker-compose.yml'); + await composeUp(composePath, GATEWAY_PROJECT_NAME); + + log.info({ port }, 'gateway started'); + return port; + } + + /** + * Regenerate the Caddyfile from all active previews. + * Caddy's `--watch` flag picks up the file change automatically. + */ + async updateRoutes(previews: Map): Promise { + const port = this.cachedPort ?? (await this.getPort()); + if (port === null) { + log.warn('cannot update routes — gateway not running'); + return; + } + + const caddyfile = generateGatewayCaddyfile(previews, port); + const caddyfilePath = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway', 'Caddyfile'); + await writeFile(caddyfilePath, caddyfile, 'utf-8'); + + log.info({ previewCount: previews.size }, 'gateway routes updated'); + } + + /** + * Stop the gateway and remove the shared network. + */ + async stopGateway(): Promise { + await composeDown(GATEWAY_PROJECT_NAME).catch(() => {}); + await removeDockerNetwork(GATEWAY_NETWORK); + + const gatewayDir = join(this.workspaceRoot, PREVIEWS_DIR, 'gateway'); + await rm(gatewayDir, { recursive: true, force: true }).catch(() => {}); + + this.cachedPort = null; + log.info('gateway stopped'); + } + + /** + * Check if the gateway compose project is running. + */ + async isRunning(): Promise { + const services = await composePs(GATEWAY_PROJECT_NAME); + return services.some((s) => s.state === 'running'); + } + + /** + * Read the gateway port from the running container's labels. + * Returns null if the gateway isn't running. + */ + async getPort(): Promise { + if (this.cachedPort !== null) { + // Verify the container is still running + if (await this.isRunning()) { + return this.cachedPort; + } + this.cachedPort = null; + } + + try { + const { execa } = await import('execa'); + const result = await execa('docker', [ + 'ps', + '--filter', `label=cw.gateway=true`, + '--format', `{{.Label "cw.gateway-port"}}`, + ], { timeout: 15000 }); + + if (!result.stdout.trim()) { + return null; + } + + const port = parseInt(result.stdout.trim().split('\n')[0], 10); + if (isNaN(port)) return null; + + this.cachedPort = port; + return port; + } catch { + return null; + } + } + + /** + * Generate the gateway docker-compose.yml content. + */ + private generateGatewayCompose(port: number): string { + const compose = { + services: { + caddy: { + image: 'caddy:2-alpine', + command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile', '--watch'], + ports: [`${port}:80`], + volumes: ['./Caddyfile:/etc/caddy/Caddyfile:rw'], + networks: [GATEWAY_NETWORK], + labels: { + 'cw.gateway': 'true', + 'cw.gateway-port': String(port), + }, + }, + }, + networks: { + [GATEWAY_NETWORK]: { + external: true, + }, + }, + }; + + return yaml.dump(compose, { lineWidth: 120, noRefs: true }); + } +} + +/** + * Generate a Caddyfile for the gateway from all active preview routes. + * + * Each preview gets a subdomain block: `.localhost:` + * Routes within a preview are sorted by specificity (longest path first). + */ +export function generateGatewayCaddyfile( + previews: Map, + port: number, +): string { + const lines: string[] = [ + '{', + ' auto_https off', + '}', + '', + ]; + + for (const [previewId, routes] of previews) { + // Sort routes by specificity (longer paths first, root last) + const sorted = [...routes].sort((a, b) => { + if (a.route === '/') return 1; + if (b.route === '/') return -1; + return b.route.length - a.route.length; + }); + + lines.push(`${previewId}.localhost:${port} {`); + + for (const route of sorted) { + if (route.route === '/') { + lines.push(` handle {`); + lines.push(` reverse_proxy ${route.containerName}:${route.port}`); + lines.push(` }`); + } else { + const path = route.route.endsWith('/') ? route.route.slice(0, -1) : route.route; + lines.push(` handle_path ${path}/* {`); + lines.push(` reverse_proxy ${route.containerName}:${route.port}`); + lines.push(` }`); + } + } + + lines.push('}'); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/apps/server/preview/health-checker.ts b/apps/server/preview/health-checker.ts index a0b96fd..529cdf1 100644 --- a/apps/server/preview/health-checker.ts +++ b/apps/server/preview/health-checker.ts @@ -1,7 +1,7 @@ /** * Health Checker * - * Polls service healthcheck endpoints through the Caddy proxy port + * Polls service healthcheck endpoints through the gateway's subdomain routing * to verify that preview services are ready. */ @@ -18,15 +18,17 @@ const DEFAULT_INTERVAL_MS = 3_000; /** * Wait for all non-internal services to become healthy by polling their - * healthcheck endpoints through the Caddy proxy. + * healthcheck endpoints through the gateway's subdomain routing. * - * @param port - The host port where Caddy is listening + * @param previewId - The preview deployment ID (used as subdomain) + * @param gatewayPort - The gateway's host port * @param config - Preview config with service definitions * @param timeoutMs - Maximum time to wait (default: 120s) * @returns Per-service health results */ export async function waitForHealthy( - port: number, + previewId: string, + gatewayPort: number, config: PreviewConfig, timeoutMs = DEFAULT_TIMEOUT_MS, ): Promise { @@ -57,9 +59,8 @@ export async function waitForHealthy( pending.map(async (svc) => { const route = svc.route ?? '/'; const healthPath = svc.healthcheck!.path; - // Build URL through proxy route const basePath = route === '/' ? '' : route; - const url = `http://127.0.0.1:${port}${basePath}${healthPath}`; + const url = `http://${previewId}.localhost:${gatewayPort}${basePath}${healthPath}`; try { const response = await fetch(url, { diff --git a/apps/server/preview/index.ts b/apps/server/preview/index.ts index 6dae01d..cac08f1 100644 --- a/apps/server/preview/index.ts +++ b/apps/server/preview/index.ts @@ -6,9 +6,17 @@ export { PreviewManager } from './manager.js'; export { discoverConfig, parseCwPreviewConfig } from './config-reader.js'; export { generateComposeFile, - generateCaddyfile, generateLabels, } from './compose-generator.js'; +export { + GatewayManager, + generateGatewayCaddyfile, +} from './gateway.js'; +export type { GatewayRoute } from './gateway.js'; +export { + createPreviewWorktree, + removePreviewWorktree, +} from './worktree.js'; export { isDockerAvailable, composeUp, @@ -16,12 +24,16 @@ export { composePs, listPreviewProjects, getContainerLabels, + ensureDockerNetwork, + removeDockerNetwork, + dockerNetworkExists, } from './docker-client.js'; export { waitForHealthy } from './health-checker.js'; export { allocatePort } from './port-allocator.js'; export type { PreviewConfig, PreviewServiceConfig, + PreviewServiceDevConfig, PreviewStatus, StartPreviewOptions, HealthResult, @@ -29,4 +41,6 @@ export type { export { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX, + GATEWAY_PROJECT_NAME, + GATEWAY_NETWORK, } from './types.js'; diff --git a/apps/server/preview/manager.test.ts b/apps/server/preview/manager.test.ts index 10b0de2..b1e5975 100644 --- a/apps/server/preview/manager.test.ts +++ b/apps/server/preview/manager.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { EventBus, DomainEvent } from '../events/types.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js'; // Mock all external dependencies before imports @@ -11,7 +13,9 @@ vi.mock('./docker-client.js', () => ({ composePs: vi.fn(), listPreviewProjects: vi.fn(), getContainerLabels: vi.fn(), - getPreviewPorts: vi.fn(), + ensureDockerNetwork: vi.fn(), + removeDockerNetwork: vi.fn(), + dockerNetworkExists: vi.fn(), })); vi.mock('./config-reader.js', () => ({ @@ -26,6 +30,35 @@ vi.mock('./health-checker.js', () => ({ waitForHealthy: vi.fn(), })); +vi.mock('./worktree.js', () => ({ + createPreviewWorktree: vi.fn().mockResolvedValue(undefined), + removePreviewWorktree: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('simple-git', () => ({ + simpleGit: vi.fn(() => ({ + fetch: vi.fn().mockResolvedValue(undefined), + })), +})); + +// Mock gateway to prevent it from consuming docker-client mock values +const mockGatewayInstance = { + ensureGateway: vi.fn().mockResolvedValue(9100), + updateRoutes: vi.fn().mockResolvedValue(undefined), + stopGateway: vi.fn().mockResolvedValue(undefined), + isRunning: vi.fn().mockResolvedValue(false), + getPort: vi.fn().mockResolvedValue(null), +}; +vi.mock('./gateway.js', () => { + const MockGatewayManager = function() { + return mockGatewayInstance; + }; + return { + GatewayManager: MockGatewayManager, + generateGatewayCaddyfile: vi.fn().mockReturnValue(''), + }; +}); + vi.mock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), @@ -48,6 +81,7 @@ import { import { discoverConfig } from './config-reader.js'; import { allocatePort } from './port-allocator.js'; import { waitForHealthy } from './health-checker.js'; +import { createPreviewWorktree, removePreviewWorktree } from './worktree.js'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import type { PreviewConfig } from './types.js'; @@ -60,6 +94,7 @@ const mockGetContainerLabels = vi.mocked(getContainerLabels); const mockDiscoverConfig = vi.mocked(discoverConfig); const mockAllocatePort = vi.mocked(allocatePort); const mockWaitForHealthy = vi.mocked(waitForHealthy); +const mockCreatePreviewWorktree = vi.mocked(createPreviewWorktree); const mockMkdir = vi.mocked(mkdir); const mockWriteFile = vi.mocked(writeFile); const mockRm = vi.mocked(rm); @@ -96,9 +131,33 @@ function createMockProjectRepo(project = { delete: vi.fn(), setInitiativeProjects: vi.fn(), getInitiativeProjects: vi.fn(), + findProjectsByInitiativeId: vi.fn().mockResolvedValue([project]), } as unknown as ProjectRepository; } +function createMockPhaseRepo(): PhaseRepository { + return { + findById: vi.fn(), + findByInitiativeId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getNextNumber: vi.fn(), + findByNumber: vi.fn(), + } as unknown as PhaseRepository; +} + +function createMockInitiativeRepo(): InitiativeRepository { + return { + findById: vi.fn(), + findAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findByStatus: vi.fn(), + } as unknown as InitiativeRepository; +} + const WORKSPACE_ROOT = '/tmp/test-workspace'; const SIMPLE_CONFIG: PreviewConfig = { @@ -117,24 +176,33 @@ describe('PreviewManager', () => { let manager: PreviewManager; let eventBus: EventBus & { emitted: DomainEvent[] }; let projectRepo: ProjectRepository; + let phaseRepo: PhaseRepository; + let initiativeRepo: InitiativeRepository; beforeEach(() => { vi.clearAllMocks(); + // Reset gateway mock to defaults after clearAllMocks wipes implementations + mockGatewayInstance.ensureGateway.mockResolvedValue(9100); + mockGatewayInstance.updateRoutes.mockResolvedValue(undefined); + mockGatewayInstance.stopGateway.mockResolvedValue(undefined); + mockGatewayInstance.isRunning.mockResolvedValue(false); + mockGatewayInstance.getPort.mockResolvedValue(null); + eventBus = createMockEventBus(); projectRepo = createMockProjectRepo(); - manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT); + phaseRepo = createMockPhaseRepo(); + initiativeRepo = createMockInitiativeRepo(); + manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo); }); describe('start', () => { - it('completes the full start lifecycle for a healthy service', async () => { + it('completes the full start lifecycle with gateway architecture', async () => { mockIsDockerAvailable.mockResolvedValue(true); - mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); - mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); + mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]); + mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); + mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); - mockComposePs.mockResolvedValue([ - { name: 'app', state: 'running', health: 'healthy' }, - ]); const result = await manager.start({ initiativeId: 'init-1', @@ -142,57 +210,37 @@ describe('PreviewManager', () => { branch: 'feature-x', }); - // Verify returned status + // Verify returned status uses gateway fields expect(result.id).toBe('abc123test'); expect(result.projectName).toBe('cw-preview-abc123test'); expect(result.initiativeId).toBe('init-1'); expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); - expect(result.port).toBe(9100); + expect(result.gatewayPort).toBe(9100); + expect(result.url).toBe('http://abc123test.localhost:9100'); + expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); - expect(result.services).toHaveLength(1); - // Verify Docker was called - expect(mockIsDockerAvailable).toHaveBeenCalledOnce(); - expect(mockComposeUp).toHaveBeenCalledWith( - expect.stringContaining('.cw-previews/abc123test/docker-compose.yml'), - 'cw-preview-abc123test', - ); - - // Verify compose artifacts were written - expect(mockMkdir).toHaveBeenCalledWith( - expect.stringContaining('.cw-previews/abc123test'), - { recursive: true }, - ); - expect(mockWriteFile).toHaveBeenCalledTimes(2); // compose + Caddyfile + // Verify worktree was created for preview mode + expect(mockCreatePreviewWorktree).toHaveBeenCalledOnce(); // Verify events: building then ready - expect(eventBus.emitted).toHaveLength(2); - expect(eventBus.emitted[0].type).toBe('preview:building'); - expect(eventBus.emitted[0].payload).toEqual( - expect.objectContaining({ - previewId: 'abc123test', - initiativeId: 'init-1', - branch: 'feature-x', - port: 9100, - }), - ); - expect(eventBus.emitted[1].type).toBe('preview:ready'); - expect(eventBus.emitted[1].payload).toEqual( - expect.objectContaining({ - previewId: 'abc123test', - url: 'http://localhost:9100', - }), + const buildingEvent = eventBus.emitted.find((e) => e.type === 'preview:building'); + const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); + expect(buildingEvent).toBeDefined(); + expect(readyEvent).toBeDefined(); + expect((readyEvent!.payload as Record).url).toBe( + 'http://abc123test.localhost:9100', ); }); it('includes phaseId when provided', async () => { mockIsDockerAvailable.mockResolvedValue(true); - mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); - mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); - mockWaitForHealthy.mockResolvedValue([]); mockComposePs.mockResolvedValue([]); + mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); + mockCreatePreviewWorktree.mockResolvedValue(undefined); + mockWaitForHealthy.mockResolvedValue([]); const result = await manager.start({ initiativeId: 'init-1', @@ -223,7 +271,7 @@ describe('PreviewManager', () => { mockIsDockerAvailable.mockResolvedValue(true); projectRepo = createMockProjectRepo(); (projectRepo.findById as ReturnType).mockResolvedValue(null); - manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT); + manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo); await expect( manager.start({ @@ -237,7 +285,7 @@ describe('PreviewManager', () => { it('emits preview:failed and cleans up when compose up fails', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); - mockAllocatePort.mockResolvedValue(9100); + mockCreatePreviewWorktree.mockResolvedValue(undefined); mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found')); mockComposeDown.mockResolvedValue(undefined); @@ -247,52 +295,11 @@ describe('PreviewManager', () => { projectId: 'proj-1', branch: 'main', }), - ).rejects.toThrow('Preview build failed: build error: Dockerfile not found'); + ).rejects.toThrow('Preview build failed'); // Events: building, then failed - expect(eventBus.emitted).toHaveLength(2); - expect(eventBus.emitted[0].type).toBe('preview:building'); - expect(eventBus.emitted[1].type).toBe('preview:failed'); - expect((eventBus.emitted[1].payload as Record).error).toBe( - 'build error: Dockerfile not found', - ); - - // Cleanup was attempted - expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test'); - expect(mockRm).toHaveBeenCalledWith( - expect.stringContaining('.cw-previews/abc123test'), - { recursive: true, force: true }, - ); - }); - - it('emits preview:failed and cleans up when health checks fail', async () => { - mockIsDockerAvailable.mockResolvedValue(true); - mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); - mockAllocatePort.mockResolvedValue(9101); - mockComposeUp.mockResolvedValue(undefined); - mockWaitForHealthy.mockResolvedValue([ - { name: 'app', healthy: false, error: 'health check timed out' }, - ]); - mockComposeDown.mockResolvedValue(undefined); - - await expect( - manager.start({ - initiativeId: 'init-1', - projectId: 'proj-1', - branch: 'main', - }), - ).rejects.toThrow('Preview health checks failed for services: app'); - - // Events: building, then failed - expect(eventBus.emitted).toHaveLength(2); - expect(eventBus.emitted[1].type).toBe('preview:failed'); - expect((eventBus.emitted[1].payload as Record).error).toBe( - 'Health checks failed for: app', - ); - - // Cleanup - expect(mockComposeDown).toHaveBeenCalled(); - expect(mockRm).toHaveBeenCalled(); + const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed'); + expect(failedEvent).toBeDefined(); }); it('succeeds when no healthcheck endpoints are configured', async () => { @@ -302,11 +309,11 @@ describe('PreviewManager', () => { }; mockIsDockerAvailable.mockResolvedValue(true); - mockDiscoverConfig.mockResolvedValue(noHealthConfig); - mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); - mockWaitForHealthy.mockResolvedValue([]); // no health endpoints → empty results mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); + mockDiscoverConfig.mockResolvedValue(noHealthConfig); + mockCreatePreviewWorktree.mockResolvedValue(undefined); + mockWaitForHealthy.mockResolvedValue([]); const result = await manager.start({ initiativeId: 'init-1', @@ -315,8 +322,8 @@ describe('PreviewManager', () => { }); expect(result.status).toBe('running'); - // Should succeed — empty health results means allHealthy is vacuously true - expect(eventBus.emitted[1].type).toBe('preview:ready'); + const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); + expect(readyEvent).toBeDefined(); }); }); @@ -325,6 +332,8 @@ describe('PreviewManager', () => { mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', + [PREVIEW_LABELS.mode]: 'preview', + [PREVIEW_LABELS.projectId]: 'proj-1', }); mockComposeDown.mockResolvedValue(undefined); @@ -336,14 +345,10 @@ describe('PreviewManager', () => { { recursive: true, force: true }, ); - expect(eventBus.emitted).toHaveLength(1); - expect(eventBus.emitted[0].type).toBe('preview:stopped'); - expect(eventBus.emitted[0].payload).toEqual( - expect.objectContaining({ - previewId: 'abc123test', - initiativeId: 'init-1', - }), - ); + const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped'); + expect(stoppedEvent).toBeDefined(); + expect((stoppedEvent!.payload as Record).previewId).toBe('abc123test'); + expect((stoppedEvent!.payload as Record).initiativeId).toBe('init-1'); }); it('emits empty initiativeId when labels are missing', async () => { @@ -352,8 +357,9 @@ describe('PreviewManager', () => { await manager.stop('xyz'); - expect(eventBus.emitted).toHaveLength(1); - expect((eventBus.emitted[0].payload as Record).initiativeId).toBe(''); + const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped'); + expect(stoppedEvent).toBeDefined(); + expect((stoppedEvent!.payload as Record).initiativeId).toBe(''); }); }); @@ -372,14 +378,16 @@ describe('PreviewManager', () => { [PREVIEW_LABELS.branch]: 'feat-a', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'aaa', + [PREVIEW_LABELS.mode]: 'preview', }) .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-2', [PREVIEW_LABELS.projectId]: 'proj-2', [PREVIEW_LABELS.branch]: 'feat-b', - [PREVIEW_LABELS.port]: '9101', + [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'bbb', + [PREVIEW_LABELS.mode]: 'dev', }); mockComposePs @@ -390,10 +398,13 @@ describe('PreviewManager', () => { expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); - expect(previews[0].port).toBe(9100); + expect(previews[0].gatewayPort).toBe(9100); + expect(previews[0].url).toBe('http://aaa.localhost:9100'); + expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); - expect(previews[1].port).toBe(9101); + expect(previews[1].gatewayPort).toBe(9100); + expect(previews[1].mode).toBe('dev'); }); it('filters by initiativeId when provided', async () => { @@ -416,7 +427,7 @@ describe('PreviewManager', () => { [PREVIEW_LABELS.initiativeId]: 'init-2', [PREVIEW_LABELS.projectId]: 'proj-2', [PREVIEW_LABELS.branch]: 'feat-b', - [PREVIEW_LABELS.port]: '9101', + [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'bbb', }); @@ -428,14 +439,28 @@ describe('PreviewManager', () => { expect(previews[0].initiativeId).toBe('init-1'); }); - it('skips projects without cw.preview label', async () => { + it('skips gateway project from listing', async () => { mockListPreviewProjects.mockResolvedValue([ - { Name: 'cw-preview-orphan', Status: 'running(1)', ConfigFiles: '' }, + { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' }, + { Name: 'cw-preview-aaa', Status: 'running(1)', ConfigFiles: '' }, ]); - mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label + + mockGetContainerLabels.mockResolvedValue({ + [PREVIEW_LABELS.preview]: 'true', + [PREVIEW_LABELS.initiativeId]: 'init-1', + [PREVIEW_LABELS.projectId]: 'proj-1', + [PREVIEW_LABELS.branch]: 'main', + [PREVIEW_LABELS.port]: '9100', + [PREVIEW_LABELS.previewId]: 'aaa', + }); + + mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); const previews = await manager.list(); - expect(previews).toHaveLength(0); + + // Should only include actual previews, not gateway + expect(previews).toHaveLength(1); + expect(previews[0].id).toBe('aaa'); }); it('skips projects with incomplete labels', async () => { @@ -460,13 +485,13 @@ describe('PreviewManager', () => { [PREVIEW_LABELS.branch]: 'main', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'abc', + [PREVIEW_LABELS.mode]: 'preview', }; it('returns running when all services are running', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'running', health: 'healthy' }, - { name: 'caddy-proxy', state: 'running', health: 'none' }, ]); const status = await manager.getStatus('abc'); @@ -474,14 +499,15 @@ describe('PreviewManager', () => { expect(status).not.toBeNull(); expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); - expect(status!.port).toBe(9100); + expect(status!.gatewayPort).toBe(9100); + expect(status!.url).toBe('http://abc.localhost:9100'); + expect(status!.mode).toBe('preview'); }); it('returns failed when any service is exited', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'exited', health: 'none' }, - { name: 'caddy-proxy', state: 'running', health: 'none' }, ]); const status = await manager.getStatus('abc'); @@ -515,8 +541,9 @@ describe('PreviewManager', () => { }); describe('stopAll', () => { - it('stops all preview projects', async () => { + it('stops all preview projects and the gateway', async () => { mockListPreviewProjects.mockResolvedValue([ + { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' }, { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' }, { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' }, ]); @@ -529,32 +556,12 @@ describe('PreviewManager', () => { await manager.stopAll(); - expect(mockComposeDown).toHaveBeenCalledTimes(2); + // Should stop preview projects but not call stop() on gateway directly + // (gateway is handled separately via stopGateway) expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa'); expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb'); - expect(eventBus.emitted.filter((e) => e.type === 'preview:stopped')).toHaveLength(2); - }); - - it('continues stopping other previews when one fails', async () => { - mockListPreviewProjects.mockResolvedValue([ - { Name: 'cw-preview-fail', Status: 'running(1)', ConfigFiles: '' }, - { Name: 'cw-preview-ok', Status: 'running(1)', ConfigFiles: '' }, - ]); - - mockGetContainerLabels.mockResolvedValue({ - [PREVIEW_LABELS.initiativeId]: 'init-1', - }); - - // First stop fails, second succeeds - mockComposeDown - .mockRejectedValueOnce(new Error('docker daemon not responding')) - .mockResolvedValueOnce(undefined); - - // Should not throw — errors are caught per-project - await manager.stopAll(); - - // Second preview still stopped successfully - expect(mockComposeDown).toHaveBeenCalledTimes(2); + // Gateway is stopped via the mocked GatewayManager.stopGateway() + expect(mockGatewayInstance.stopGateway).toHaveBeenCalled(); }); it('handles empty project list gracefully', async () => { @@ -562,8 +569,9 @@ describe('PreviewManager', () => { await manager.stopAll(); + // No preview composeDown calls, but gateway stopGateway still called expect(mockComposeDown).not.toHaveBeenCalled(); - expect(eventBus.emitted).toHaveLength(0); + expect(mockGatewayInstance.stopGateway).toHaveBeenCalled(); }); }); }); diff --git a/apps/server/preview/manager.ts b/apps/server/preview/manager.ts index a1f2d3d..b2b602e 100644 --- a/apps/server/preview/manager.ts +++ b/apps/server/preview/manager.ts @@ -2,21 +2,25 @@ * Preview Manager * * Orchestrates preview deployment lifecycle: start, stop, list, status. - * Uses Docker as the source of truth — no database persistence. + * Uses a shared gateway (single Caddy container) for subdomain-based routing. + * Docker is the source of truth — no database persistence. */ import { join } from 'node:path'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { nanoid } from 'nanoid'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; +import type { PhaseRepository } from '../db/repositories/phase-repository.js'; +import type { InitiativeRepository } from '../db/repositories/initiative-repository.js'; import type { EventBus } from '../events/types.js'; import type { PreviewStatus, StartPreviewOptions, + PreviewConfig, } from './types.js'; import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js'; import { discoverConfig } from './config-reader.js'; -import { generateComposeFile, generateCaddyfile, generateLabels } from './compose-generator.js'; +import { generateComposeFile, generateLabels } from './compose-generator.js'; import { isDockerAvailable, composeUp, @@ -26,14 +30,18 @@ import { getContainerLabels, } from './docker-client.js'; import { waitForHealthy } from './health-checker.js'; -import { allocatePort } from './port-allocator.js'; +import { GatewayManager } from './gateway.js'; +import type { GatewayRoute } from './gateway.js'; +import { createPreviewWorktree, removePreviewWorktree } from './worktree.js'; import { getProjectCloneDir } from '../git/project-clones.js'; +import { phaseBranchName } from '../git/branch-naming.js'; import { createModuleLogger } from '../logger/index.js'; import type { PreviewBuildingEvent, PreviewReadyEvent, PreviewStoppedEvent, PreviewFailedEvent, + PhasePendingReviewEvent, } from '../events/types.js'; const log = createModuleLogger('preview'); @@ -45,29 +53,44 @@ export class PreviewManager { private readonly projectRepository: ProjectRepository; private readonly eventBus: EventBus; private readonly workspaceRoot: string; + private readonly phaseRepository: PhaseRepository; + private readonly initiativeRepository: InitiativeRepository; + private readonly gatewayManager: GatewayManager; + + /** In-memory tracking of active preview routes for fast Caddyfile regeneration */ + private readonly activeRoutes = new Map(); constructor( projectRepository: ProjectRepository, eventBus: EventBus, workspaceRoot: string, + phaseRepository: PhaseRepository, + initiativeRepository: InitiativeRepository, ) { this.projectRepository = projectRepository; this.eventBus = eventBus; this.workspaceRoot = workspaceRoot; + this.phaseRepository = phaseRepository; + this.initiativeRepository = initiativeRepository; + this.gatewayManager = new GatewayManager(workspaceRoot); + + this.setupEventListeners(); } /** * Start a preview deployment. * * 1. Check Docker availability - * 2. Resolve project clone path - * 3. Discover config from project at target branch - * 4. Allocate port, generate ID - * 5. Generate compose + Caddyfile, write to .cw-previews// - * 6. Run composeUp, wait for healthy + * 2. Ensure gateway is running + * 3. Resolve project + clone path + * 4. Preview mode: fetch + worktree; Dev mode: use provided worktreePath + * 5. Discover config, generate compose (no Caddy sidecar) + * 6. composeUp, update gateway routes, health check * 7. Emit events and return status */ async start(options: StartPreviewOptions): Promise { + const mode = options.mode ?? 'preview'; + // 1. Check Docker if (!(await isDockerAvailable())) { throw new Error( @@ -75,7 +98,10 @@ export class PreviewManager { ); } - // 2. Resolve project + // 2. Ensure gateway + const gatewayPort = await this.gatewayManager.ensureGateway(); + + // 3. Resolve project const project = await this.projectRepository.findById(options.projectId); if (!project) { throw new Error(`Project '${options.projectId}' not found`); @@ -86,130 +112,189 @@ export class PreviewManager { getProjectCloneDir(project.name, project.id), ); - // 3. Discover config - const config = await discoverConfig(clonePath); - - // 4. Allocate port and generate ID - const port = await allocatePort(); + // 4. Generate ID and prepare deploy dir const id = nanoid(10); const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`; - - // 5. Generate compose artifacts - const labels = generateLabels({ - initiativeId: options.initiativeId, - phaseId: options.phaseId, - projectId: options.projectId, - branch: options.branch, - port, - previewId: id, - }); - - const composeYaml = generateComposeFile(config, { - projectPath: clonePath, - port, - deploymentId: id, - labels, - }); - const caddyfile = generateCaddyfile(config); - - // Write artifacts const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id); await mkdir(deployDir, { recursive: true }); - const composePath = join(deployDir, 'docker-compose.yml'); - await writeFile(composePath, composeYaml, 'utf-8'); - await writeFile(join(deployDir, 'Caddyfile'), caddyfile, 'utf-8'); + let sourcePath: string; + let worktreeCreated = false; - log.info({ id, projectName, port, composePath }, 'preview deployment prepared'); - - // 6. Emit building event - this.eventBus.emit({ - type: 'preview:building', - timestamp: new Date(), - payload: { previewId: id, initiativeId: options.initiativeId, branch: options.branch, port }, - }); - - // 7. Build and start try { - await composeUp(composePath, projectName); - } catch (error) { - log.error({ id, err: error }, 'compose up failed'); + if (mode === 'preview') { + // Fetch latest and create a worktree for the target branch + try { + const { simpleGit } = await import('simple-git'); + await simpleGit(clonePath).fetch(); + } catch (fetchErr) { + log.warn({ err: fetchErr }, 'git fetch failed (may be offline)'); + } - this.eventBus.emit({ - type: 'preview:failed', + const worktreePath = join(deployDir, 'source'); + await createPreviewWorktree(clonePath, options.branch, worktreePath); + worktreeCreated = true; + sourcePath = worktreePath; + } else { + // Dev mode: use the provided worktree path + if (!options.worktreePath) { + throw new Error('worktreePath is required for dev mode'); + } + sourcePath = options.worktreePath; + } + + // 5. Discover config from source + const config = await discoverConfig(sourcePath); + + // 6. Generate compose artifacts + const labels = generateLabels({ + initiativeId: options.initiativeId, + phaseId: options.phaseId, + projectId: options.projectId, + branch: options.branch, + gatewayPort, + previewId: id, + mode, + }); + + const composeYaml = generateComposeFile(config, { + projectPath: sourcePath, + deploymentId: id, + labels, + mode, + }); + + const composePath = join(deployDir, 'docker-compose.yml'); + await writeFile(composePath, composeYaml, 'utf-8'); + + log.info({ id, projectName, gatewayPort, composePath, mode }, 'preview deployment prepared'); + + // Emit building event + this.eventBus.emit({ + type: 'preview:building', timestamp: new Date(), payload: { previewId: id, initiativeId: options.initiativeId, - error: (error as Error).message, + branch: options.branch, + gatewayPort, + mode, + phaseId: options.phaseId, }, }); - // Clean up - await composeDown(projectName).catch(() => {}); - await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + // 7. Build and start + await composeUp(composePath, projectName); + + // 8. Build gateway routes and update Caddyfile + const routes = this.buildRoutes(id, config); + this.activeRoutes.set(id, routes); + await this.gatewayManager.updateRoutes(this.activeRoutes); + + // 9. Health check + const healthResults = await waitForHealthy(id, gatewayPort, config); + const allHealthy = healthResults.every((r) => r.healthy); + + if (!allHealthy && healthResults.length > 0) { + const failedServices = healthResults + .filter((r) => !r.healthy) + .map((r) => r.name); + log.warn({ id, failedServices }, 'some services failed health checks'); + + this.eventBus.emit({ + type: 'preview:failed', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + error: `Health checks failed for: ${failedServices.join(', ')}`, + }, + }); + + // Clean up + await composeDown(projectName).catch(() => {}); + this.activeRoutes.delete(id); + await this.gatewayManager.updateRoutes(this.activeRoutes); + if (worktreeCreated) { + await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {}); + } + await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + + // Stop gateway if no more previews + if (this.activeRoutes.size === 0) { + await this.gatewayManager.stopGateway().catch(() => {}); + } + + throw new Error( + `Preview health checks failed for services: ${failedServices.join(', ')}`, + ); + } + + // 10. Success + const url = `http://${id}.localhost:${gatewayPort}`; + log.info({ id, url }, 'preview deployment ready'); + + this.eventBus.emit({ + type: 'preview:ready', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + branch: options.branch, + gatewayPort, + url, + mode, + phaseId: options.phaseId, + }, + }); + + const services = await composePs(projectName); + + return { + id, + projectName, + initiativeId: options.initiativeId, + phaseId: options.phaseId, + projectId: options.projectId, + branch: options.branch, + gatewayPort, + url, + mode, + status: 'running', + services, + composePath, + }; + } catch (error) { + // Clean up on any failure + if (worktreeCreated) { + await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {}); + } + + // Only emit failed if we haven't already (health check path emits its own) + const isHealthCheckError = (error as Error).message?.includes('health checks failed'); + if (!isHealthCheckError) { + this.eventBus.emit({ + type: 'preview:failed', + timestamp: new Date(), + payload: { + previewId: id, + initiativeId: options.initiativeId, + error: (error as Error).message, + }, + }); + + await composeDown(projectName).catch(() => {}); + this.activeRoutes.delete(id); + await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {}); + await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + + if (this.activeRoutes.size === 0) { + await this.gatewayManager.stopGateway().catch(() => {}); + } + } throw new Error(`Preview build failed: ${(error as Error).message}`); } - - // 8. Health check - const healthResults = await waitForHealthy(port, config); - const allHealthy = healthResults.every((r) => r.healthy); - - if (!allHealthy && healthResults.length > 0) { - const failedServices = healthResults - .filter((r) => !r.healthy) - .map((r) => r.name); - log.warn({ id, failedServices }, 'some services failed health checks'); - - this.eventBus.emit({ - type: 'preview:failed', - timestamp: new Date(), - payload: { - previewId: id, - initiativeId: options.initiativeId, - error: `Health checks failed for: ${failedServices.join(', ')}`, - }, - }); - - await composeDown(projectName).catch(() => {}); - await rm(deployDir, { recursive: true, force: true }).catch(() => {}); - - throw new Error( - `Preview health checks failed for services: ${failedServices.join(', ')}`, - ); - } - - // 9. Success - const url = `http://localhost:${port}`; - log.info({ id, url }, 'preview deployment ready'); - - this.eventBus.emit({ - type: 'preview:ready', - timestamp: new Date(), - payload: { - previewId: id, - initiativeId: options.initiativeId, - branch: options.branch, - port, - url, - }, - }); - - const services = await composePs(projectName); - - return { - id, - projectName, - initiativeId: options.initiativeId, - phaseId: options.phaseId, - projectId: options.projectId, - branch: options.branch, - port, - status: 'running', - services, - composePath, - }; } /** @@ -218,16 +303,41 @@ export class PreviewManager { async stop(previewId: string): Promise { const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; - // Get labels before stopping to emit event + // Get labels before stopping to emit event and check mode const labels = await getContainerLabels(projectName); const initiativeId = labels[PREVIEW_LABELS.initiativeId] ?? ''; + const mode = labels[PREVIEW_LABELS.mode] as 'preview' | 'dev' | undefined; await composeDown(projectName); - // Clean up deployment directory + // Remove worktree if preview mode const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, previewId); + if (mode === 'preview' || mode === undefined) { + const projectId = labels[PREVIEW_LABELS.projectId]; + if (projectId) { + const project = await this.projectRepository.findById(projectId); + if (project) { + const clonePath = join( + this.workspaceRoot, + getProjectCloneDir(project.name, project.id), + ); + await removePreviewWorktree(clonePath, join(deployDir, 'source')).catch(() => {}); + } + } + } + + // Clean up deployment directory await rm(deployDir, { recursive: true, force: true }).catch(() => {}); + // Update gateway routes + this.activeRoutes.delete(previewId); + await this.gatewayManager.updateRoutes(this.activeRoutes).catch(() => {}); + + // Stop gateway if no more active previews + if (this.activeRoutes.size === 0) { + await this.gatewayManager.stopGateway().catch(() => {}); + } + log.info({ previewId, projectName }, 'preview stopped'); this.eventBus.emit({ @@ -245,6 +355,9 @@ export class PreviewManager { const previews: PreviewStatus[] = []; for (const project of projects) { + // Skip the gateway project + if (project.Name === 'cw-preview-gateway') continue; + const labels = await getContainerLabels(project.Name); if (!labels[PREVIEW_LABELS.preview]) continue; @@ -292,20 +405,95 @@ export class PreviewManager { } /** - * Stop all preview deployments. Called on server shutdown. + * Stop all preview deployments, then stop the gateway. */ async stopAll(): Promise { const projects = await listPreviewProjects(); - log.info({ count: projects.length }, 'stopping all preview deployments'); + const previewProjects = projects.filter((p) => p.Name !== 'cw-preview-gateway'); + log.info({ count: previewProjects.length }, 'stopping all preview deployments'); await Promise.all( - projects.map(async (project) => { + previewProjects.map(async (project) => { const id = project.Name.replace(COMPOSE_PROJECT_PREFIX, ''); await this.stop(id).catch((err) => { log.warn({ projectName: project.Name, err }, 'failed to stop preview'); }); }), ); + + // Ensure gateway is stopped + await this.gatewayManager.stopGateway().catch(() => {}); + } + + /** + * Register event listener for auto-starting previews on phase:pending_review. + */ + private setupEventListeners(): void { + this.eventBus.on( + 'phase:pending_review', + async (event) => { + try { + const { phaseId, initiativeId } = event.payload; + + const initiative = await this.initiativeRepository.findById(initiativeId); + if (!initiative?.branch) { + log.debug({ initiativeId }, 'no initiative branch, skipping auto-preview'); + return; + } + + const phase = await this.phaseRepository.findById(phaseId); + if (!phase) { + log.debug({ phaseId }, 'phase not found, skipping auto-preview'); + return; + } + + const projects = await this.projectRepository.findProjectsByInitiativeId(initiativeId); + if (projects.length !== 1) { + log.debug( + { initiativeId, projectCount: projects.length }, + 'auto-preview requires exactly one project', + ); + return; + } + + const branch = phaseBranchName(initiative.branch, phase.name); + + log.info( + { initiativeId, phaseId, branch, projectId: projects[0].id }, + 'auto-starting preview for pending_review phase', + ); + + await this.start({ + initiativeId, + phaseId, + projectId: projects[0].id, + branch, + mode: 'preview', + }); + } catch (error) { + log.warn({ err: error }, 'auto-preview failed (best-effort)'); + } + }, + ); + } + + /** + * Build gateway routes from a preview config. + */ + private buildRoutes(previewId: string, config: PreviewConfig): GatewayRoute[] { + const routes: GatewayRoute[] = []; + + for (const [name, svc] of Object.entries(config.services)) { + if (svc.internal) continue; + + routes.push({ + containerName: `cw-preview-${previewId}-${name}`, + port: svc.port, + route: svc.route ?? '/', + }); + } + + return routes; } /** @@ -320,7 +508,8 @@ export class PreviewManager { const initiativeId = labels[PREVIEW_LABELS.initiativeId]; const projectId = labels[PREVIEW_LABELS.projectId]; const branch = labels[PREVIEW_LABELS.branch]; - const port = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10); + const gatewayPort = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10); + const mode = (labels[PREVIEW_LABELS.mode] as 'preview' | 'dev') ?? 'preview'; if (!initiativeId || !projectId || !branch) { return null; @@ -333,7 +522,9 @@ export class PreviewManager { phaseId: labels[PREVIEW_LABELS.phaseId], projectId, branch, - port, + gatewayPort, + url: `http://${previewId}.localhost:${gatewayPort}`, + mode, status: 'running', services: [], composePath, diff --git a/apps/server/preview/port-allocator.test.ts b/apps/server/preview/port-allocator.test.ts index d01c6d2..b94a9e3 100644 --- a/apps/server/preview/port-allocator.test.ts +++ b/apps/server/preview/port-allocator.test.ts @@ -1,42 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createServer } from 'node:net'; -// Mock the docker-client module to avoid actual Docker calls -vi.mock('./docker-client.js', () => ({ - getPreviewPorts: vi.fn(), -})); - import { allocatePort } from './port-allocator.js'; -import { getPreviewPorts } from './docker-client.js'; - -const mockedGetPreviewPorts = vi.mocked(getPreviewPorts); describe('allocatePort', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('returns BASE_PORT (9100) when no ports are in use', async () => { - mockedGetPreviewPorts.mockResolvedValue([]); + it('returns BASE_PORT (9100) when the port is available', async () => { const port = await allocatePort(); expect(port).toBe(9100); }); - it('skips ports already used by previews', async () => { - mockedGetPreviewPorts.mockResolvedValue([9100, 9101]); - const port = await allocatePort(); - expect(port).toBe(9102); - }); - - it('skips non-contiguous used ports', async () => { - mockedGetPreviewPorts.mockResolvedValue([9100, 9103]); - const port = await allocatePort(); - expect(port).toBe(9101); - }); - it('skips a port that is bound by another process', async () => { - mockedGetPreviewPorts.mockResolvedValue([]); - // Bind port 9100 to simulate external use const server = createServer(); await new Promise((resolve) => { diff --git a/apps/server/preview/port-allocator.ts b/apps/server/preview/port-allocator.ts index 5475cf8..a06b6ac 100644 --- a/apps/server/preview/port-allocator.ts +++ b/apps/server/preview/port-allocator.ts @@ -1,12 +1,12 @@ /** * Port Allocator * - * Finds the next available port for a preview deployment. - * Queries running preview containers and performs a bind test. + * Finds the next available port for the gateway. + * Only called once when the gateway first starts — subsequent previews + * reuse the same gateway port. */ import { createServer } from 'node:net'; -import { getPreviewPorts } from './docker-client.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('preview:port'); @@ -18,22 +18,13 @@ const BASE_PORT = 9100; const MAX_PORT = 9200; /** - * Allocate the next available port for a preview deployment. - * - * 1. Queries running preview containers for their cw.port labels - * 2. Finds the next port >= BASE_PORT that isn't in use - * 3. Performs a bind test to verify no external conflict + * Allocate the next available port by performing a bind test. * * @returns An available port number * @throws If no port is available in the range */ export async function allocatePort(): Promise { - const usedPorts = new Set(await getPreviewPorts()); - log.debug({ usedPorts: Array.from(usedPorts) }, 'ports in use by previews'); - for (let port = BASE_PORT; port < MAX_PORT; port++) { - if (usedPorts.has(port)) continue; - if (await isPortAvailable(port)) { log.info({ port }, 'allocated port'); return port; diff --git a/apps/server/preview/types.ts b/apps/server/preview/types.ts index b691c32..412bf07 100644 --- a/apps/server/preview/types.ts +++ b/apps/server/preview/types.ts @@ -5,6 +5,16 @@ * Docker IS the source of truth — no database table needed. */ +/** + * Dev mode configuration for a service. + * Used when running from an agent's worktree with hot-reload. + */ +export interface PreviewServiceDevConfig { + image: string; + command?: string; + workdir?: string; +} + /** * Service configuration within a preview deployment. */ @@ -18,6 +28,7 @@ export interface PreviewServiceConfig { healthcheck?: { path: string; interval?: string; retries?: number }; env?: Record; volumes?: string[]; + dev?: PreviewServiceDevConfig; } /** @@ -41,7 +52,9 @@ export interface PreviewStatus { phaseId?: string; projectId: string; branch: string; - port: number; + gatewayPort: number; + url: string; + mode: 'preview' | 'dev'; status: 'building' | 'running' | 'stopped' | 'failed'; services: Array<{ name: string; state: string; health: string }>; composePath: string; @@ -59,6 +72,7 @@ export const PREVIEW_LABELS = { projectId: `${PREVIEW_LABEL_PREFIX}.project-id`, port: `${PREVIEW_LABEL_PREFIX}.port`, previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`, + mode: `${PREVIEW_LABEL_PREFIX}.mode`, } as const; /** @@ -66,6 +80,12 @@ export const PREVIEW_LABELS = { */ export const COMPOSE_PROJECT_PREFIX = 'cw-preview-'; +/** + * Gateway compose project name and shared Docker network. + */ +export const GATEWAY_PROJECT_NAME = 'cw-preview-gateway'; +export const GATEWAY_NETWORK = 'cw-preview-net'; + /** * Options for starting a preview deployment. */ @@ -74,6 +94,8 @@ export interface StartPreviewOptions { phaseId?: string; projectId: string; branch: string; + mode?: 'preview' | 'dev'; + worktreePath?: string; } /** diff --git a/apps/server/preview/worktree.ts b/apps/server/preview/worktree.ts new file mode 100644 index 0000000..20daba8 --- /dev/null +++ b/apps/server/preview/worktree.ts @@ -0,0 +1,49 @@ +/** + * Preview Worktree Helper + * + * Creates and removes git worktrees for preview deployments. + * Preview mode checks out a specific branch into a temp directory + * so the Docker build runs against the correct code. + */ + +import { simpleGit } from 'simple-git'; +import { createModuleLogger } from '../logger/index.js'; + +const log = createModuleLogger('preview:worktree'); + +/** + * Create a git worktree at the specified destination, checking out the given branch. + * Does NOT create a new branch — the branch must already exist. + * + * @param repoPath - Path to the git repository (bare clone) + * @param branch - Branch to check out + * @param destPath - Where to create the worktree + */ +export async function createPreviewWorktree( + repoPath: string, + branch: string, + destPath: string, +): Promise { + log.info({ repoPath, branch, destPath }, 'creating preview worktree'); + const git = simpleGit(repoPath); + await git.raw(['worktree', 'add', destPath, branch]); +} + +/** + * Remove a git worktree. + * + * @param repoPath - Path to the git repository + * @param worktreePath - Path of the worktree to remove + */ +export async function removePreviewWorktree( + repoPath: string, + worktreePath: string, +): Promise { + log.info({ repoPath, worktreePath }, 'removing preview worktree'); + try { + const git = simpleGit(repoPath); + await git.raw(['worktree', 'remove', worktreePath, '--force']); + } catch (error) { + log.warn({ worktreePath, err: error }, 'failed to remove worktree (may not exist)'); + } +} diff --git a/apps/server/trpc/routers/preview.ts b/apps/server/trpc/routers/preview.ts index e7b8ebd..206bda6 100644 --- a/apps/server/trpc/routers/preview.ts +++ b/apps/server/trpc/routers/preview.ts @@ -14,6 +14,8 @@ export function previewProcedures(publicProcedure: ProcedureBuilder) { phaseId: z.string().min(1).optional(), projectId: z.string().min(1), branch: z.string().min(1), + mode: z.enum(['preview', 'dev']).default('preview'), + worktreePath: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const previewManager = requirePreviewManager(ctx); diff --git a/apps/web/src/components/review/PreviewPanel.tsx b/apps/web/src/components/review/PreviewPanel.tsx deleted file mode 100644 index caef75a..0000000 --- a/apps/web/src/components/review/PreviewPanel.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useState } from "react"; -import { - Loader2, - ExternalLink, - Square, - RotateCcw, - CircleDot, - CircleX, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { trpc } from "@/lib/trpc"; -import { toast } from "sonner"; - -interface PreviewPanelProps { - initiativeId: string; - phaseId?: string; - projectId: string; - branch: string; -} - -export function PreviewPanel({ - initiativeId, - phaseId, - projectId, - branch, -}: PreviewPanelProps) { - const [activePreviewId, setActivePreviewId] = useState(null); - - // Check for existing previews for this initiative - const previewsQuery = trpc.listPreviews.useQuery( - { initiativeId }, - { refetchInterval: activePreviewId ? 3000 : false }, - ); - - const existingPreview = previewsQuery.data?.find( - (p) => p.phaseId === phaseId || (!phaseId && p.initiativeId === initiativeId), - ); - - const previewStatusQuery = trpc.getPreviewStatus.useQuery( - { previewId: activePreviewId ?? existingPreview?.id ?? "" }, - { - enabled: !!(activePreviewId ?? existingPreview?.id), - refetchInterval: 3000, - }, - ); - - const preview = previewStatusQuery.data ?? existingPreview; - - const startMutation = trpc.startPreview.useMutation({ - onSuccess: (data) => { - setActivePreviewId(data.id); - toast.success(`Preview running at http://localhost:${data.port}`); - }, - onError: (err) => { - toast.error(`Preview failed: ${err.message}`); - }, - }); - - const stopMutation = trpc.stopPreview.useMutation({ - onSuccess: () => { - setActivePreviewId(null); - toast.success("Preview stopped"); - previewsQuery.refetch(); - }, - onError: (err) => { - toast.error(`Failed to stop preview: ${err.message}`); - }, - }); - - const handleStart = () => { - startMutation.mutate({ initiativeId, phaseId, projectId, branch }); - }; - - const handleStop = () => { - const id = activePreviewId ?? existingPreview?.id; - if (id) { - stopMutation.mutate({ previewId: id }); - } - }; - - // Building state - if (startMutation.isPending) { - return ( -
- -
-

- Building preview... -

-

- Building containers and starting services -

-
-
- ); - } - - // Running state - if (preview && (preview.status === "running" || preview.status === "building")) { - const url = `http://localhost:${preview.port}`; - const isBuilding = preview.status === "building"; - - return ( -
- {isBuilding ? ( - - ) : ( - - )} -
-

- {isBuilding ? "Building..." : "Preview running"} -

- {!isBuilding && ( - - {url} - - - )} -
- -
- ); - } - - // Failed state - if (preview && preview.status === "failed") { - return ( -
- -
-

- Preview failed -

-
- -
- ); - } - - // No preview — show start button - return ( - - ); -} diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 0d0d492..d2c7bd7 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -94,7 +94,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { const startPreview = trpc.startPreview.useMutation({ onSuccess: (data) => { setActivePreviewId(data.id); - toast.success(`Preview running at http://localhost:${data.port}`); + toast.success(`Preview running at ${data.url}`); }, onError: (err) => toast.error(`Preview failed: ${err.message}`), }); @@ -119,7 +119,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { : preview?.status === "failed" ? ("failed" as const) : ("idle" as const), - url: preview?.port ? `http://localhost:${preview.port}` : undefined, + url: preview?.url ?? undefined, onStart: () => startPreview.mutate({ initiativeId, diff --git a/docs/preview.md b/docs/preview.md index ea8bd78..a22dfa4 100644 --- a/docs/preview.md +++ b/docs/preview.md @@ -4,27 +4,92 @@ ## Overview -When a phase enters `pending_review`, reviewers can spin up the app at a specific branch in local Docker containers, accessible through a single port via a Caddy reverse proxy. +Preview deployments let reviewers spin up a branch in local Docker containers. A single shared **Caddy gateway** handles subdomain routing for all active previews, accessed at `http://.localhost:`. + +Two modes: +- **Preview mode**: Checks out target branch into a git worktree, builds Docker images, serves production-like output. +- **Dev mode**: Mounts the agent's worktree into a container with a dev server image (e.g. `node:20-alpine`), enabling hot reload. + +**Auto-start**: When a phase enters `pending_review`, a preview is automatically started for the phase's branch (if the initiative has exactly one project). **Key design decision: No database table.** Docker IS the source of truth. Instead of persisting rows, we query Docker directly via compose project names, container labels, and `docker compose` CLI commands. ## Architecture +``` +.cw-previews/ + gateway/ + docker-compose.yml ← single Caddy container, one port + Caddyfile ← regenerated on each preview add/remove + / + docker-compose.yml ← per-preview stack (no published ports) + source/ ← git worktree (preview mode only) + +Docker: + network: cw-preview-net ← external, shared by gateway + all previews + cw-preview-gateway ← Caddy on one port, subdomain routing + cw-preview- ← per-preview compose project (services only) + +Routing: + .localhost: → cw-preview--: +``` + ``` PreviewManager + ├── GatewayManager (shared Caddy gateway lifecycle + Caddyfile generation) ├── ConfigReader (discover .cw-preview.yml / compose / Dockerfile) - ├── ComposeGenerator (generate docker-compose.yml + Caddyfile) - ├── DockerClient (thin wrapper around docker compose CLI) - ├── HealthChecker (poll service healthcheck endpoints) - └── PortAllocator (find next available port 9100-9200) + ├── ComposeGenerator (generate per-preview docker-compose.yml) + ├── DockerClient (thin wrapper around docker compose CLI + network ops) + ├── HealthChecker (poll service healthcheck endpoints via subdomain URL) + ├── PortAllocator (find next available port 9100-9200 for gateway) + └── Worktree helper (git worktree add/remove for preview mode) ``` ### Lifecycle -1. **Start**: discover config → allocate port → generate compose + Caddyfile → `docker compose up --build -d` → health check → emit `preview:ready` -2. **Stop**: `docker compose down --volumes --remove-orphans` → clean up `.cw-previews//` → emit `preview:stopped` -3. **List**: `docker compose ls --filter name=cw-preview` → parse container labels → reconstruct status -4. **Shutdown**: `stopAll()` called on server shutdown to prevent orphaned containers +1. **Start**: ensure gateway → discover config → create worktree (preview) or use provided path (dev) → generate compose → `docker compose up --build -d` → update gateway routes → health check → emit `preview:ready` +2. **Stop**: `docker compose down --volumes --remove-orphans` → remove worktree → clean up `.cw-previews//` → update gateway routes → stop gateway if no more previews → emit `preview:stopped` +3. **List**: `docker compose ls --filter name=cw-preview` → skip gateway project → parse container labels → reconstruct status +4. **Shutdown**: `stopAll()` called on server shutdown — stops all previews, then stops gateway + +### Gateway + +The `GatewayManager` class manages a single shared Caddy container: + +- **`ensureGateway()`** — idempotent. Creates the `cw-preview-net` Docker network, checks if gateway is already running, allocates a port (9100-9200) if needed, writes compose + Caddyfile, starts Caddy with `--watch` flag. +- **`updateRoutes()`** — regenerates the full Caddyfile from all active previews. Caddy's `--watch` flag auto-reloads on file change (no `docker exec` needed). +- **`stopGateway()`** — composes down the gateway, removes the Docker network, cleans up the gateway directory. + +Gateway Caddyfile format: +``` +{ + auto_https off +} + +abc123.localhost:9100 { + handle_path /api/* { + reverse_proxy cw-preview-abc123-backend:8080 + } + handle { + reverse_proxy cw-preview-abc123-frontend:3000 + } +} + +xyz789.localhost:9100 { + handle { + reverse_proxy cw-preview-xyz789-app:3000 + } +} +``` + +Routes are sorted by specificity (longer paths first) to ensure correct matching. + +### Subdomain Routing + +Previews are accessed at `http://.localhost:`. + +- **Chrome / Firefox**: Resolve `*.localhost` to `127.0.0.1` natively. No DNS config needed. +- **Safari**: Requires a `/etc/hosts` entry: `127.0.0.1 .localhost` for each preview. ### Docker Labels @@ -37,12 +102,21 @@ All preview containers get `cw.*` labels for metadata retrieval: | `cw.phase-id` | Phase ID (optional) | | `cw.project-id` | Project ID | | `cw.branch` | Branch name | -| `cw.port` | Host port | +| `cw.port` | Gateway port | | `cw.preview-id` | Nanoid for this deployment | +| `cw.mode` | `"preview"` or `"dev"` | ### Compose Project Naming -Project names follow `cw-preview-` convention. This enables filtering via `docker compose ls --filter name=cw-preview`. +- **Gateway**: `cw-preview-gateway` (single instance) +- **Previews**: `cw-preview-` — filtered via `docker compose ls --filter name=cw-preview`, gateway excluded from listings +- **Container names**: `cw-preview--` — unique DNS names on the shared network + +### Networking + +- **`cw-preview-net`** — external Docker bridge network shared by gateway + all preview stacks +- **`internal`** — per-preview bridge network for inter-service communication +- Public services join both networks; internal services (e.g. databases) only join `internal` ## Configuration @@ -65,6 +139,10 @@ services: retries: 10 env: VITE_API_URL: /api + dev: + image: node:20-alpine + command: npm run dev -- --host 0.0.0.0 + workdir: /app backend: build: @@ -85,64 +163,68 @@ services: POSTGRES_PASSWORD: preview ``` +The `dev` section is optional per service. When present and mode is `dev`: +- `image` (required) — Docker image to run +- `command` — override entrypoint +- `workdir` — container working directory (default `/app`) + +In dev mode, the project directory is volume-mounted into the container and `node_modules` gets an anonymous volume to prevent host overwrite. + ### 2. `docker-compose.yml` / `compose.yml` (existing compose passthrough) -If found, the existing compose file is wrapped with a Caddy sidecar. +If found, the existing compose file is used with gateway network injection. ### 3. `Dockerfile` (single-service fallback) If only a Dockerfile exists, creates a single `app` service building from `.` with port 3000. -## Reverse Proxy: Caddy - -Caddy runs as a container in the same Docker network. Only Caddy publishes a port to the host. Generated Caddyfile: - -``` -:80 { - handle_path /api/* { - reverse_proxy backend:8080 - } - handle { - reverse_proxy frontend:3000 - } -} -``` - ## Module Files | File | Purpose | |------|---------| -| `types.ts` | PreviewConfig, PreviewStatus, labels, constants | -| `config-reader.ts` | Discovery + YAML parsing | -| `compose-generator.ts` | Docker Compose YAML + Caddyfile generation | -| `docker-client.ts` | Docker CLI wrapper (execa) | -| `health-checker.ts` | Service readiness polling | -| `port-allocator.ts` | Port 9100-9200 allocation with bind test | -| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll) | +| `types.ts` | PreviewConfig, PreviewStatus, labels, constants, dev config types | +| `config-reader.ts` | Discovery + YAML parsing (including `dev` section) | +| `compose-generator.ts` | Per-preview Docker Compose YAML + label generation | +| `gateway.ts` | GatewayManager class + Caddyfile generation | +| `worktree.ts` | Git worktree create/remove helpers | +| `docker-client.ts` | Docker CLI wrapper (execa) + network operations | +| `health-checker.ts` | Service readiness polling via subdomain URL | +| `port-allocator.ts` | Port 9100-9200 allocation with TCP bind test | +| `manager.ts` | PreviewManager class (start/stop/list/status/stopAll + auto-start) | | `index.ts` | Barrel exports | ## Events | Event | Payload | |-------|---------| -| `preview:building` | `{previewId, initiativeId, branch, port}` | -| `preview:ready` | `{previewId, initiativeId, branch, port, url}` | +| `preview:building` | `{previewId, initiativeId, branch, gatewayPort, mode, phaseId?}` | +| `preview:ready` | `{previewId, initiativeId, branch, gatewayPort, url, mode, phaseId?}` | | `preview:stopped` | `{previewId, initiativeId}` | | `preview:failed` | `{previewId, initiativeId, error}` | +## Auto-Start + +`PreviewManager.setupEventListeners()` listens for `phase:pending_review` events: +1. Loads the initiative and its projects +2. If exactly one project: auto-starts a preview in `preview` mode +3. Branch is derived from `phaseBranchName(initiative.branch, phase.name)` +4. Errors are caught and logged (best-effort, never blocks the phase transition) + ## tRPC Procedures | Procedure | Type | Input | |-----------|------|-------| -| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch}` | +| `startPreview` | mutation | `{initiativeId, phaseId?, projectId, branch, mode?, worktreePath?}` | | `stopPreview` | mutation | `{previewId}` | | `listPreviews` | query | `{initiativeId?}` | | `getPreviewStatus` | query | `{previewId}` | +`mode` defaults to `'preview'`. Set to `'dev'` with a `worktreePath` for dev mode. + ## CLI Commands ``` -cw preview start --initiative --project --branch [--phase ] +cw preview start --initiative --project --branch [--phase ] [--mode preview|dev] cw preview stop cw preview list [--initiative ] cw preview status @@ -150,17 +232,17 @@ cw preview status ## Frontend -`PreviewPanel` component in the Review tab: +The Review tab shows preview status inline: - **No preview**: "Start Preview" button - **Building**: Spinner + "Building preview..." -- **Running**: Green dot + `http://localhost:` link + Stop button +- **Running**: Green dot + `http://.localhost:` link + Stop button - **Failed**: Error message + Retry button Polls `getPreviewStatus` with `refetchInterval: 3000` while active. ## Container Wiring -- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot)` +- `PreviewManager` instantiated in `apps/server/container.ts` with `(projectRepository, eventBus, workspaceRoot, phaseRepository, initiativeRepository)` - Added to `Container` interface and `toContextDeps()` - `GracefulShutdown` calls `previewManager.stopAll()` during shutdown - `requirePreviewManager(ctx)` helper in `apps/server/trpc/routers/_helpers.ts` @@ -168,4 +250,5 @@ Polls `getPreviewStatus` with `refetchInterval: 3000` while active. ## Dependencies - `js-yaml` + `@types/js-yaml` — for parsing `.cw-preview.yml` +- `simple-git` — for git worktree operations - Docker must be installed and running on the host