352 lines
9.8 KiB
TypeScript
352 lines
9.8 KiB
TypeScript
import { exec as execCb } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import http from 'http';
|
|
import { logger } from '../utils/logger';
|
|
|
|
const exec = promisify(execCb);
|
|
|
|
const EXEC_TIMEOUT = 120_000; // 2 minutes
|
|
const DOCKER_SOCKET = '/var/run/docker.sock';
|
|
|
|
/** Validate a service/project name to prevent shell injection. */
|
|
function validateName(name: string, label: string): string {
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
throw new Error(`Invalid ${label}: ${name}`);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
/** Validate a Docker duration format (e.g., "1h", "30m", "24h"). */
|
|
function validateDuration(value: string): string {
|
|
if (!/^\d+[smhd]$/.test(value)) {
|
|
throw new Error(`Invalid duration: ${value}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Validate a tail count (positive integer, capped). */
|
|
function validateTail(value: number): number {
|
|
const n = Math.max(1, Math.min(value, 5000));
|
|
return Math.floor(n);
|
|
}
|
|
|
|
/** Parsed container status from `docker compose ps --format json` */
|
|
export interface ContainerInfo {
|
|
name: string;
|
|
service: string;
|
|
status: string;
|
|
state: string;
|
|
health: string;
|
|
ports: string;
|
|
createdAt: string;
|
|
exitCode: number;
|
|
}
|
|
|
|
/**
|
|
* Execute a shell command with timeout and proper error handling.
|
|
*/
|
|
async function execCmd(
|
|
command: string,
|
|
cwd: string,
|
|
timeoutMs = EXEC_TIMEOUT
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
|
|
try {
|
|
const result = await exec(command, {
|
|
cwd,
|
|
timeout: timeoutMs,
|
|
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
env: { ...process.env, COMPOSE_ANSI: 'never' },
|
|
});
|
|
return result;
|
|
} catch (err: unknown) {
|
|
const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
|
|
if (error.killed) {
|
|
throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`);
|
|
}
|
|
// Include stderr in the error for debugging
|
|
const msg = error.stderr || error.message || 'Unknown exec error';
|
|
throw new Error(`Command failed: ${command}\n${msg}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the base compose command with project name.
|
|
*/
|
|
function composeCmd(project: string): string {
|
|
return `docker compose -p ${validateName(project, 'project')}`;
|
|
}
|
|
|
|
// ─── Docker Compose CLI Operations ───────────────────────────────────
|
|
|
|
export async function composeUp(
|
|
projectDir: string,
|
|
project: string,
|
|
services?: string[]
|
|
): Promise<string> {
|
|
const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : '';
|
|
const orphanFlag = services?.length ? '' : ' --remove-orphans';
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} up -d${orphanFlag}${svc}`,
|
|
projectDir
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
export async function composeDown(
|
|
projectDir: string,
|
|
project: string,
|
|
removeVolumes = false
|
|
): Promise<string> {
|
|
const flags = removeVolumes ? ' -v' : '';
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} down${flags}`,
|
|
projectDir
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
export async function composeStop(
|
|
projectDir: string,
|
|
project: string
|
|
): Promise<string> {
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} stop`,
|
|
projectDir
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
export async function composeRestart(
|
|
projectDir: string,
|
|
project: string,
|
|
service?: string
|
|
): Promise<string> {
|
|
const svc = service ? ` ${validateName(service, 'service')}` : '';
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} restart${svc}`,
|
|
projectDir
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
export async function composePull(
|
|
projectDir: string,
|
|
project: string
|
|
): Promise<string> {
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} pull`,
|
|
projectDir,
|
|
300_000 // 5 min for pulls
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
export async function composeBuild(
|
|
projectDir: string,
|
|
project: string
|
|
): Promise<string> {
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} build`,
|
|
projectDir,
|
|
600_000 // 10 min for builds
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
/**
|
|
* List containers with status. Returns parsed container info.
|
|
*/
|
|
export async function composePs(
|
|
projectDir: string,
|
|
project: string
|
|
): Promise<ContainerInfo[]> {
|
|
const { stdout } = await execCmd(
|
|
`${composeCmd(project)} ps --format json`,
|
|
projectDir
|
|
);
|
|
|
|
if (!stdout.trim()) return [];
|
|
|
|
// docker compose ps --format json outputs one JSON object per line
|
|
const containers: ContainerInfo[] = [];
|
|
for (const line of stdout.trim().split('\n')) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const raw = JSON.parse(line);
|
|
containers.push({
|
|
name: raw.Name || raw.name || '',
|
|
service: raw.Service || raw.service || '',
|
|
status: raw.Status || raw.status || '',
|
|
state: raw.State || raw.state || '',
|
|
health: raw.Health || raw.health || '',
|
|
ports: raw.Ports || raw.ports || '',
|
|
createdAt: raw.CreatedAt || raw.created_at || '',
|
|
exitCode: raw.ExitCode ?? raw.exit_code ?? 0,
|
|
});
|
|
} catch {
|
|
logger.warn(`[docker] Failed to parse container line: ${line}`);
|
|
}
|
|
}
|
|
return containers;
|
|
}
|
|
|
|
/**
|
|
* Get logs from a specific service.
|
|
*/
|
|
export async function composeLogs(
|
|
projectDir: string,
|
|
project: string,
|
|
service?: string,
|
|
tail = 200,
|
|
since?: string
|
|
): Promise<string> {
|
|
const parts = [composeCmd(project), 'logs', '--no-color'];
|
|
if (tail > 0) parts.push(`--tail=${validateTail(tail)}`);
|
|
if (since) parts.push(`--since=${validateDuration(since)}`);
|
|
if (service) parts.push(validateName(service, 'service'));
|
|
|
|
const { stdout, stderr } = await execCmd(parts.join(' '), projectDir);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
/**
|
|
* Execute a command inside a running service container.
|
|
* Optionally pass environment variables via -e flags.
|
|
*/
|
|
export async function composeExec(
|
|
projectDir: string,
|
|
project: string,
|
|
service: string,
|
|
command: string,
|
|
timeoutMs = EXEC_TIMEOUT,
|
|
envVars?: Record<string, string>
|
|
): Promise<string> {
|
|
const envFlags = envVars
|
|
? Object.entries(envVars).map(([k, v]) => `-e ${k}='${v.replace(/'/g, "'\\''")}'`).join(' ') + ' '
|
|
: '';
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
|
|
projectDir,
|
|
timeoutMs
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
/**
|
|
* Run a one-off command in a service container (docker compose run).
|
|
* Uses --entrypoint "" to skip the service's entrypoint script.
|
|
* Useful for running setup commands (prisma db push, seed) without the entrypoint.
|
|
*/
|
|
export async function composeRun(
|
|
projectDir: string,
|
|
project: string,
|
|
service: string,
|
|
command: string,
|
|
timeoutMs = EXEC_TIMEOUT
|
|
): Promise<string> {
|
|
const { stdout, stderr } = await execCmd(
|
|
`${composeCmd(project)} run --rm --no-deps -T --entrypoint "" ${validateName(service, 'service')} ${command}`,
|
|
projectDir,
|
|
timeoutMs
|
|
);
|
|
return stdout || stderr;
|
|
}
|
|
|
|
// ─── Docker Socket API ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Make a request to the Docker Engine API via Unix socket.
|
|
*/
|
|
function dockerSocketRequest(path: string): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{
|
|
socketPath: DOCKER_SOCKET,
|
|
path,
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
(res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => (data += chunk));
|
|
res.on('end', () => {
|
|
if (res.statusCode && res.statusCode >= 400) {
|
|
reject(new Error(`Docker API returned ${res.statusCode}: ${data}`));
|
|
} else {
|
|
resolve(data);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.setTimeout(10_000, () => {
|
|
req.destroy();
|
|
reject(new Error('Docker socket request timed out'));
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get status of a specific container by name via Docker socket API.
|
|
*/
|
|
export async function getContainerStatus(
|
|
containerName: string
|
|
): Promise<{ state: string; health: string; running: boolean } | null> {
|
|
try {
|
|
const data = await dockerSocketRequest(
|
|
`/containers/${encodeURIComponent(containerName)}/json`
|
|
);
|
|
const info = JSON.parse(data);
|
|
return {
|
|
state: info.State?.Status || 'unknown',
|
|
health: info.State?.Health?.Status || 'none',
|
|
running: info.State?.Running === true,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll until a container reaches healthy state or timeout expires.
|
|
*/
|
|
export async function waitForHealthy(
|
|
containerName: string,
|
|
timeoutMs = 60_000,
|
|
pollIntervalMs = 2_000
|
|
): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const status = await getContainerStatus(containerName);
|
|
if (status?.health === 'healthy') return true;
|
|
if (status?.state === 'exited' || status?.state === 'dead') {
|
|
throw new Error(`Container ${containerName} exited unexpectedly`);
|
|
}
|
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
}
|
|
throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
|
|
}
|
|
|
|
/**
|
|
* Wait for an HTTP endpoint to respond with 200.
|
|
*/
|
|
export async function waitForHttp(
|
|
url: string,
|
|
timeoutMs = 120_000,
|
|
pollIntervalMs = 3_000
|
|
): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
|
if (response.ok) return true;
|
|
} catch {
|
|
// Expected while service is starting
|
|
}
|
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
}
|
|
throw new Error(`HTTP endpoint ${url} did not respond within ${timeoutMs}ms`);
|
|
}
|