Run project-specific initialization commands (DB migrations, fixture loading, etc.) automatically after containers are healthy, before the preview is marked ready. Configured via per-service `seed` arrays in .cw-preview.yml.
240 lines
5.9 KiB
TypeScript
240 lines
5.9 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,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Execute a command inside a running service container.
|
|
* Used for seed commands (migrations, fixture loading, etc.) after health checks pass.
|
|
*/
|
|
export async function execInContainer(
|
|
projectName: string,
|
|
serviceName: string,
|
|
command: string,
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const result = await execa('docker', [
|
|
'compose', '-p', projectName,
|
|
'exec', '-T', serviceName,
|
|
'sh', '-c', command,
|
|
], { timeout: 300000 }); // 5 min per seed command
|
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
}
|
|
|
|
/**
|
|
* 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 {};
|
|
}
|
|
}
|