Files
Codewalkers/apps/server/preview/compose-generator.ts
Lukas May 34578d39c6 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
2026-03-03 11:22:53 +01:00

192 lines
5.1 KiB
TypeScript

/**
* 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;
}