Merge changemaker-control-panel into v2 monorepo
Absorbs the separate control-panel git repo as a subdirectory. Instances and backups directories excluded via .gitignore. Bunker Admin
This commit is contained in:
351
changemaker-control-panel/api/src/services/docker.service.ts
Normal file
351
changemaker-control-panel/api/src/services/docker.service.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
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}`).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`);
|
||||
}
|
||||
Reference in New Issue
Block a user