- Add agentId label to preview containers (cw.agent-id) for tracking - Add startForAgent/stopByAgentId methods to PreviewManager - Auto-teardown: previews torn down on agent:stopped event - Conditional preview prompt injection for execute/refine/discuss agents - Agent-simplified CLI: cw preview start/stop --agent <id> - cw preview setup command with --auto mode for guided config generation - hasPreviewConfig hint on cw project register output - New tRPC procedures: startPreviewForAgent, stopPreviewByAgent
153 lines
4.4 KiB
TypeScript
153 lines
4.4 KiB
TypeScript
/**
|
|
* Docker Compose Generator
|
|
*
|
|
* Generates per-preview docker-compose.yml files (no Caddy sidecar — the
|
|
* shared gateway handles routing). Services connect to the external
|
|
* `cw-preview-net` network for gateway access and an internal bridge
|
|
* network for inter-service communication.
|
|
*/
|
|
|
|
import yaml from 'js-yaml';
|
|
import type { PreviewConfig, PreviewServiceConfig } from './types.js';
|
|
import { PREVIEW_LABELS, GATEWAY_NETWORK } from './types.js';
|
|
|
|
export interface ComposeGeneratorOptions {
|
|
projectPath: string;
|
|
deploymentId: string;
|
|
labels: Record<string, string>;
|
|
mode: 'preview' | 'dev';
|
|
}
|
|
|
|
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[];
|
|
container_name?: string;
|
|
command?: string;
|
|
working_dir?: string;
|
|
}
|
|
|
|
interface ComposeFile {
|
|
services: Record<string, ComposeService>;
|
|
networks: Record<string, { driver?: string; external?: boolean }>;
|
|
}
|
|
|
|
/**
|
|
* Generate a Docker Compose YAML string for a preview deployment.
|
|
*
|
|
* Structure:
|
|
* - User-defined services with build contexts (no published ports)
|
|
* - Public services connect to both cw-preview-net and internal network
|
|
* - Internal services connect only to internal network
|
|
* - Container names: cw-preview-<id>-<service> for DNS resolution on shared network
|
|
* - Dev mode: uses image + volumes + command instead of build
|
|
*/
|
|
export function generateComposeFile(
|
|
config: PreviewConfig,
|
|
opts: ComposeGeneratorOptions,
|
|
): string {
|
|
const compose: ComposeFile = {
|
|
services: {},
|
|
networks: {
|
|
[GATEWAY_NETWORK]: { external: true },
|
|
internal: { driver: 'bridge' },
|
|
},
|
|
};
|
|
|
|
for (const [name, svc] of Object.entries(config.services)) {
|
|
const containerName = `cw-preview-${opts.deploymentId}-${name}`;
|
|
const service: ComposeService = {
|
|
container_name: containerName,
|
|
labels: { ...opts.labels },
|
|
networks: svc.internal
|
|
? ['internal']
|
|
: [GATEWAY_NETWORK, 'internal'],
|
|
};
|
|
|
|
if (opts.mode === 'dev' && svc.dev) {
|
|
// Dev mode: use dev image + mount worktree
|
|
service.image = svc.dev.image;
|
|
service.working_dir = svc.dev.workdir ?? '/app';
|
|
service.volumes = [
|
|
`${opts.projectPath}:${svc.dev.workdir ?? '/app'}`,
|
|
// Anonymous volume for node_modules to avoid host overwrite
|
|
`${svc.dev.workdir ?? '/app'}/node_modules`,
|
|
];
|
|
if (svc.dev.command) {
|
|
service.command = svc.dev.command;
|
|
}
|
|
} else {
|
|
// Preview mode: build from source
|
|
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 (from config, not dev overrides)
|
|
if (opts.mode !== 'dev' && svc.volumes && svc.volumes.length > 0) {
|
|
service.volumes = svc.volumes;
|
|
}
|
|
|
|
compose.services[name] = service;
|
|
}
|
|
|
|
return yaml.dump(compose, { lineWidth: 120, noRefs: true });
|
|
}
|
|
|
|
/**
|
|
* Generate compose labels for a preview deployment.
|
|
*/
|
|
export function generateLabels(opts: {
|
|
initiativeId: string;
|
|
phaseId?: string;
|
|
projectId: string;
|
|
branch: string;
|
|
gatewayPort: number;
|
|
previewId: string;
|
|
mode: 'preview' | 'dev';
|
|
agentId?: 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.gatewayPort),
|
|
[PREVIEW_LABELS.previewId]: opts.previewId,
|
|
[PREVIEW_LABELS.mode]: opts.mode,
|
|
};
|
|
|
|
if (opts.phaseId) {
|
|
labels[PREVIEW_LABELS.phaseId] = opts.phaseId;
|
|
}
|
|
|
|
if (opts.agentId) {
|
|
labels[PREVIEW_LABELS.agentId] = opts.agentId;
|
|
}
|
|
|
|
return labels;
|
|
}
|