import type Docker from "dockerode";
import { DEFAULT_EXEC_MAX_BUFFER, DEFAULT_EXEC_TIMEOUT } from "../../constants.js";
import type {
ContainerExecResult,
ContainerInfo,
ContainerProcessList,
ContainerStats,
DockerRawContainerInfo,
DockerRawContainerInspectInfo,
HostConfig,
LogEntry,
} from "../../types.js";
import { logError } from "../../utils/errors.js";
import { narrowToDefaultHost } from "../../utils/host-utils.js";
import { applyPagination } from "../../utils/pagination.js";
import type { ClientManager } from "./utils/client-manager.js";
import {
createStreamContext,
executeWithTimeout,
setupExecInstance,
} from "./utils/exec-handler.js";
import { parseDockerLogs, parseTimeSpec } from "./utils/log-parser.js";
/**
* Service for Docker container operations.
* Handles container lifecycle, monitoring, and execution.
*/
export class ContainerService {
constructor(private clientManager: ClientManager) {}
private matchesContainerIdentifier(
container: Pick<DockerRawContainerInfo, "Id" | "Names">,
containerId: string
): boolean {
const query = containerId.trim();
if (!query) return false;
if (container.Id.startsWith(query)) return true;
const normalizedQuery = query.replace(/^\//, "");
const names = Array.isArray(container.Names) ? container.Names : [];
return names.some((name) => name.replace(/^\//, "") === normalizedQuery);
}
/**
* List containers across multiple hosts with filtering and pagination.
*
* @param hosts - List of Docker hosts to query
* @param options - Filtering and pagination options
* @param options.state - Filter by container state
* @param options.nameFilter - Filter by container name (partial match)
* @param options.imageFilter - Filter by image name (partial match)
* @param options.labelFilter - Filter by label (format: "key=value")
* @param options.limit - Maximum number of containers to return
* @param options.offset - Number of containers to skip before returning results
* @returns Promise resolving to array of container information
*/
async listContainers(
hosts: HostConfig[],
options: {
state?: "all" | "running" | "stopped" | "paused";
nameFilter?: string;
imageFilter?: string;
labelFilter?: string;
limit?: number;
offset?: number;
} = {}
): Promise<ContainerInfo[]> {
const targetHosts = narrowToDefaultHost(hosts);
// Query all hosts in parallel using Promise.allSettled
const results = await Promise.allSettled(
targetHosts.map((host) => this.listContainersOnHost(host, options))
);
// Collect results from successful queries, log failures
const containers: ContainerInfo[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "fulfilled") {
containers.push(...result.value);
} else {
logError(result.reason, {
operation: "listContainers",
metadata: { host: targetHosts[i].name },
});
}
}
// Apply pagination using shared utility
return applyPagination(containers, options.offset, options.limit);
}
/**
* List containers on a single host (internal helper).
*/
private async listContainersOnHost(
host: HostConfig,
options: {
state?: "all" | "running" | "stopped" | "paused";
nameFilter?: string;
imageFilter?: string;
labelFilter?: string;
}
): Promise<ContainerInfo[]> {
const docker = await this.clientManager.getClient(host);
const listOptions: Docker.ContainerListOptions = {
all: options.state !== "running",
};
// Add label filter if specified
if (options.labelFilter) {
listOptions.filters = { label: [options.labelFilter] };
}
const containers = await docker.listContainers(listOptions);
const results: ContainerInfo[] = [];
for (const c of containers) {
const containerState = c.State?.toLowerCase() as ContainerInfo["state"];
// Apply state filter
if (options.state && options.state !== "all") {
if (options.state === "stopped" && containerState !== "exited") continue;
if (options.state === "paused" && containerState !== "paused") continue;
if (options.state === "running" && containerState !== "running") continue;
}
const names = Array.isArray(c.Names) ? c.Names : [];
const name = names[0]?.replace(/^\//, "") || c.Id.slice(0, 12);
// Apply name filter
if (options.nameFilter && !name.toLowerCase().includes(options.nameFilter.toLowerCase())) {
continue;
}
// Apply image filter
if (
options.imageFilter &&
!c.Image.toLowerCase().includes(options.imageFilter.toLowerCase())
) {
continue;
}
results.push({
id: c.Id,
name,
image: c.Image,
state: containerState,
status: c.Status,
created: new Date(c.Created * 1000).toISOString(),
ports: (c.Ports || []).map((p) => ({
containerPort: p.PrivatePort,
hostPort: p.PublicPort,
protocol: p.Type as "tcp" | "udp",
hostIp: p.IP,
})),
labels: c.Labels || {},
hostName: host.name,
});
}
return results;
}
/**
* Find which host a container is on.
* Performs parallel scan across hosts for optimal performance.
*
* Note: For cache-optimized lookups, use DockerService.findContainerHost() with
* ContainerHostMapCache instead. The cache requires client-provider capability
* and is integrated at the facade level.
*
* @param containerId - Container ID, name, or ID prefix
* @param hosts - List of hosts to search
* @returns Host and container info if found, null otherwise
*/
async findContainerHost(
containerId: string,
hosts: HostConfig[]
): Promise<{ host: HostConfig; container: DockerRawContainerInfo } | null> {
if (hosts.length === 0) {
return null;
}
// Parallel scan across hosts with early return: resolve immediately when found.
return new Promise<{ host: HostConfig; container: DockerRawContainerInfo } | null>(
(resolve) => {
let completed = 0;
let settled = false;
const finishIfDone = (): void => {
completed += 1;
if (!settled && completed === hosts.length) {
settled = true;
resolve(null);
}
};
for (const host of hosts) {
void (async () => {
try {
const docker = await this.clientManager.getClient(host);
const containers = await docker.listContainers({ all: true });
const found = containers.find((container) =>
this.matchesContainerIdentifier(container, containerId)
);
if (found && !settled) {
settled = true;
resolve({ host, container: found });
return;
}
} catch (error) {
// Log errors but continue searching other hosts
logError(error, { metadata: { containerId } });
} finally {
finishIfDone();
}
})();
}
}
);
}
/**
* Perform action on container.
*
* @param containerId - Container ID or name
* @param action - Action to perform
* @param host - Host configuration
*/
async containerAction(
containerId: string,
action: "start" | "stop" | "restart" | "pause" | "unpause",
host: HostConfig
): Promise<void> {
const container = await this.getContainer(containerId, host);
switch (action) {
case "start":
await container.start();
break;
case "stop":
await container.stop({ t: 10 });
break;
case "restart":
await container.restart({ t: 10 });
break;
case "pause":
await container.pause();
break;
case "unpause":
await container.unpause();
break;
}
}
/**
* Get container by ID or name.
*/
private async getContainer(containerId: string, host: HostConfig): Promise<Docker.Container> {
const docker = await this.clientManager.getClient(host);
return docker.getContainer(containerId);
}
/**
* Get container logs.
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @param options - Log filtering options
* @returns Promise resolving to array of log entries
*/
async getContainerLogs(
containerId: string,
host: HostConfig,
options: {
lines?: number;
since?: string;
until?: string;
stream?: "all" | "stdout" | "stderr";
} = {}
): Promise<LogEntry[]> {
const container = await this.getContainer(containerId, host);
const logOptions: {
stdout: boolean;
stderr: boolean;
tail: number;
timestamps: boolean;
follow: false;
since?: number;
until?: number;
} = {
stdout: options.stream !== "stderr",
stderr: options.stream !== "stdout",
tail: options.lines || 100,
timestamps: true,
follow: false,
};
if (options.since) {
logOptions.since = parseTimeSpec(options.since);
}
if (options.until) {
logOptions.until = parseTimeSpec(options.until);
}
const logs = await container.logs(logOptions);
return parseDockerLogs(logs.toString());
}
/**
* Get container stats.
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @param containerName - Optional container name (avoids redundant inspect() call)
* @returns Promise resolving to container resource usage stats
*/
async getContainerStats(
containerId: string,
host: HostConfig,
containerName?: string
): Promise<ContainerStats> {
const container = await this.getContainer(containerId, host);
const stats = await container.stats({ stream: false });
// Calculate CPU percentage
const cpuDelta =
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || 1;
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCount * 100 : 0;
// Memory stats
const memUsage = stats.memory_stats.usage || 0;
const memLimit = stats.memory_stats.limit || 1;
const memPercent = (memUsage / memLimit) * 100;
// Network stats
let netRx = 0,
netTx = 0;
if (stats.networks) {
for (const net of Object.values(stats.networks)) {
netRx += (net as { rx_bytes: number }).rx_bytes || 0;
netTx += (net as { tx_bytes: number }).tx_bytes || 0;
}
}
// Block I/O
let blockRead = 0,
blockWrite = 0;
if (stats.blkio_stats?.io_service_bytes_recursive) {
for (const entry of stats.blkio_stats.io_service_bytes_recursive) {
if (entry.op === "read") blockRead += entry.value;
if (entry.op === "write") blockWrite += entry.value;
}
}
// Use provided name if available, otherwise fetch via inspect()
// This eliminates 1 API call per container when name is known (50% reduction)
let finalName: string;
if (containerName) {
finalName = containerName;
} else {
const info = await container.inspect();
finalName = info.Name.replace(/^\//, "");
}
return {
containerId,
containerName: finalName,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage: memUsage,
memoryLimit: memLimit,
memoryPercent: Math.round(memPercent * 100) / 100,
networkRx: netRx,
networkTx: netTx,
blockRead,
blockWrite,
};
}
/**
* Execute a command inside a container.
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @param options - Execution options
* @param options.command - Shell command to execute
* @param options.user - Optional user to run as
* @param options.workdir - Optional working directory
* @param options.timeout - Optional timeout in ms (default 30s, max 5min)
* @returns Promise resolving to stdout, stderr, and exit code
* @throws Error if timeout exceeded or buffer limit exceeded
*/
async execContainer(
containerId: string,
host: HostConfig,
options: { command: string; user?: string; workdir?: string; timeout?: number }
): Promise<ContainerExecResult> {
const container = await this.getContainer(containerId, host);
const timeout = options.timeout ?? DEFAULT_EXEC_TIMEOUT;
const maxBuffer = DEFAULT_EXEC_MAX_BUFFER;
const exec = await setupExecInstance(container, options);
const docker = await this.clientManager.getClient(host);
const context = await createStreamContext(exec, docker.modem, maxBuffer);
try {
await executeWithTimeout(context, timeout, maxBuffer);
const inspection = await exec.inspect();
return {
stdout: Buffer.concat(context.stdoutChunks).toString().trimEnd(),
stderr: Buffer.concat(context.stderrChunks).toString().trimEnd(),
exitCode: inspection.ExitCode ?? 0,
};
} catch (error) {
context.cleanup();
throw error;
}
}
/**
* List running processes inside a container.
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @returns Promise resolving to process list with titles and data
*/
async getContainerProcesses(
containerId: string,
host: HostConfig
): Promise<ContainerProcessList> {
const container = await this.getContainer(containerId, host);
const result = await container.top();
return {
titles: result.Titles ?? [],
processes: result.Processes ?? [],
};
}
/**
* Inspect a container to get detailed information.
*
* @param containerId - Container ID or name
* @param host - Host configuration
* @returns Promise resolving to Docker container inspection data
*/
async inspectContainer(
containerId: string,
host: HostConfig
): Promise<DockerRawContainerInspectInfo> {
const container = await this.getContainer(containerId, host);
return container.inspect();
}
}