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
534 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|