Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
/**
|
|
* 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/<id>/
|
|
* 6. Run composeUp, wait for healthy
|
|
* 7. Emit events and return status
|
|
*/
|
|
async start(options: StartPreviewOptions): Promise<PreviewStatus> {
|
|
// 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<PreviewBuildingEvent>({
|
|
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<PreviewFailedEvent>({
|
|
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<PreviewFailedEvent>({
|
|
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<PreviewReadyEvent>({
|
|
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<void> {
|
|
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<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) {
|
|
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. Called on server shutdown.
|
|
*/
|
|
async stopAll(): Promise<void> {
|
|
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<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 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,
|
|
};
|
|
}
|
|
}
|