/** * Preview Manager * * Orchestrates preview deployment lifecycle: start, stop, list, status. * Uses Docker as 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 { EventBus } from '../events/types.js'; import type { PreviewStatus, StartPreviewOptions, } 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 { isDockerAvailable, composeUp, composeDown, composePs, listPreviewProjects, getContainerLabels, } from './docker-client.js'; import { waitForHealthy } from './health-checker.js'; import { allocatePort } from './port-allocator.js'; import { getProjectCloneDir } from '../git/project-clones.js'; import { createModuleLogger } from '../logger/index.js'; import type { PreviewBuildingEvent, PreviewReadyEvent, PreviewStoppedEvent, PreviewFailedEvent, } from '../events/types.js'; const log = createModuleLogger('preview'); /** Directory for preview deployment artifacts (relative to workspace root) */ const PREVIEWS_DIR = '.cw-previews'; export class PreviewManager { private readonly projectRepository: ProjectRepository; private readonly eventBus: EventBus; private readonly workspaceRoot: string; constructor( projectRepository: ProjectRepository, eventBus: EventBus, workspaceRoot: string, ) { this.projectRepository = projectRepository; this.eventBus = eventBus; this.workspaceRoot = workspaceRoot; } /** * 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 * 7. Emit events and return status */ async start(options: StartPreviewOptions): Promise { // 1. Check Docker if (!(await isDockerAvailable())) { throw new Error( 'Docker is not available. Please ensure Docker is installed and running.', ); } // 2. Resolve project const project = await this.projectRepository.findById(options.projectId); if (!project) { throw new Error(`Project '${options.projectId}' not found`); } const clonePath = join( this.workspaceRoot, getProjectCloneDir(project.name, project.id), ); // 3. Discover config const config = await discoverConfig(clonePath); // 4. Allocate port and generate ID const port = await allocatePort(); 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'); 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'); this.eventBus.emit({ type: 'preview:failed', timestamp: new Date(), payload: { previewId: id, initiativeId: options.initiativeId, error: (error as Error).message, }, }); // Clean up await composeDown(projectName).catch(() => {}); await rm(deployDir, { recursive: true, force: true }).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, }; } /** * Stop a preview deployment and clean up artifacts. */ async stop(previewId: string): Promise { const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; // Get labels before stopping to emit event const labels = await getContainerLabels(projectName); const initiativeId = labels[PREVIEW_LABELS.initiativeId] ?? ''; await composeDown(projectName); // Clean up deployment directory const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, previewId); await rm(deployDir, { recursive: true, force: true }).catch(() => {}); log.info({ previewId, projectName }, 'preview stopped'); this.eventBus.emit({ type: 'preview:stopped', timestamp: new Date(), payload: { previewId, initiativeId }, }); } /** * List all active preview deployments, optionally filtered by initiative. */ async list(initiativeId?: string): Promise { const projects = await listPreviewProjects(); const previews: PreviewStatus[] = []; for (const project of projects) { const labels = await getContainerLabels(project.Name); if (!labels[PREVIEW_LABELS.preview]) continue; const preview = this.labelsToStatus(project.Name, labels, project.ConfigFiles); if (!preview) continue; if (initiativeId && preview.initiativeId !== initiativeId) continue; // Get service statuses preview.services = await composePs(project.Name); previews.push(preview); } return previews; } /** * Get the status of a specific preview deployment. */ async getStatus(previewId: string): Promise { const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`; const labels = await getContainerLabels(projectName); if (!labels[PREVIEW_LABELS.preview]) { return null; } const preview = this.labelsToStatus(projectName, labels, ''); if (!preview) return null; preview.services = await composePs(projectName); // Determine status from service states if (preview.services.length === 0) { preview.status = 'stopped'; } else if (preview.services.every((s) => s.state === 'running')) { preview.status = 'running'; } else if (preview.services.some((s) => s.state === 'exited' || s.state === 'dead')) { preview.status = 'failed'; } else { preview.status = 'building'; } return preview; } /** * Stop all preview deployments. Called on server shutdown. */ async stopAll(): Promise { const projects = await listPreviewProjects(); log.info({ count: projects.length }, 'stopping all preview deployments'); await Promise.all( projects.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'); }); }), ); } /** * Reconstruct PreviewStatus from Docker container labels. */ private labelsToStatus( projectName: string, labels: Record, composePath: string, ): PreviewStatus | null { const previewId = labels[PREVIEW_LABELS.previewId] ?? projectName.replace(COMPOSE_PROJECT_PREFIX, ''); 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); if (!initiativeId || !projectId || !branch) { return null; } return { id: previewId, projectName, initiativeId, phaseId: labels[PREVIEW_LABELS.phaseId], projectId, branch, port, status: 'running', services: [], composePath, }; } }