refactor: Restructure monorepo to apps/server/ and apps/web/ layout
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
This commit is contained in:
207
apps/server/preview/compose-generator.test.ts
Normal file
207
apps/server/preview/compose-generator.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import yaml from 'js-yaml';
|
||||
import {
|
||||
generateComposeFile,
|
||||
generateCaddyfile,
|
||||
generateLabels,
|
||||
} from './compose-generator.js';
|
||||
import type { PreviewConfig } from './types.js';
|
||||
|
||||
describe('generateComposeFile', () => {
|
||||
const baseOpts = {
|
||||
projectPath: '/workspace/repos/my-project-abc123',
|
||||
port: 9100,
|
||||
deploymentId: 'test123',
|
||||
labels: {
|
||||
'cw.preview': 'true',
|
||||
'cw.initiative-id': 'init-1',
|
||||
'cw.port': '9100',
|
||||
},
|
||||
};
|
||||
|
||||
it('generates valid compose YAML with user services and Caddy proxy', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: {
|
||||
name: 'app',
|
||||
build: '.',
|
||||
port: 3000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
// Has both user service and caddy
|
||||
expect(parsed.services.app).toBeDefined();
|
||||
expect(parsed.services['caddy-proxy']).toBeDefined();
|
||||
|
||||
// Network present
|
||||
expect(parsed.networks.preview).toBeDefined();
|
||||
|
||||
// Caddy publishes port
|
||||
expect(parsed.services['caddy-proxy'].ports).toContain('9100:80');
|
||||
|
||||
// Labels propagated
|
||||
expect(parsed.services.app.labels['cw.preview']).toBe('true');
|
||||
});
|
||||
|
||||
it('handles object build config with context path joining', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
api: {
|
||||
name: 'api',
|
||||
build: { context: 'packages/api', dockerfile: 'Dockerfile.prod' },
|
||||
port: 8080,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
expect(parsed.services.api.build.context).toBe(
|
||||
'/workspace/repos/my-project-abc123/packages/api',
|
||||
);
|
||||
expect(parsed.services.api.build.dockerfile).toBe('Dockerfile.prod');
|
||||
});
|
||||
|
||||
it('handles image-based services', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
db: {
|
||||
name: 'db',
|
||||
image: 'postgres:16',
|
||||
port: 5432,
|
||||
internal: true,
|
||||
env: { POSTGRES_PASSWORD: 'test' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
expect(parsed.services.db.image).toBe('postgres:16');
|
||||
expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test');
|
||||
});
|
||||
|
||||
it('caddy depends on all user services', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
frontend: { name: 'frontend', build: '.', port: 3000 },
|
||||
backend: { name: 'backend', build: '.', port: 8080 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateComposeFile(config, baseOpts);
|
||||
const parsed = yaml.load(result) as Record<string, any>;
|
||||
|
||||
expect(parsed.services['caddy-proxy'].depends_on).toContain('frontend');
|
||||
expect(parsed.services['caddy-proxy'].depends_on).toContain('backend');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCaddyfile', () => {
|
||||
it('generates simple single-service Caddyfile', () => {
|
||||
const config: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: { name: 'app', build: '.', port: 3000 },
|
||||
},
|
||||
};
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
expect(caddyfile).toContain(':80 {');
|
||||
expect(caddyfile).toContain('reverse_proxy app:3000');
|
||||
expect(caddyfile).toContain('}');
|
||||
});
|
||||
|
||||
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 caddyfile = generateCaddyfile(config);
|
||||
expect(caddyfile).toContain('handle_path /api/*');
|
||||
expect(caddyfile).toContain('reverse_proxy backend:8080');
|
||||
expect(caddyfile).toContain('handle {');
|
||||
expect(caddyfile).toContain('reverse_proxy 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 },
|
||||
},
|
||||
};
|
||||
|
||||
const caddyfile = generateCaddyfile(config);
|
||||
expect(caddyfile).not.toContain('postgres');
|
||||
expect(caddyfile).not.toContain('db:5432');
|
||||
});
|
||||
|
||||
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 caddyfile = generateCaddyfile(config);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateLabels', () => {
|
||||
it('generates correct labels', () => {
|
||||
const labels = generateLabels({
|
||||
initiativeId: 'init-1',
|
||||
phaseId: 'phase-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'feature/test',
|
||||
port: 9100,
|
||||
previewId: 'abc123',
|
||||
});
|
||||
|
||||
expect(labels['cw.preview']).toBe('true');
|
||||
expect(labels['cw.initiative-id']).toBe('init-1');
|
||||
expect(labels['cw.phase-id']).toBe('phase-1');
|
||||
expect(labels['cw.project-id']).toBe('proj-1');
|
||||
expect(labels['cw.branch']).toBe('feature/test');
|
||||
expect(labels['cw.port']).toBe('9100');
|
||||
expect(labels['cw.preview-id']).toBe('abc123');
|
||||
});
|
||||
|
||||
it('omits phaseId label when not provided', () => {
|
||||
const labels = generateLabels({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
port: 9100,
|
||||
previewId: 'abc123',
|
||||
});
|
||||
|
||||
expect(labels['cw.phase-id']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
191
apps/server/preview/compose-generator.ts
Normal file
191
apps/server/preview/compose-generator.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
|
||||
import { PREVIEW_LABELS } from './types.js';
|
||||
|
||||
export interface ComposeGeneratorOptions {
|
||||
projectPath: string;
|
||||
port: number;
|
||||
deploymentId: string;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ComposeService {
|
||||
build?: { context: string; dockerfile: string } | string;
|
||||
image?: string;
|
||||
environment?: Record<string, string>;
|
||||
volumes?: string[];
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
depends_on?: string[];
|
||||
}
|
||||
|
||||
interface ComposeFile {
|
||||
services: Record<string, ComposeService>;
|
||||
networks: Record<string, { driver: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Docker Compose YAML string for the preview deployment.
|
||||
*
|
||||
* Structure:
|
||||
* - User-defined services with build contexts
|
||||
* - Caddy reverse proxy publishing the single host port
|
||||
* - Shared `preview` network
|
||||
*/
|
||||
export function generateComposeFile(
|
||||
config: PreviewConfig,
|
||||
opts: ComposeGeneratorOptions,
|
||||
): string {
|
||||
const compose: ComposeFile = {
|
||||
services: {},
|
||||
networks: {
|
||||
preview: { driver: 'bridge' },
|
||||
},
|
||||
};
|
||||
|
||||
const serviceNames: string[] = [];
|
||||
|
||||
// Add user-defined services
|
||||
for (const [name, svc] of Object.entries(config.services)) {
|
||||
serviceNames.push(name);
|
||||
const service: ComposeService = {
|
||||
labels: { ...opts.labels },
|
||||
networks: ['preview'],
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
} else if (svc.image) {
|
||||
service.image = svc.image;
|
||||
}
|
||||
|
||||
// Environment
|
||||
if (svc.env && Object.keys(svc.env).length > 0) {
|
||||
service.environment = svc.env;
|
||||
}
|
||||
|
||||
// Volumes
|
||||
if (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<string, unknown>).ports = [`${opts.port}:80`];
|
||||
|
||||
// Mount Caddyfile via inline config
|
||||
(caddyService as Record<string, unknown>).command = ['caddy', 'run', '--config', '/etc/caddy/Caddyfile'];
|
||||
|
||||
// Caddy config will be written to the deployment directory and mounted
|
||||
(caddyService as Record<string, unknown>).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.
|
||||
*/
|
||||
export function generateLabels(opts: {
|
||||
initiativeId: string;
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
previewId: string;
|
||||
}): Record<string, string> {
|
||||
const labels: Record<string, string> = {
|
||||
[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.previewId]: opts.previewId,
|
||||
};
|
||||
|
||||
if (opts.phaseId) {
|
||||
labels[PREVIEW_LABELS.phaseId] = opts.phaseId;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
115
apps/server/preview/config-reader.test.ts
Normal file
115
apps/server/preview/config-reader.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCwPreviewConfig } from './config-reader.js';
|
||||
|
||||
describe('parseCwPreviewConfig', () => {
|
||||
it('parses minimal single-service config', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
app:
|
||||
build: "."
|
||||
port: 3000
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.version).toBe(1);
|
||||
expect(Object.keys(config.services)).toEqual(['app']);
|
||||
expect(config.services.app.port).toBe(3000);
|
||||
expect(config.services.app.build).toBe('.');
|
||||
});
|
||||
|
||||
it('parses multi-service config with routes and healthchecks', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: "."
|
||||
dockerfile: apps/web/Dockerfile
|
||||
port: 3000
|
||||
route: /
|
||||
healthcheck:
|
||||
path: /
|
||||
interval: 5s
|
||||
retries: 10
|
||||
env:
|
||||
VITE_API_URL: /api
|
||||
backend:
|
||||
build:
|
||||
context: "."
|
||||
dockerfile: packages/api/Dockerfile
|
||||
port: 8080
|
||||
route: /api
|
||||
healthcheck:
|
||||
path: /health
|
||||
env:
|
||||
DATABASE_URL: "postgres://db:5432/app"
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
port: 5432
|
||||
internal: true
|
||||
env:
|
||||
POSTGRES_PASSWORD: preview
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
|
||||
expect(Object.keys(config.services)).toHaveLength(3);
|
||||
|
||||
// Frontend
|
||||
expect(config.services.frontend.port).toBe(3000);
|
||||
expect(config.services.frontend.route).toBe('/');
|
||||
expect(config.services.frontend.healthcheck?.path).toBe('/');
|
||||
expect(config.services.frontend.healthcheck?.retries).toBe(10);
|
||||
expect(config.services.frontend.env?.VITE_API_URL).toBe('/api');
|
||||
expect(config.services.frontend.build).toEqual({
|
||||
context: '.',
|
||||
dockerfile: 'apps/web/Dockerfile',
|
||||
});
|
||||
|
||||
// Backend
|
||||
expect(config.services.backend.port).toBe(8080);
|
||||
expect(config.services.backend.route).toBe('/api');
|
||||
|
||||
// DB (internal)
|
||||
expect(config.services.db.internal).toBe(true);
|
||||
expect(config.services.db.image).toBe('postgres:16-alpine');
|
||||
});
|
||||
|
||||
it('rejects config without services', () => {
|
||||
expect(() => parseCwPreviewConfig('version: 1\n')).toThrow('missing "services"');
|
||||
});
|
||||
|
||||
it('rejects service without port (unless internal)', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
app:
|
||||
build: "."
|
||||
`;
|
||||
expect(() => parseCwPreviewConfig(raw)).toThrow('must specify a "port"');
|
||||
});
|
||||
|
||||
it('allows internal service without port', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
redis:
|
||||
image: redis:7
|
||||
internal: true
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.services.redis.internal).toBe(true);
|
||||
expect(config.services.redis.port).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes string build to string', () => {
|
||||
const raw = `
|
||||
version: 1
|
||||
services:
|
||||
app:
|
||||
build: "./app"
|
||||
port: 3000
|
||||
`;
|
||||
const config = parseCwPreviewConfig(raw);
|
||||
expect(config.services.app.build).toBe('./app');
|
||||
});
|
||||
});
|
||||
164
apps/server/preview/config-reader.ts
Normal file
164
apps/server/preview/config-reader.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Preview Config Reader
|
||||
*
|
||||
* Discovers and parses preview configuration from a project directory.
|
||||
* Discovery order: .cw-preview.yml → docker-compose.yml/compose.yml → Dockerfile
|
||||
*/
|
||||
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:config');
|
||||
|
||||
/** Files to check for existing Docker Compose config */
|
||||
const COMPOSE_FILES = [
|
||||
'docker-compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'compose.yml',
|
||||
'compose.yaml',
|
||||
];
|
||||
|
||||
/**
|
||||
* Discover and parse preview configuration from a project directory.
|
||||
*
|
||||
* Discovery order:
|
||||
* 1. `.cw-preview.yml` — explicit CW preview config
|
||||
* 2. `docker-compose.yml` / `compose.yml` (+ variants) — wrap existing compose
|
||||
* 3. `Dockerfile` at root — single-service fallback (assumes port 3000)
|
||||
*
|
||||
* @param projectPath - Absolute path to the project directory (at the target branch)
|
||||
* @returns Parsed and normalized PreviewConfig
|
||||
* @throws If no config can be discovered
|
||||
*/
|
||||
export async function discoverConfig(projectPath: string): Promise<PreviewConfig> {
|
||||
// 1. Check for explicit .cw-preview.yml
|
||||
const cwPreviewPath = join(projectPath, '.cw-preview.yml');
|
||||
if (await fileExists(cwPreviewPath)) {
|
||||
log.info({ path: cwPreviewPath }, 'found .cw-preview.yml');
|
||||
const raw = await readFile(cwPreviewPath, 'utf-8');
|
||||
return parseCwPreviewConfig(raw);
|
||||
}
|
||||
|
||||
// 2. Check for existing compose files
|
||||
for (const composeFile of COMPOSE_FILES) {
|
||||
const composePath = join(projectPath, composeFile);
|
||||
if (await fileExists(composePath)) {
|
||||
log.info({ path: composePath }, 'found existing compose file');
|
||||
return parseExistingCompose(composePath, composeFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for Dockerfile
|
||||
const dockerfilePath = join(projectPath, 'Dockerfile');
|
||||
if (await fileExists(dockerfilePath)) {
|
||||
log.info({ path: dockerfilePath }, 'found Dockerfile, using single-service fallback');
|
||||
return createDockerfileFallback();
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No preview configuration found in ${projectPath}. ` +
|
||||
`Expected one of: .cw-preview.yml, docker-compose.yml, compose.yml, or Dockerfile`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `.cw-preview.yml` file into a PreviewConfig.
|
||||
*/
|
||||
export function parseCwPreviewConfig(raw: string): PreviewConfig {
|
||||
const parsed = yaml.load(raw) as Record<string, unknown>;
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new Error('Invalid .cw-preview.yml: expected a YAML object');
|
||||
}
|
||||
|
||||
if (!parsed.services || typeof parsed.services !== 'object') {
|
||||
throw new Error('Invalid .cw-preview.yml: missing "services" key');
|
||||
}
|
||||
|
||||
const services: Record<string, PreviewServiceConfig> = {};
|
||||
const rawServices = parsed.services as Record<string, Record<string, unknown>>;
|
||||
|
||||
for (const [name, svc] of Object.entries(rawServices)) {
|
||||
if (!svc || typeof svc !== 'object') {
|
||||
throw new Error(`Invalid service "${name}": expected an object`);
|
||||
}
|
||||
|
||||
const port = svc.port as number | undefined;
|
||||
if (port === undefined && !svc.internal) {
|
||||
throw new Error(`Service "${name}" must specify a "port" (or be marked "internal: true")`);
|
||||
}
|
||||
|
||||
services[name] = {
|
||||
name,
|
||||
port: port ?? 0,
|
||||
...(svc.build !== undefined && { build: normalizeBuild(svc.build) }),
|
||||
...(svc.image !== undefined && { image: svc.image as string }),
|
||||
...(svc.route !== undefined && { route: svc.route as string }),
|
||||
...(svc.internal !== undefined && { internal: svc.internal as boolean }),
|
||||
...(svc.healthcheck !== undefined && { healthcheck: svc.healthcheck as PreviewServiceConfig['healthcheck'] }),
|
||||
...(svc.env !== undefined && { env: svc.env as Record<string, string> }),
|
||||
...(svc.volumes !== undefined && { volumes: svc.volumes as string[] }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
services,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an existing Docker Compose file as a passthrough config.
|
||||
*/
|
||||
function parseExistingCompose(composePath: string, composeFile: string): PreviewConfig {
|
||||
return {
|
||||
version: 1,
|
||||
compose: composeFile,
|
||||
services: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single-service fallback config from a Dockerfile.
|
||||
*/
|
||||
function createDockerfileFallback(): PreviewConfig {
|
||||
return {
|
||||
version: 1,
|
||||
services: {
|
||||
app: {
|
||||
name: 'app',
|
||||
build: '.',
|
||||
port: 3000,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize build config to a consistent format.
|
||||
*/
|
||||
function normalizeBuild(build: unknown): PreviewServiceConfig['build'] {
|
||||
if (typeof build === 'string') {
|
||||
return build;
|
||||
}
|
||||
if (typeof build === 'object' && build !== null) {
|
||||
const b = build as Record<string, unknown>;
|
||||
return {
|
||||
context: (b.context as string) ?? '.',
|
||||
dockerfile: (b.dockerfile as string) ?? 'Dockerfile',
|
||||
};
|
||||
}
|
||||
return '.';
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
206
apps/server/preview/docker-client.ts
Normal file
206
apps/server/preview/docker-client.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Docker Client
|
||||
*
|
||||
* Thin wrapper around Docker CLI via execa for preview lifecycle management.
|
||||
* No SDK dependency — uses `docker compose` subprocess calls.
|
||||
*/
|
||||
|
||||
import { execa } from 'execa';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
import { COMPOSE_PROJECT_PREFIX, PREVIEW_LABELS } from './types.js';
|
||||
|
||||
const log = createModuleLogger('preview:docker');
|
||||
|
||||
/**
|
||||
* Service status from `docker compose ps`.
|
||||
*/
|
||||
export interface ServiceStatus {
|
||||
name: string;
|
||||
state: string;
|
||||
health: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose project from `docker compose ls`.
|
||||
*/
|
||||
export interface ComposeProject {
|
||||
Name: string;
|
||||
Status: string;
|
||||
ConfigFiles: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker is available and running.
|
||||
*/
|
||||
export async function isDockerAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execa('docker', ['info'], { timeout: 10000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a compose project (build and run in background).
|
||||
*/
|
||||
export async function composeUp(composePath: string, projectName: string): Promise<void> {
|
||||
log.info({ composePath, projectName }, 'starting compose project');
|
||||
const cwd = composePath.substring(0, composePath.lastIndexOf('/'));
|
||||
|
||||
await execa('docker', [
|
||||
'compose',
|
||||
'-p', projectName,
|
||||
'-f', composePath,
|
||||
'up',
|
||||
'--build',
|
||||
'-d',
|
||||
], {
|
||||
cwd,
|
||||
timeout: 600000, // 10 minutes for builds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove a compose project.
|
||||
*/
|
||||
export async function composeDown(projectName: string): Promise<void> {
|
||||
log.info({ projectName }, 'stopping compose project');
|
||||
await execa('docker', [
|
||||
'compose',
|
||||
'-p', projectName,
|
||||
'down',
|
||||
'--volumes',
|
||||
'--remove-orphans',
|
||||
], {
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service statuses for a compose project.
|
||||
*/
|
||||
export async function composePs(projectName: string): Promise<ServiceStatus[]> {
|
||||
try {
|
||||
const result = await execa('docker', [
|
||||
'compose',
|
||||
'-p', projectName,
|
||||
'ps',
|
||||
'--format', 'json',
|
||||
], {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// docker compose ps --format json outputs one JSON object per line
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
return lines.map((line) => {
|
||||
const container = JSON.parse(line);
|
||||
return {
|
||||
name: container.Service || container.Name || '',
|
||||
state: container.State || 'unknown',
|
||||
health: container.Health || 'none',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn({ projectName, err: error }, 'failed to get compose ps');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all preview compose projects.
|
||||
*/
|
||||
export async function listPreviewProjects(): Promise<ComposeProject[]> {
|
||||
try {
|
||||
const result = await execa('docker', [
|
||||
'compose',
|
||||
'ls',
|
||||
'--filter', `name=${COMPOSE_PROJECT_PREFIX}`,
|
||||
'--format', 'json',
|
||||
], {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(result.stdout);
|
||||
} catch (error) {
|
||||
log.warn({ err: error }, 'failed to list preview projects');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container labels for a compose project.
|
||||
* Returns labels from the first container that has cw.preview=true.
|
||||
*/
|
||||
export async function getContainerLabels(projectName: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const result = await execa('docker', [
|
||||
'ps',
|
||||
'--filter', `label=${PREVIEW_LABELS.preview}=true`,
|
||||
'--filter', `label=com.docker.compose.project=${projectName}`,
|
||||
'--format', '{{json .Labels}}',
|
||||
], {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
if (!result.stdout.trim()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Parse the first line's label string: "key=val,key=val,..."
|
||||
const firstLine = result.stdout.trim().split('\n')[0];
|
||||
const labelStr = firstLine.replace(/^"|"$/g, '');
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const pair of labelStr.split(',')) {
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
const key = pair.substring(0, eqIdx);
|
||||
const value = pair.substring(eqIdx + 1);
|
||||
if (key.startsWith('cw.')) {
|
||||
labels[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
} catch (error) {
|
||||
log.warn({ projectName, err: error }, 'failed to get container labels');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ports of running preview containers by reading their cw.port labels.
|
||||
*/
|
||||
export async function getPreviewPorts(): Promise<number[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
102
apps/server/preview/health-checker.ts
Normal file
102
apps/server/preview/health-checker.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Health Checker
|
||||
*
|
||||
* Polls service healthcheck endpoints through the Caddy proxy port
|
||||
* to verify that preview services are ready.
|
||||
*/
|
||||
|
||||
import type { PreviewConfig, HealthResult } from './types.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:health');
|
||||
|
||||
/** Default timeout for health checks (120 seconds) */
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Default polling interval (3 seconds) */
|
||||
const DEFAULT_INTERVAL_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Wait for all non-internal services to become healthy by polling their
|
||||
* healthcheck endpoints through the Caddy proxy.
|
||||
*
|
||||
* @param port - The host port where Caddy is listening
|
||||
* @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,
|
||||
config: PreviewConfig,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<HealthResult[]> {
|
||||
const services = Object.values(config.services).filter((svc) => {
|
||||
if (svc.internal) return false;
|
||||
if (!svc.healthcheck?.path) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (services.length === 0) {
|
||||
log.info('no healthcheck endpoints configured, skipping health wait');
|
||||
return [];
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const results = new Map<string, HealthResult>();
|
||||
|
||||
// Initialize all as unhealthy
|
||||
for (const svc of services) {
|
||||
results.set(svc.name, { name: svc.name, healthy: false });
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pending = services.filter((svc) => !results.get(svc.name)!.healthy);
|
||||
if (pending.length === 0) break;
|
||||
|
||||
await Promise.all(
|
||||
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}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (response.ok) {
|
||||
log.info({ service: svc.name, url }, 'service healthy');
|
||||
results.set(svc.name, { name: svc.name, healthy: true });
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const stillPending = services.filter((svc) => !results.get(svc.name)!.healthy);
|
||||
if (stillPending.length === 0) break;
|
||||
|
||||
log.debug(
|
||||
{ pending: stillPending.map((s) => s.name) },
|
||||
'waiting for services to become healthy',
|
||||
);
|
||||
await sleep(DEFAULT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Mark timed-out services
|
||||
for (const svc of services) {
|
||||
const result = results.get(svc.name)!;
|
||||
if (!result.healthy) {
|
||||
result.error = 'health check timed out';
|
||||
log.warn({ service: svc.name }, 'service health check timed out');
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(results.values());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
32
apps/server/preview/index.ts
Normal file
32
apps/server/preview/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Preview Module — Barrel Exports
|
||||
*/
|
||||
|
||||
export { PreviewManager } from './manager.js';
|
||||
export { discoverConfig, parseCwPreviewConfig } from './config-reader.js';
|
||||
export {
|
||||
generateComposeFile,
|
||||
generateCaddyfile,
|
||||
generateLabels,
|
||||
} from './compose-generator.js';
|
||||
export {
|
||||
isDockerAvailable,
|
||||
composeUp,
|
||||
composeDown,
|
||||
composePs,
|
||||
listPreviewProjects,
|
||||
getContainerLabels,
|
||||
} from './docker-client.js';
|
||||
export { waitForHealthy } from './health-checker.js';
|
||||
export { allocatePort } from './port-allocator.js';
|
||||
export type {
|
||||
PreviewConfig,
|
||||
PreviewServiceConfig,
|
||||
PreviewStatus,
|
||||
StartPreviewOptions,
|
||||
HealthResult,
|
||||
} from './types.js';
|
||||
export {
|
||||
PREVIEW_LABELS,
|
||||
COMPOSE_PROJECT_PREFIX,
|
||||
} from './types.js';
|
||||
569
apps/server/preview/manager.test.ts
Normal file
569
apps/server/preview/manager.test.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
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 { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js';
|
||||
|
||||
// Mock all external dependencies before imports
|
||||
vi.mock('./docker-client.js', () => ({
|
||||
isDockerAvailable: vi.fn(),
|
||||
composeUp: vi.fn(),
|
||||
composeDown: vi.fn(),
|
||||
composePs: vi.fn(),
|
||||
listPreviewProjects: vi.fn(),
|
||||
getContainerLabels: vi.fn(),
|
||||
getPreviewPorts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config-reader.js', () => ({
|
||||
discoverConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./port-allocator.js', () => ({
|
||||
allocatePort: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./health-checker.js', () => ({
|
||||
waitForHealthy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
rm: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('nanoid', () => ({
|
||||
nanoid: vi.fn(() => 'abc123test'),
|
||||
}));
|
||||
|
||||
import { PreviewManager } from './manager.js';
|
||||
import {
|
||||
isDockerAvailable,
|
||||
composeUp,
|
||||
composeDown,
|
||||
composePs,
|
||||
listPreviewProjects,
|
||||
getContainerLabels,
|
||||
} from './docker-client.js';
|
||||
import { discoverConfig } from './config-reader.js';
|
||||
import { allocatePort } from './port-allocator.js';
|
||||
import { waitForHealthy } from './health-checker.js';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import type { PreviewConfig } from './types.js';
|
||||
|
||||
const mockIsDockerAvailable = vi.mocked(isDockerAvailable);
|
||||
const mockComposeUp = vi.mocked(composeUp);
|
||||
const mockComposeDown = vi.mocked(composeDown);
|
||||
const mockComposePs = vi.mocked(composePs);
|
||||
const mockListPreviewProjects = vi.mocked(listPreviewProjects);
|
||||
const mockGetContainerLabels = vi.mocked(getContainerLabels);
|
||||
const mockDiscoverConfig = vi.mocked(discoverConfig);
|
||||
const mockAllocatePort = vi.mocked(allocatePort);
|
||||
const mockWaitForHealthy = vi.mocked(waitForHealthy);
|
||||
const mockMkdir = vi.mocked(mkdir);
|
||||
const mockWriteFile = vi.mocked(writeFile);
|
||||
const mockRm = vi.mocked(rm);
|
||||
|
||||
// Collect emitted events
|
||||
function createMockEventBus(): EventBus & { emitted: DomainEvent[] } {
|
||||
const emitted: DomainEvent[] = [];
|
||||
return {
|
||||
emitted,
|
||||
emit: vi.fn((event: DomainEvent) => {
|
||||
emitted.push(event);
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockProjectRepo(project = {
|
||||
id: 'proj-1',
|
||||
name: 'test-project',
|
||||
url: 'https://github.com/test/repo.git',
|
||||
defaultBranch: 'main',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}): ProjectRepository {
|
||||
return {
|
||||
findById: vi.fn().mockResolvedValue(project),
|
||||
findByName: vi.fn(),
|
||||
findByUrl: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
setInitiativeProjects: vi.fn(),
|
||||
getInitiativeProjects: vi.fn(),
|
||||
} as unknown as ProjectRepository;
|
||||
}
|
||||
|
||||
const WORKSPACE_ROOT = '/tmp/test-workspace';
|
||||
|
||||
const SIMPLE_CONFIG: PreviewConfig = {
|
||||
version: 1,
|
||||
services: {
|
||||
app: {
|
||||
name: 'app',
|
||||
build: '.',
|
||||
port: 3000,
|
||||
healthcheck: { path: '/health' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('PreviewManager', () => {
|
||||
let manager: PreviewManager;
|
||||
let eventBus: EventBus & { emitted: DomainEvent[] };
|
||||
let projectRepo: ProjectRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventBus = createMockEventBus();
|
||||
projectRepo = createMockProjectRepo();
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('completes the full start lifecycle for a healthy service', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]);
|
||||
mockComposePs.mockResolvedValue([
|
||||
{ name: 'app', state: 'running', health: 'healthy' },
|
||||
]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'feature-x',
|
||||
});
|
||||
|
||||
// Verify returned status
|
||||
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.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 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes phaseId when provided', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockResolvedValue(undefined);
|
||||
mockWaitForHealthy.mockResolvedValue([]);
|
||||
mockComposePs.mockResolvedValue([]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
phaseId: 'phase-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'feature-x',
|
||||
});
|
||||
|
||||
expect(result.phaseId).toBe('phase-1');
|
||||
});
|
||||
|
||||
it('throws when Docker is not available', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
}),
|
||||
).rejects.toThrow('Docker is not available');
|
||||
|
||||
// No events should be emitted
|
||||
expect(eventBus.emitted).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('throws when project is not found', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
projectRepo = createMockProjectRepo();
|
||||
(projectRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT);
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'nonexistent',
|
||||
branch: 'main',
|
||||
}),
|
||||
).rejects.toThrow("Project 'nonexistent' not found");
|
||||
});
|
||||
|
||||
it('emits preview:failed and cleans up when compose up fails', async () => {
|
||||
mockIsDockerAvailable.mockResolvedValue(true);
|
||||
mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG);
|
||||
mockAllocatePort.mockResolvedValue(9100);
|
||||
mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found'));
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
}),
|
||||
).rejects.toThrow('Preview build failed: build error: Dockerfile not found');
|
||||
|
||||
// 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<string, unknown>).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<string, unknown>).error).toBe(
|
||||
'Health checks failed for: app',
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
expect(mockComposeDown).toHaveBeenCalled();
|
||||
expect(mockRm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('succeeds when no healthcheck endpoints are configured', async () => {
|
||||
const noHealthConfig: PreviewConfig = {
|
||||
version: 1,
|
||||
services: { app: { name: 'app', build: '.', port: 3000 } },
|
||||
};
|
||||
|
||||
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' }]);
|
||||
|
||||
const result = await manager.start({
|
||||
initiativeId: 'init-1',
|
||||
projectId: 'proj-1',
|
||||
branch: 'main',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('running');
|
||||
// Should succeed — empty health results means allHealthy is vacuously true
|
||||
expect(eventBus.emitted[1].type).toBe('preview:ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('stops compose, cleans up artifacts, and emits preview:stopped', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
});
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
await manager.stop('abc123test');
|
||||
|
||||
expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test');
|
||||
expect(mockRm).toHaveBeenCalledWith(
|
||||
`${WORKSPACE_ROOT}/.cw-previews/abc123test`,
|
||||
{ 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits empty initiativeId when labels are missing', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue({});
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
await manager.stop('xyz');
|
||||
|
||||
expect(eventBus.emitted).toHaveLength(1);
|
||||
expect((eventBus.emitted[0].payload as Record<string, unknown>).initiativeId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('returns all active previews reconstructed from Docker state', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '/tmp/compose.yml' },
|
||||
{ Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '/tmp/compose2.yml' },
|
||||
]);
|
||||
|
||||
mockGetContainerLabels
|
||||
.mockResolvedValueOnce({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-1',
|
||||
[PREVIEW_LABELS.branch]: 'feat-a',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'aaa',
|
||||
})
|
||||
.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.previewId]: 'bbb',
|
||||
});
|
||||
|
||||
mockComposePs
|
||||
.mockResolvedValueOnce([{ name: 'app', state: 'running', health: 'healthy' }])
|
||||
.mockResolvedValueOnce([{ name: 'api', state: 'running', health: 'none' }]);
|
||||
|
||||
const previews = await manager.list();
|
||||
|
||||
expect(previews).toHaveLength(2);
|
||||
expect(previews[0].id).toBe('aaa');
|
||||
expect(previews[0].port).toBe(9100);
|
||||
expect(previews[0].services).toHaveLength(1);
|
||||
expect(previews[1].id).toBe('bbb');
|
||||
expect(previews[1].port).toBe(9101);
|
||||
});
|
||||
|
||||
it('filters by initiativeId when provided', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
|
||||
mockGetContainerLabels
|
||||
.mockResolvedValueOnce({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
[PREVIEW_LABELS.projectId]: 'proj-1',
|
||||
[PREVIEW_LABELS.branch]: 'feat-a',
|
||||
[PREVIEW_LABELS.port]: '9100',
|
||||
[PREVIEW_LABELS.previewId]: 'aaa',
|
||||
})
|
||||
.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.previewId]: 'bbb',
|
||||
});
|
||||
|
||||
mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]);
|
||||
|
||||
const previews = await manager.list('init-1');
|
||||
|
||||
expect(previews).toHaveLength(1);
|
||||
expect(previews[0].initiativeId).toBe('init-1');
|
||||
});
|
||||
|
||||
it('skips projects without cw.preview label', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-orphan', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label
|
||||
|
||||
const previews = await manager.list();
|
||||
expect(previews).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips projects with incomplete labels', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-partial', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.preview]: 'true',
|
||||
// Missing required: initiativeId, projectId, branch
|
||||
});
|
||||
|
||||
const previews = await manager.list();
|
||||
expect(previews).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
const labels = {
|
||||
[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]: 'abc',
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.status).toBe('running');
|
||||
expect(status!.id).toBe('abc');
|
||||
expect(status!.port).toBe(9100);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(status!.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('returns stopped when no services exist', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue(labels);
|
||||
mockComposePs.mockResolvedValue([]);
|
||||
|
||||
const status = await manager.getStatus('abc');
|
||||
expect(status!.status).toBe('stopped');
|
||||
});
|
||||
|
||||
it('returns building when services are in other states', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue(labels);
|
||||
mockComposePs.mockResolvedValue([
|
||||
{ name: 'app', state: 'created', health: 'starting' },
|
||||
]);
|
||||
|
||||
const status = await manager.getStatus('abc');
|
||||
expect(status!.status).toBe('building');
|
||||
});
|
||||
|
||||
it('returns null when preview is not found', async () => {
|
||||
mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label
|
||||
|
||||
const status = await manager.getStatus('nonexistent');
|
||||
expect(status).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopAll', () => {
|
||||
it('stops all preview projects', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([
|
||||
{ Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' },
|
||||
{ Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' },
|
||||
]);
|
||||
|
||||
// stop() calls getContainerLabels then composeDown
|
||||
mockGetContainerLabels.mockResolvedValue({
|
||||
[PREVIEW_LABELS.initiativeId]: 'init-1',
|
||||
});
|
||||
mockComposeDown.mockResolvedValue(undefined);
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
expect(mockComposeDown).toHaveBeenCalledTimes(2);
|
||||
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);
|
||||
});
|
||||
|
||||
it('handles empty project list gracefully', async () => {
|
||||
mockListPreviewProjects.mockResolvedValue([]);
|
||||
|
||||
await manager.stopAll();
|
||||
|
||||
expect(mockComposeDown).not.toHaveBeenCalled();
|
||||
expect(eventBus.emitted).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
342
apps/server/preview/manager.ts
Normal file
342
apps/server/preview/manager.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
55
apps/server/preview/port-allocator.test.ts
Normal file
55
apps/server/preview/port-allocator.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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([]);
|
||||
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<void>((resolve) => {
|
||||
server.listen(9100, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
const port = await allocatePort();
|
||||
expect(port).toBe(9101);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
63
apps/server/preview/port-allocator.ts
Normal file
63
apps/server/preview/port-allocator.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Port Allocator
|
||||
*
|
||||
* Finds the next available port for a preview deployment.
|
||||
* Queries running preview containers and performs a bind test.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:net';
|
||||
import { getPreviewPorts } from './docker-client.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('preview:port');
|
||||
|
||||
/** Starting port for preview deployments */
|
||||
const BASE_PORT = 9100;
|
||||
|
||||
/** Maximum port to try before giving up */
|
||||
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
|
||||
*
|
||||
* @returns An available port number
|
||||
* @throws If no port is available in the range
|
||||
*/
|
||||
export async function allocatePort(): Promise<number> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No available ports in range ${BASE_PORT}-${MAX_PORT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a port is available by attempting to bind to it.
|
||||
*/
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
86
apps/server/preview/types.ts
Normal file
86
apps/server/preview/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Preview Deployment Types
|
||||
*
|
||||
* Configuration and status types for Docker-based preview deployments.
|
||||
* Docker IS the source of truth — no database table needed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service configuration within a preview deployment.
|
||||
*/
|
||||
export interface PreviewServiceConfig {
|
||||
name: string;
|
||||
build?: { context: string; dockerfile: string } | string;
|
||||
image?: string;
|
||||
port: number;
|
||||
route?: string;
|
||||
internal?: boolean;
|
||||
healthcheck?: { path: string; interval?: string; retries?: number };
|
||||
env?: Record<string, string>;
|
||||
volumes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview deployment configuration.
|
||||
* Parsed from `.cw-preview.yml`, existing compose file, or inferred from Dockerfile.
|
||||
*/
|
||||
export interface PreviewConfig {
|
||||
version: 1;
|
||||
compose?: string;
|
||||
services: Record<string, PreviewServiceConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime status of a preview deployment.
|
||||
* Reconstructed from Docker state + container labels.
|
||||
*/
|
||||
export interface PreviewStatus {
|
||||
id: string;
|
||||
projectName: string;
|
||||
initiativeId: string;
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
port: number;
|
||||
status: 'building' | 'running' | 'stopped' | 'failed';
|
||||
services: Array<{ name: string; state: string; health: string }>;
|
||||
composePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker labels applied to preview containers for metadata retrieval.
|
||||
*/
|
||||
export const PREVIEW_LABEL_PREFIX = 'cw';
|
||||
export const PREVIEW_LABELS = {
|
||||
preview: `${PREVIEW_LABEL_PREFIX}.preview`,
|
||||
initiativeId: `${PREVIEW_LABEL_PREFIX}.initiative-id`,
|
||||
phaseId: `${PREVIEW_LABEL_PREFIX}.phase-id`,
|
||||
branch: `${PREVIEW_LABEL_PREFIX}.branch`,
|
||||
projectId: `${PREVIEW_LABEL_PREFIX}.project-id`,
|
||||
port: `${PREVIEW_LABEL_PREFIX}.port`,
|
||||
previewId: `${PREVIEW_LABEL_PREFIX}.preview-id`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Compose project name prefix for all preview deployments.
|
||||
*/
|
||||
export const COMPOSE_PROJECT_PREFIX = 'cw-preview-';
|
||||
|
||||
/**
|
||||
* Options for starting a preview deployment.
|
||||
*/
|
||||
export interface StartPreviewOptions {
|
||||
initiativeId: string;
|
||||
phaseId?: string;
|
||||
projectId: string;
|
||||
branch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check result for a single service.
|
||||
*/
|
||||
export interface HealthResult {
|
||||
name: string;
|
||||
healthy: boolean;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user