import type { ComposeProject, ComposeServiceInfo, HostConfig } from "../types.js";
import { ComposeOperationError, logError } from "../utils/errors.js";
import { isLocalHost } from "../utils/host-utils.js";
import { escapeShellArg } from "../utils/path-security.js";
import { validateAlphanumeric } from "../utils/validation.js";
import type {
IComposePathResolver,
IComposeService,
ILocalExecutorService,
ISSHService,
} from "./interfaces.js";
import { validateHostForSsh } from "./ssh.js";
/**
* Validate Docker Compose project name
* Project names must be alphanumeric with hyphens and underscores only
*/
export function validateProjectName(name: string): void {
validateAlphanumeric(name, "project name");
}
/**
* Validate Docker Compose service name
* Service names must be alphanumeric with hyphens and underscores only
*/
export function validateServiceName(name: string): void {
validateAlphanumeric(name, "service name");
}
/**
* Validate Docker Compose action
*
* SECURITY: Prevents command injection by only allowing known compose actions.
* This is a whitelist approach - only explicitly allowed actions can be executed.
*
* @throws {Error} If action is not in the whitelist
*/
export function validateComposeAction(action: string): void {
const ALLOWED_ACTIONS = [
"up",
"down",
"start",
"stop",
"restart",
"ps",
"logs",
"build",
"pull",
"push",
"config",
"create",
"exec",
"kill",
"pause",
"unpause",
"port",
"rm",
"run",
"scale",
"top",
"version",
"events",
"images",
"ls",
];
if (!ALLOWED_ACTIONS.includes(action)) {
throw new Error(`Invalid compose action: ${action}`);
}
}
/**
* Validate and escape extra arguments for docker compose commands
*
* SECURITY (S-M1, CWE-78): Prevents command injection by escaping all arguments
* using single-quote shell escaping. This is more robust than a deny-list approach.
*
* This function replaces the previous validateComposeArgs that used a deny-list
* of shell metacharacters. Using escapeShellArg() for each argument ensures that
* all special characters (including spaces, quotes, semicolons, pipes, etc.) are
* properly neutralized.
*
* @param args - Arguments to validate and escape
* @returns Array of escaped arguments safe for shell execution
* @throws {Error} If any argument exceeds 500 chars (DoS prevention)
*/
export function validateAndEscapeComposeArgs(args: string[]): string[] {
const escaped: string[] = [];
for (const arg of args) {
// SECURITY: Reject extremely long arguments (DoS prevention)
if (arg.length > 500) {
throw new Error(`Compose argument too long: ${arg.substring(0, 50)}...`);
}
// SECURITY (S-M1): Escape each argument to prevent command injection
// This neutralizes ALL shell metacharacters including: ; & | ` $ ( ) < > { } [ ] \ " ' # \n \r \t
escaped.push(escapeShellArg(arg));
}
return escaped;
}
/**
* Build docker compose command string for remote execution
*
* @param project - Project name (optional, for commands that need -p flag)
* @param action - Compose action (up, down, ps, ls, etc.)
* @param extraArgs - Additional arguments
* @param composePath - Optional path to compose file (adds -f flag)
* @returns Command string
*/
function buildComposeCommand(
project: string | null,
action: string,
extraArgs: string[] = [],
composePath?: string | null
): string {
const parts = ["docker", "compose"];
if (composePath) {
// SECURITY (CWE-78): Escape composePath to prevent command injection
// composePath comes from discovery and could contain shell metacharacters
parts.push("-f", escapeShellArg(composePath));
}
if (project) {
parts.push("-p", project);
}
parts.push(action);
parts.push(...extraArgs);
return parts.join(" ");
}
/**
* Parse compose status string to enum
*/
export function parseComposeStatus(status: string): ComposeProject["status"] {
const lower = status.toLowerCase();
if (lower.includes("running")) {
if (lower.includes("(") && !lower.includes("running(")) {
return "partial";
}
return "running";
}
if (lower.includes("exited") || lower.includes("stopped")) {
return "stopped";
}
return "unknown";
}
/**
* Parse docker compose ps JSON output into service info array
*
* Docker compose ps outputs one JSON object per line when using --format json.
* This function parses each line and converts to our internal ComposeServiceInfo format.
*
* @param stdout - Raw stdout from docker compose ps --format json
* @returns Array of parsed service info (empty array if no output)
*/
export function parseComposeServices(stdout: string): ComposeServiceInfo[] {
const services: ComposeServiceInfo[] = [];
if (!stdout.trim()) {
return services;
}
// docker compose ps outputs one JSON object per line
const lines = stdout.trim().split("\n");
for (const line of lines) {
if (!line.trim()) continue;
try {
const svc = JSON.parse(line) as {
Name: string;
State: string;
Health?: string;
ExitCode?: number;
Publishers?: Array<{
PublishedPort: number;
TargetPort: number;
Protocol: string;
}>;
};
services.push({
name: svc.Name,
status: svc.State,
health: svc.Health,
exitCode: svc.ExitCode,
publishers: svc.Publishers?.map((p) => ({
publishedPort: p.PublishedPort,
targetPort: p.TargetPort,
protocol: p.Protocol,
})),
});
} catch (_error) {
/* Intentionally swallow errors - missing port/service info is non-critical */
}
}
return services;
}
/**
* Determine overall project status from service statuses
*
* Logic:
* - No services → stopped
* - All running → running
* - Some running → partial
* - None running → stopped
*
* @param services - Array of service info from docker compose ps
* @returns Overall project status
*/
export function determineProjectStatus(services: ComposeServiceInfo[]): ComposeProject["status"] {
if (services.length === 0) {
return "stopped";
}
const running = services.filter((s) => s.status === "running").length;
if (running === services.length) {
return "running";
}
if (running > 0) {
return "partial";
}
return "stopped";
}
/**
* ComposeService class for managing Docker Compose operations with dependency injection
*/
export class ComposeService implements IComposeService {
private pathResolver?: IComposePathResolver;
constructor(
private sshService: ISSHService,
private localExecutor: ILocalExecutorService,
pathResolver?: IComposePathResolver
) {
this.pathResolver = pathResolver;
}
/**
* Execute docker compose command on local or remote host
*
* SECURITY: Arguments are validated before execution to prevent command injection.
* Uses local executor for localhost, SSH connection pool for remote hosts.
*
* @param host - Host configuration with execution details
* @param project - Docker Compose project name (validated, alphanumeric only)
* @param action - Compose action (up, down, restart, etc.)
* @param extraArgs - Additional arguments (validated for shell metacharacters)
* @returns Command output
* @throws {Error} If validation fails or execution fails
*/
async composeExec(
host: HostConfig,
project: string,
action: string,
extraArgs: string[] = []
): Promise<string> {
validateProjectName(project);
validateComposeAction(action);
// SECURITY (S-M1): Validate and escape arguments for SSH execution
// For local execution, we use the unescaped args (execFile doesn't need escaping)
// For SSH execution, we use the escaped args (shell string needs escaping)
const escapedArgs = validateAndEscapeComposeArgs(extraArgs);
// Try to resolve compose file path via discovery (if available)
let composePath: string | null = null;
if (this.pathResolver) {
try {
composePath = await this.pathResolver.resolveProjectPath(host, project);
} catch (error) {
// Graceful fallback - log error but proceed without -f flag
logError(error as Error, {
operation: "composeExec",
metadata: { host: host.name, project, action },
});
}
}
try {
// Route to local or SSH executor based on host config
if (isLocalHost(host)) {
// Local execution: use execFile (no escaping needed)
const args = ["compose"];
if (composePath) {
args.push("-f", composePath);
}
if (project) {
args.push("-p", project);
}
args.push(action);
args.push(...extraArgs); // Use unescaped args for execFile
return await this.localExecutor.executeLocalCommand("docker", args, {
timeoutMs: 30000,
});
}
// Remote host - use SSH with escaped args
validateHostForSsh(host);
const command = buildComposeCommand(project, action, escapedArgs, composePath);
return await this.sshService.executeSSHCommand(host, command, [], { timeoutMs: 30000 });
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown error";
throw new ComposeOperationError(
`Docker Compose command failed: ${detail}`,
host.name,
project,
action,
error
);
}
}
/**
* List all compose projects on a host (local or remote)
*/
async listComposeProjects(host: HostConfig): Promise<ComposeProject[]> {
const args = ["--format", "json"];
try {
let stdout: string;
if (isLocalHost(host)) {
stdout = await this.localExecutor.executeLocalCommand(
"docker",
["compose", "ls", ...args],
{ timeoutMs: 15000 }
);
} else {
validateHostForSsh(host);
const command = buildComposeCommand(null, "ls", args);
stdout = await this.sshService.executeSSHCommand(host, command, [], {
timeoutMs: 15000,
});
}
if (!stdout.trim()) {
return [];
}
const projects = JSON.parse(stdout) as Array<{
Name: string;
Status: string;
ConfigFiles: string;
}>;
return projects.map((p) => ({
name: p.Name,
status: parseComposeStatus(p.Status),
configFiles: p.ConfigFiles.split(",").map((f) => f.trim()),
services: [],
}));
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown error";
throw new ComposeOperationError(
`Failed to list compose projects: ${detail}`,
host.name,
"*",
"ls",
error
);
}
}
/**
* Get detailed status of a compose project (local or remote)
*/
async getComposeStatus(host: HostConfig, project: string): Promise<ComposeProject> {
validateProjectName(project);
const args = ["--format", "json"];
try {
let stdout: string;
if (isLocalHost(host)) {
stdout = await this.localExecutor.executeLocalCommand(
"docker",
["compose", "-p", project, "ps", ...args],
{ timeoutMs: 15000 }
);
} else {
validateHostForSsh(host);
const command = buildComposeCommand(project, "ps", args);
stdout = await this.sshService.executeSSHCommand(host, command, [], {
timeoutMs: 15000,
});
}
const services = parseComposeServices(stdout);
const status = determineProjectStatus(services);
return {
name: project,
status,
configFiles: [],
services,
};
} catch (error) {
const detail = error instanceof Error ? error.message : "Unknown error";
throw new ComposeOperationError(
`Failed to get compose status: ${detail}`,
host.name,
project,
"ps",
error
);
}
}
/**
* Start a compose project
*/
async composeUp(host: HostConfig, project: string, detach = true): Promise<string> {
const args = detach ? ["-d"] : [];
return this.composeExec(host, project, "up", args);
}
/**
* Stop a compose project
*/
async composeDown(host: HostConfig, project: string, removeVolumes = false): Promise<string> {
const args = removeVolumes ? ["-v"] : [];
return this.composeExec(host, project, "down", args);
}
/**
* Restart a compose project
*/
async composeRestart(host: HostConfig, project: string): Promise<string> {
return this.composeExec(host, project, "restart", []);
}
/**
* Get logs from a compose project
*/
async composeLogs(
host: HostConfig,
project: string,
options: {
tail?: number;
follow?: boolean;
timestamps?: boolean;
since?: string;
until?: string;
services?: string[];
} = {}
): Promise<string> {
const args: string[] = ["--no-color"];
if (options.tail !== undefined) {
args.push("--tail", String(options.tail));
}
if (options.follow) {
args.push("-f");
}
if (options.timestamps) {
args.push("-t");
}
if (options.since) {
args.push("--since", options.since);
}
if (options.until) {
args.push("--until", options.until);
}
if (options.services && options.services.length > 0) {
// Validate service names
for (const service of options.services) {
validateServiceName(service);
}
args.push(...options.services);
}
return this.composeExec(host, project, "logs", args);
}
/**
* Build images for a compose project
*/
async composeBuild(
host: HostConfig,
project: string,
options: { service?: string; noCache?: boolean; pull?: boolean } = {}
): Promise<string> {
const args: string[] = [];
if (options.noCache) {
args.push("--no-cache");
}
if (options.pull) {
args.push("--pull");
}
if (options.service) {
validateServiceName(options.service);
args.push(options.service);
}
return this.composeExec(host, project, "build", args);
}
/**
* Pull images for a compose project
*/
async composePull(
host: HostConfig,
project: string,
options: { service?: string; ignorePullFailures?: boolean; quiet?: boolean } = {}
): Promise<string> {
const args: string[] = [];
if (options.ignorePullFailures) {
args.push("--ignore-pull-failures");
}
if (options.quiet) {
args.push("--quiet");
}
if (options.service) {
validateServiceName(options.service);
args.push(options.service);
}
return this.composeExec(host, project, "pull", args);
}
/**
* Recreate containers for a compose project (force recreate)
*/
async composeRecreate(
host: HostConfig,
project: string,
options: { service?: string; forceRecreate?: boolean; noDeps?: boolean } = {}
): Promise<string> {
const args: string[] = ["-d"];
if (options.forceRecreate === true) {
args.push("--force-recreate");
}
if (options.noDeps) {
args.push("--no-deps");
}
if (options.service) {
validateServiceName(options.service);
args.push(options.service);
}
return this.composeExec(host, project, "up", args);
}
}