Files
changemaker.lite/changemaker-control-panel/api/src/services/docker.service.ts

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`);
}