Files
Codewalkers/apps/server/preview/docker-client.ts
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
Refactor preview deployments to use a single shared Caddy gateway container
with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy
sidecar and one port per preview. Adds dev/preview modes, git worktree
support for branch checkouts, and auto-start on phase:pending_review.

- Add GatewayManager for shared Caddy lifecycle + Caddyfile generation
- Add git worktree helpers for preview mode branch checkouts
- Add dev mode: volume-mount + dev server image instead of build
- Remove per-preview Caddy sidecar and port publishing
- Use shared cw-preview-net Docker network with container name DNS
- Auto-start previews when phase enters pending_review
- Delete unused PreviewPanel.tsx
- Update all tests (40 pass), docs, events, CLI, tRPC, frontend
2026-03-05 12:22:29 +01:00

223 lines
5.4 KiB
TypeScript

/**
* 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;
}
}
/**
* Ensure a Docker network exists. Creates it if missing.
*/
export async function ensureDockerNetwork(name: string): Promise<void> {
try {
await execa('docker', ['network', 'create', '--driver', 'bridge', name], {
timeout: 15000,
});
log.info({ name }, 'created docker network');
} catch (error) {
// Ignore "already exists" error
if ((error as Error).message?.includes('already exists')) {
log.debug({ name }, 'docker network already exists');
return;
}
throw error;
}
}
/**
* Remove a Docker network. Ignores errors (e.g., network in use or not found).
*/
export async function removeDockerNetwork(name: string): Promise<void> {
try {
await execa('docker', ['network', 'rm', name], { timeout: 15000 });
log.info({ name }, 'removed docker network');
} catch {
log.debug({ name }, 'failed to remove docker network (may not exist or be in use)');
}
}
/**
* Check if a Docker network exists.
*/
export async function dockerNetworkExists(name: string): Promise<boolean> {
try {
await execa('docker', ['network', 'inspect', name], { timeout: 15000 });
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 {};
}
}