Files
Codewalkers/apps/server/preview/manager.ts
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
2026-03-05 12:22:29 +01:00

534 lines
17 KiB
TypeScript

/**
* Preview Manager
*
* Orchestrates preview deployment lifecycle: start, stop, list, status.
* 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, generateLabels } from './compose-generator.js';
import {
isDockerAvailable,
composeUp,
composeDown,
composePs,
listPreviewProjects,
getContainerLabels,
} from './docker-client.js';
import { waitForHealthy } from './health-checker.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');
/** 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;
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<string, GatewayRoute[]>();
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. 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<PreviewStatus> {
const mode = options.mode ?? 'preview';
// 1. Check Docker
if (!(await isDockerAvailable())) {
throw new Error(
'Docker is not available. Please ensure Docker is installed and running.',
);
}
// 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`);
}
const clonePath = join(
this.workspaceRoot,
getProjectCloneDir(project.name, project.id),
);
// 4. Generate ID and prepare deploy dir
const id = nanoid(10);
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
await mkdir(deployDir, { recursive: true });
let sourcePath: string;
let worktreeCreated = false;
try {
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)');
}
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<PreviewBuildingEvent>({
type: 'preview:building',
timestamp: new Date(),
payload: {
previewId: id,
initiativeId: options.initiativeId,
branch: options.branch,
gatewayPort,
mode,
phaseId: options.phaseId,
},
});
// 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<PreviewFailedEvent>({
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<PreviewReadyEvent>({
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<PreviewFailedEvent>({
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}`);
}
}
/**
* Stop a preview deployment and clean up artifacts.
*/
async stop(previewId: string): Promise<void> {
const projectName = `${COMPOSE_PROJECT_PREFIX}${previewId}`;
// 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);
// 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<PreviewStoppedEvent>({
type: 'preview:stopped',
timestamp: new Date(),
payload: { previewId, initiativeId },
});
}
/**
* List all active preview deployments, optionally filtered by initiative.
*/
async list(initiativeId?: string): Promise<PreviewStatus[]> {
const projects = await listPreviewProjects();
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;
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<PreviewStatus | null> {
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, then stop the gateway.
*/
async stopAll(): Promise<void> {
const projects = await listPreviewProjects();
const previewProjects = projects.filter((p) => p.Name !== 'cw-preview-gateway');
log.info({ count: previewProjects.length }, 'stopping all preview deployments');
await Promise.all(
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<PhasePendingReviewEvent>(
'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;
}
/**
* Reconstruct PreviewStatus from Docker container labels.
*/
private labelsToStatus(
projectName: string,
labels: Record<string, string>,
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 gatewayPort = parseInt(labels[PREVIEW_LABELS.port] ?? '0', 10);
const mode = (labels[PREVIEW_LABELS.mode] as 'preview' | 'dev') ?? 'preview';
if (!initiativeId || !projectId || !branch) {
return null;
}
return {
id: previewId,
projectName,
initiativeId,
phaseId: labels[PREVIEW_LABELS.phaseId],
projectId,
branch,
gatewayPort,
url: `http://${previewId}.localhost:${gatewayPort}`,
mode,
status: 'running',
services: [],
composePath,
};
}
}