@pact-toolbox/docker
Version:
Modern Docker container orchestration for Pact Toolbox
1,274 lines (1,269 loc) • 54.8 kB
JavaScript
import { existsSync, readFileSync, statSync } from "node:fs";
import { LogLevels, colors, existsSync as existsSync$1, logger } from "@pact-toolbox/node-utils";
import { join } from "node:path";
import * as tar from "tar-fs";
import Docker from "dockerode";
//#region src/utils.ts
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
function isDockerInstalled() {
const socket = DOCKER_SOCKET;
try {
const stats = statSync(socket);
return stats.isSocket();
} catch (e) {
logger.error(`Docker is not installed or the socket is not accessible: ${e}`);
return false;
}
}
const CHALK_SERVICE_COLORS = [
colors.cyan,
colors.green,
colors.yellow,
colors.blue,
colors.magenta,
colors.red
];
let colorIndex = 0;
const serviceChalkColorMap = /* @__PURE__ */ new Map();
function getServiceColor(serviceName) {
if (!serviceChalkColorMap.has(serviceName)) {
const selectedChalkFunction = CHALK_SERVICE_COLORS[colorIndex % CHALK_SERVICE_COLORS.length];
serviceChalkColorMap.set(serviceName, selectedChalkFunction);
colorIndex++;
}
return serviceChalkColorMap.get(serviceName);
}
/**
* Create a service tag with color
*/
function createServiceTag(serviceName) {
const colorFn = getServiceColor(serviceName);
return colorFn(`[${serviceName}]`);
}
/**
* Reset service colors for testing
*/
function resetServiceColors() {
serviceChalkColorMap.clear();
colorIndex = 0;
}
//#endregion
//#region src/service.ts
var DockerService = class {
serviceName;
config;
containerName;
healthCheckFailed = false;
#docker;
#networkName;
#containerId;
#logStream = null;
#coloredPrefix;
#logger;
constructor(config, options) {
this.serviceName = options.serviceName || config.containerName;
this.config = config;
this.containerName = config.containerName;
this.#docker = options.docker;
this.#networkName = options.networkName;
const colorizer = process.stdout.isTTY ? getServiceColor(this.serviceName) : null;
this.#coloredPrefix = colorizer ? colorizer(this.serviceName) : this.serviceName;
this.#logger = options.logger.withTag(this.#coloredPrefix);
}
async #pullImage() {
if (!this.config.image) return;
try {
const image = this.#docker.getImage(this.config.image);
await image.inspect();
return;
} catch (error) {
if (error.statusCode !== 404) throw error;
}
this.#logger.start(`Pulling image '${this.config.image}'...`);
try {
const stream = await this.#docker.pull(this.config.image, { platform: this.config.platform });
await new Promise((resolve, reject) => {
this.#docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve(), (event) => {
if (event.status && event.progress) this.#logger.debug(`${event.status}: ${event.progress}`);
});
});
this.#logger.success(`Image '${this.config.image}' pulled successfully.`);
} catch (error) {
this.#logger.error(`Error pulling image '${this.config.image}':`, error);
throw error;
}
}
async #buildImage() {
if (!this.config.build || !this.config.image) return;
this.#logger.start(`Building image '${this.config.image}' from context '${this.config.build.context}'...`);
const buildConfig = this.config.build;
const dockerfilePath = join(buildConfig.context, buildConfig.dockerfile || "Dockerfile");
if (!existsSync$1(dockerfilePath)) throw new Error(`Dockerfile not found at ${dockerfilePath}`);
const tarStream = tar.pack(buildConfig.context, { ignore: (name) => {
return name.includes("node_modules") || name.includes(".git");
} });
try {
const buildOptions = {
t: this.config.image,
dockerfile: buildConfig.dockerfile || "Dockerfile",
q: false,
pull: buildConfig.pull,
platform: this.config.platform,
target: buildConfig.target,
networkmode: buildConfig.network,
shmsize: buildConfig.shm_size ? this.#parseMemory(buildConfig.shm_size) : void 0
};
if (buildConfig.args) buildOptions.buildargs = buildConfig.args;
if (buildConfig.labels) buildOptions.labels = buildConfig.labels;
if (buildConfig.cache_from) buildOptions.cachefrom = buildConfig.cache_from;
const buildStream = this.#docker.buildImage(tarStream, buildOptions);
await new Promise((resolve, reject) => {
this.#docker.modem.followProgress(buildStream, (err, res) => {
if (err) return reject(err);
if (res && res.length > 0) {
const lastMessage = res[res.length - 1];
if (lastMessage?.errorDetail) return reject(new Error(lastMessage.errorDetail.message));
}
resolve();
}, (event) => {
if (event.stream) this.#logger.debug(event.stream.trim());
else if (event.status) this.#logger.debug(`${event.status}: ${event.progress || ""}`);
});
});
this.#logger.success(`Image '${this.config.image}' built successfully.`);
} catch (error) {
this.#logger.error(`Error building image '${this.config.image}':`, error);
throw error;
}
}
async prepareImage() {
if (this.config.build && this.config.image) await this.#buildImage();
else if (this.config.image) await this.#pullImage();
}
#parsePorts() {
if (!this.config.ports) return void 0;
const portBindings = {};
this.config.ports.forEach((p) => {
const protocol = p.protocol || "tcp";
const hostPort = String(p.published);
const containerPort = `${p.target}/${protocol}`;
portBindings[containerPort] = [{
HostPort: hostPort,
HostIp: p.mode === "host" ? "0.0.0.0" : void 0
}];
});
return portBindings;
}
#parseVolumes() {
if (!this.config.volumes) return void 0;
const volumes = [];
for (const volume of this.config.volumes) if (typeof volume === "string") volumes.push(volume);
else {
const volumeConfig = volume;
let volumeString = "";
if (volumeConfig.type === "bind" || volumeConfig.type === "volume") {
volumeString = `${volumeConfig.source}:${volumeConfig.target}`;
if (volumeConfig.readOnly) volumeString += ":ro";
if (volumeConfig.bind?.propagation) volumeString += `:${volumeConfig.bind.propagation}`;
} else if (volumeConfig.type === "tmpfs") continue;
if (volumeString) volumes.push(volumeString);
}
return volumes.length > 0 ? volumes : void 0;
}
#parseTmpfs() {
if (!this.config.tmpfs && !this.config.volumes) return void 0;
const tmpfs = {};
if (this.config.tmpfs) {
const tmpfsList = Array.isArray(this.config.tmpfs) ? this.config.tmpfs : [this.config.tmpfs];
tmpfsList.forEach((path) => {
tmpfs[path] = "";
});
}
if (this.config.volumes) {
for (const volume of this.config.volumes) if (typeof volume === "object" && volume.type === "tmpfs") {
const volumeConfig = volume;
let options = "";
if (volumeConfig.tmpfs?.size) options += `size=${volumeConfig.tmpfs.size}`;
if (volumeConfig.tmpfs?.mode) options += options ? `,mode=${volumeConfig.tmpfs.mode}` : `mode=${volumeConfig.tmpfs.mode}`;
tmpfs[volumeConfig.target] = options;
}
}
return Object.keys(tmpfs).length > 0 ? tmpfs : void 0;
}
#parseEnvironment() {
const env = [];
if (this.config.environment) if (Array.isArray(this.config.environment)) env.push(...this.config.environment);
else env.push(...Object.entries(this.config.environment).map(([k, v]) => `${k}=${v}`));
if (this.config.envFile) {
const envFiles = Array.isArray(this.config.envFile) ? this.config.envFile : [this.config.envFile];
for (const envFile of envFiles) try {
if (existsSync$1(envFile)) {
const content = readFileSync(envFile, "utf-8");
const lines = content.split("\n").filter((line) => line.trim() && !line.startsWith("#"));
env.push(...lines);
}
} catch (error) {
this.#logger.warn(`Failed to read env file ${envFile}:`, error);
}
}
return env.length > 0 ? env : void 0;
}
#parseMemory(memory) {
if (!memory) return void 0;
const units = {
b: 1,
k: 1024,
m: 1024 * 1024,
g: 1024 * 1024 * 1024
};
const match = memory.toLowerCase().match(/^(\d+)([bkmg])?$/);
if (!match) return void 0;
const value = parseInt(match[1]);
const unit = match[2] || "b";
return value * units[unit];
}
#parseNetworkingConfig() {
if (!this.config.networks) return { EndpointsConfig: { [this.#networkName]: {} } };
const endpointsConfig = {};
if (Array.isArray(this.config.networks)) {
this.config.networks.forEach((networkName) => {
endpointsConfig[networkName] = {};
});
endpointsConfig[this.#networkName] = {};
} else Object.entries(this.config.networks).forEach(([networkName, config]) => {
const networkConfig = config;
endpointsConfig[networkName] = {
Aliases: networkConfig.aliases,
IPAMConfig: {
IPv4Address: networkConfig.ipv4Address,
IPv6Address: networkConfig.ipv6Address
},
Links: [],
NetworkID: void 0
};
});
return { EndpointsConfig: endpointsConfig };
}
async start() {
this.#logger.start(`Starting service instance...`);
await this.prepareImage();
try {
const existingContainer = this.#docker.getContainer(this.containerName);
const inspectInfo = await existingContainer.inspect();
this.#logger.warn(`Container '${this.containerName}' already exists (State: ${inspectInfo.State.Status}). Attempting to remove it.`);
if (inspectInfo.State.Running) await existingContainer.stop({ t: this.config.stopGracePeriod || 10 }).catch((err) => this.#logger.warn(`Could not stop existing container: ${err.message}`));
await existingContainer.remove().catch((err) => this.#logger.warn(`Could not remove existing container: ${err.message}`));
this.#logger.log(`Existing container '${this.containerName}' removed.`);
} catch (error) {
if (error.statusCode !== 404) {
this.#logger.error(`Error checking for existing container:`, error.message || error);
throw error;
}
}
let restartPolicy = void 0;
const deployRestartPolicy = this.config.deploy?.restartPolicy;
const topLevelRestart = this.config.restart;
if (deployRestartPolicy) {
let dockerodeConditionName = "no";
const composeCondition = deployRestartPolicy.condition;
if (composeCondition === "none") dockerodeConditionName = "no";
else if (composeCondition === "on-failure" || composeCondition === "unless-stopped" || composeCondition === "always") dockerodeConditionName = composeCondition;
else if (composeCondition) this.#logger.warn(`Unsupported deploy.restart_policy.condition: '${composeCondition}'. Defaulting to 'no'.`);
restartPolicy = {
Name: dockerodeConditionName,
MaximumRetryCount: deployRestartPolicy.maxAttempts
};
} else if (topLevelRestart) if (topLevelRestart === "on-failure" || topLevelRestart === "unless-stopped" || topLevelRestart === "always" || topLevelRestart === "no") restartPolicy = { Name: topLevelRestart };
else {
this.#logger.warn(`Unsupported top-level restart value: '${topLevelRestart}'. Defaulting to 'no'.`);
restartPolicy = { Name: "no" };
}
const createOptions = {
name: this.containerName,
Image: this.config.image,
Cmd: this.config.command,
Entrypoint: typeof this.config.entrypoint === "string" ? [this.config.entrypoint] : this.config.entrypoint,
Env: this.#parseEnvironment(),
ExposedPorts: {},
Labels: this.config.labels,
User: this.config.user,
WorkingDir: this.config.workingDir,
Hostname: this.config.hostname,
Domainname: this.config.domainName,
MacAddress: this.config.macAddress,
AttachStdin: false,
AttachStdout: false,
AttachStderr: false,
Tty: false,
OpenStdin: false,
StdinOnce: false,
HostConfig: {
RestartPolicy: restartPolicy,
PortBindings: this.#parsePorts(),
Binds: this.#parseVolumes(),
NetworkMode: this.#networkName,
Ulimits: this.config.ulimits,
Privileged: this.config.privileged,
CapAdd: this.config.capAdd,
CapDrop: this.config.capDrop,
Devices: this.config.devices?.map((device) => ({
PathOnHost: device,
PathInContainer: device,
CgroupPermissions: "rwm"
})),
Dns: Array.isArray(this.config.dns) ? this.config.dns : this.config.dns ? [this.config.dns] : void 0,
DnsSearch: Array.isArray(this.config.dnsSearch) ? this.config.dnsSearch : this.config.dnsSearch ? [this.config.dnsSearch] : void 0,
DnsOptions: this.config.dnsOpt,
ExtraHosts: this.config.extraHosts,
IpcMode: this.config.ipc,
PidMode: this.config.pid,
Init: this.config.init,
Isolation: this.config.isolation,
Memory: this.#parseMemory(this.config.memLimit),
MemoryReservation: this.#parseMemory(this.config.memReservation),
MemorySwap: this.#parseMemory(this.config.memSwapLimit),
MemorySwappiness: this.config.memSwappiness,
OomKillDisable: this.config.oomKillDisable,
OomScoreAdj: this.config.oomScoreAdj,
CpuShares: this.config.cpuShares,
CpuQuota: this.config.cpuQuota,
CpuPeriod: this.config.cpuPeriod,
CpusetCpus: this.config.cpusetCpus,
CpusetMems: this.config.cpusetMems,
BlkioWeight: this.config.blkioWeight,
BlkioWeightDevice: void 0,
BlkioDeviceReadBps: this.config.deviceReadBps?.map((d) => ({
Path: d.path,
Rate: parseInt(d.rate)
})),
BlkioDeviceWriteBps: this.config.deviceWriteBps?.map((d) => ({
Path: d.path,
Rate: parseInt(d.rate)
})),
BlkioDeviceReadIOps: this.config.deviceReadIops?.map((d) => ({
Path: d.path,
Rate: d.rate
})),
BlkioDeviceWriteIOps: this.config.deviceWriteIops?.map((d) => ({
Path: d.path,
Rate: d.rate
})),
LogConfig: this.config.logging ? {
Type: this.config.logging.driver || "json-file",
Config: this.config.logging.options || {}
} : void 0,
Tmpfs: this.#parseTmpfs()
},
NetworkingConfig: this.#parseNetworkingConfig(),
StopSignal: this.config.stopSignal,
StopTimeout: this.config.stopGracePeriod,
Healthcheck: this.config.healthCheck,
platform: this.config.platform
};
if (this.config.expose) this.config.expose.forEach((p) => {
createOptions.ExposedPorts[`${p}/tcp`] = {};
});
if (this.config.ports) this.config.ports.forEach((p) => {
const protocol = p.protocol || "tcp";
createOptions.ExposedPorts[`${p.target}/${protocol}`] = {};
});
if (this.config.deploy?.resources?.limits?.cpus) {
const cpus = parseFloat(this.config.deploy.resources.limits.cpus);
createOptions.HostConfig.CpuQuota = Math.floor(cpus * 1e5);
createOptions.HostConfig.CpuPeriod = 1e5;
}
if (this.config.deploy?.resources?.limits?.memory) createOptions.HostConfig.Memory = this.#parseMemory(this.config.deploy.resources.limits.memory);
try {
this.#logger.start(`Creating container '${this.containerName}' with image '${this.config.image}'...`);
const container = await this.#docker.createContainer(createOptions);
this.#containerId = container.id;
this.#logger.log(`Container '${this.containerName}' (ID: ${this.#containerId}) created. Starting...`);
await container.start();
this.#logger.log(`Service started (Container: ${this.containerName}).`);
} catch (error) {
this.#logger.error(`Error starting service:`, error.message || error, error.json ? JSON.stringify(error.json) : "");
throw error;
}
}
async stop() {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) {
this.#logger.warn(`No container ID or name to stop.`);
return;
}
try {
const container = this.#docker.getContainer(containerRef);
const inspectInfo = await container.inspect().catch(() => null);
if (!inspectInfo) {
this.#logger.log(`Container '${containerRef}' not found for stopping.`);
return;
}
if (inspectInfo.State.Status !== "running") {
this.#logger.log(`Container '${this.containerName}' is not running (Status: ${inspectInfo.State.Status}).`);
return;
}
this.#logger.log(`Stopping container '${this.containerName}' (ID: ${inspectInfo.Id})...`);
const stopTimeout = this.config.stopGracePeriod || 10;
const stopSignal = this.config.stopSignal || "SIGTERM";
try {
await container.stop({
t: stopTimeout,
signal: stopSignal
});
this.#logger.log(`Container '${this.containerName}' stopped gracefully.`);
} catch (stopError) {
if (stopError.statusCode === 304) this.#logger.log(`Container '${this.containerName}' was already stopped.`);
else {
this.#logger.warn(`Graceful stop failed, attempting force kill...`);
await container.kill();
this.#logger.log(`Container '${this.containerName}' force killed.`);
}
}
} catch (error) {
if (error.statusCode === 404) this.#logger.log(`Container '${containerRef}' not found during stop.`);
else {
this.#logger.warn(`Error stopping container '${this.containerName}':`, error.message || error);
throw error;
}
}
}
async remove() {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) {
this.#logger.warn(`No container ID or name to remove.`);
return;
}
try {
const container = this.#docker.getContainer(containerRef);
await container.inspect().catch((err) => {
if (err.statusCode === 404) this.#logger.log(`Container '${containerRef}' not found before removal, attempting removal anyway.`);
else throw err;
});
this.#logger.log(`Removing container '${this.containerName}'...`);
await container.remove({ force: true });
this.#logger.log(`Container '${this.containerName}' removed.`);
} catch (error) {
if (error.statusCode === 404) this.#logger.log(`Container '${containerRef}' was already removed.`);
else this.#logger.warn(`Error removing container '${this.containerName}':`, error.message || error);
}
}
async isHealthy() {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) return false;
try {
const data = await this.#docker.getContainer(containerRef).inspect();
if (!data.State.Health) return data.State.Running;
return data.State.Health.Status === "healthy";
} catch (error) {
if (error.statusCode !== 404) this.#logger.error(`Error checking health for container '${containerRef}':`, error.message || error);
return false;
}
}
async getState() {
const containerRef = this.#containerId || this.containerName;
try {
const data = await this.#docker.getContainer(containerRef).inspect();
let status = "stopped";
if (data.State.Running) status = "running";
else if (data.State.Status === "created") status = "creating";
else if (data.State.Status === "exited") status = "stopped";
if (data.State.Health?.Status === "healthy") status = "healthy";
else if (data.State.Health?.Status === "unhealthy") status = "unhealthy";
const state = {
id: this.serviceName,
status,
containerId: data.Id,
startTime: data.State.StartedAt ? new Date(data.State.StartedAt) : void 0,
endTime: data.State.FinishedAt ? new Date(data.State.FinishedAt) : void 0,
restartCount: data.RestartCount || 0,
health: data.State.Health?.Status,
ports: this.config.ports?.map((p) => `${p.published}:${p.target}/${p.protocol || "tcp"}`) || []
};
return state;
} catch (error) {
return {
id: this.serviceName,
status: "failed",
restartCount: 0,
error,
ports: []
};
}
}
async waitForHealthy(timeoutMs = 12e4, intervalMs = 1e3) {
if (!this.config.healthCheck) {
this.#logger.log(`No health check defined for '${this.containerName}'. Assuming healthy.`);
return;
}
this.#logger.log(`Waiting for container '${this.containerName}' to become healthy (timeout: ${timeoutMs}ms, interval: ${intervalMs}ms)...`);
const startTime = Date.now();
let lastStatus = "";
while (Date.now() - startTime < timeoutMs) {
try {
const containerRef = this.#containerId || this.containerName;
const data = await this.#docker.getContainer(containerRef).inspect();
if (!data.State.Running) throw new Error(`Container '${this.containerName}' is not running (Status: ${data.State.Status})`);
const healthStatus = data.State.Health?.Status;
if (healthStatus !== lastStatus) {
this.#logger.debug(`Health status for '${this.containerName}': ${healthStatus}`);
lastStatus = healthStatus || "";
}
if (healthStatus === "healthy") {
this.#logger.log(`Container '${this.containerName}' is healthy.`);
this.healthCheckFailed = false;
return;
}
if (healthStatus === "unhealthy") {
const lastLog = data.State.Health?.Log?.[data.State.Health.Log.length - 1];
const errorMsg = lastLog ? ` Last health check output: ${lastLog.Output}` : "";
throw new Error(`Container '${this.containerName}' became unhealthy.${errorMsg}`);
}
} catch (error) {
if (error.statusCode === 404) throw new Error(`Container '${this.containerName}' not found during health check`);
if (error.message.includes("unhealthy") || error.message.includes("not running")) throw error;
this.#logger.warn(`Health check attempt failed for '${this.containerName}': ${error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
this.healthCheckFailed = true;
throw new Error(`Timeout waiting for container '${this.containerName}' to become healthy after ${timeoutMs}ms.`);
}
async streamLogs() {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) {
this.#logger.warn(`No container ID or name to stream logs from.`);
return;
}
try {
const container = this.#docker.getContainer(containerRef);
const inspectInfo = await container.inspect().catch(() => null);
if (!inspectInfo || !inspectInfo.State.Running) {
this.#logger.warn(`Container '${containerRef}' is not running. Cannot stream logs.`);
return;
}
this.#logger.log(`Attaching to logs of container '${this.containerName}'...`);
const stream = await container.logs({
follow: true,
stdout: true,
stderr: true,
timestamps: true
});
this.#logStream = stream;
this.#logStream.on("data", (chunk) => {
let logLine = chunk.toString("utf8");
const potentiallyPrefixed = /^[^a-zA-Z0-9\s\p{P}]*(?=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})/u;
logLine = logLine.replace(potentiallyPrefixed, "");
logLine = logLine.replace(/[^\x20-\x7E\n\r\t]/g, "");
const trimmedMessage = logLine.trimEnd();
if (trimmedMessage) trimmedMessage.split("\n").forEach((line) => {
if (line.trim()) console.log(`${this.#coloredPrefix} ${line}`);
});
});
this.#logStream.on("end", () => {
this.#logger.log(`Log stream ended for container '${this.containerName}'.`);
this.#logStream = null;
});
this.#logStream.on("error", (err) => {
this.#logger.error(`Error in log stream for container '${this.containerName}':`, err);
this.#logStream = null;
});
} catch (error) {
this.#logger.error(`Error attaching to logs for container '${this.containerName}':`, error.message || error);
this.#logStream = null;
}
}
stopLogStream() {
if (this.#logStream) {
this.#logger.log(`Detaching from logs of container '${this.containerName}'.`);
try {
if (typeof this.#logStream.destroy === "function") this.#logStream.destroy();
else if (typeof this.#logStream.end === "function") this.#logStream.end();
} catch (error) {
this.#logger.warn(`Error stopping log stream for '${this.containerName}':`, error);
} finally {
this.#logStream = null;
}
}
}
/**
* Get container logs without streaming
*/
async getLogs(tail = 100) {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) return [];
try {
const container = this.#docker.getContainer(containerRef);
const logs = await container.logs({
stdout: true,
stderr: true,
tail,
timestamps: true
});
return logs.toString().split("\n").filter((line) => line.trim());
} catch (error) {
this.#logger.error(`Error getting logs for container '${containerRef}':`, error.message || error);
return [];
}
}
/**
* Execute a command in the running container
*/
async exec(command) {
const containerRef = this.#containerId || this.containerName;
if (!containerRef) throw new Error("No container to execute command in");
try {
const container = this.#docker.getContainer(containerRef);
const exec = await container.exec({
Cmd: command,
AttachStdout: true,
AttachStderr: true
});
const stream = await exec.start({
hijack: true,
stdin: false
});
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
stream.on("data", (chunk) => {
const header = chunk.readUInt8(0);
const data = chunk.slice(8).toString();
if (header === 1) stdout += data;
else if (header === 2) stderr += data;
});
stream.on("end", async () => {
try {
const inspectResult = await exec.inspect();
resolve({
stdout,
stderr,
exitCode: inspectResult.ExitCode || 0
});
} catch (error) {
reject(error);
}
});
stream.on("error", reject);
});
} catch (error) {
this.#logger.error(`Error executing command in container '${containerRef}':`, error.message || error);
throw error;
}
}
};
//#endregion
//#region src/orchestrator.ts
var ContainerOrchestrator = class {
#docker = new Docker();
#networkName;
#networkId;
#runningServices;
#logger;
#volumes;
constructor(config) {
this.#networkName = config.networkName;
this.#runningServices = /* @__PURE__ */ new Map();
this.#logger = config.logger ?? logger.create({ level: LogLevels.info });
this.#volumes = config.volumes || [];
}
async #getOrCreateNetwork() {
try {
const network = this.#docker.getNetwork(this.#networkName);
const inspectInfo = await network.inspect();
this.#networkId = inspectInfo.Id;
} catch (error) {
if (error.statusCode === 404) {
this.#logger.start(`Creating network '${this.#networkName}'...`);
const createdNetwork = await this.#docker.createNetwork({
Name: this.#networkName,
Driver: "bridge"
});
this.#networkId = createdNetwork.id;
this.#logger.success(`Network '${this.#networkName}' created (ID: ${this.#networkId}).`);
} else {
this.#logger.error(`Error inspecting/creating network ${this.#networkName}:`, error.message || error);
throw error;
}
}
if (!this.#networkId) throw new Error(`Failed to obtain network ID for '${this.#networkName}'`);
}
async #createVolumes() {
for (const volume of this.#volumes) try {
this.#docker.getVolume(volume);
} catch (error) {
if (error.statusCode === 404) await this.#docker.createVolume({ Name: volume });
}
}
#resolveServiceOrder(services) {
const serviceMap = new Map(services.map((s) => [s.containerName, s]));
const dependencies = /* @__PURE__ */ new Map();
for (const service of services) {
const serviceGroupName = service.containerName;
if (!dependencies.has(serviceGroupName)) dependencies.set(serviceGroupName, /* @__PURE__ */ new Set());
if (service.dependsOn) {
const deps = dependencies.get(serviceGroupName);
Object.keys(service.dependsOn).forEach((depGroupName) => {
if (serviceMap.has(depGroupName)) deps.add(depGroupName);
else this.#logger.warn(`Dependency '${depGroupName}' for service '${serviceGroupName}' not found in defined services. It will be ignored.`);
});
}
}
const sorted = [];
const visited = /* @__PURE__ */ new Set();
const visiting = /* @__PURE__ */ new Set();
const visit = (serviceGroupName) => {
if (visited.has(serviceGroupName)) return;
if (visiting.has(serviceGroupName)) throw new Error(`Circular dependency detected: ${serviceGroupName}`);
visiting.add(serviceGroupName);
const serviceDeps = dependencies.get(serviceGroupName);
if (serviceDeps) for (const dep of serviceDeps) visit(dep);
visiting.delete(serviceGroupName);
visited.add(serviceGroupName);
sorted.push(serviceGroupName);
};
for (const service of services) {
const serviceGroupName = service.containerName;
if (!visited.has(serviceGroupName)) visit(serviceGroupName);
}
return sorted;
}
/**
* Handle graceful shutdown signals
*/
setupGracefulShutdown() {
const shutdown = async (signal) => {
this.#logger.info(`Received ${signal}, initiating graceful shutdown...`);
try {
await this.stopAllServices();
process.exit(0);
} catch (error) {
this.#logger.error("Error during graceful shutdown:", error);
process.exit(1);
}
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("uncaughtException", async (error) => {
this.#logger.error("Uncaught exception:", error);
try {
await this.stopAllServices();
} catch (shutdownError) {
this.#logger.error("Error during emergency shutdown:", shutdownError);
}
process.exit(1);
});
process.on("unhandledRejection", async (reason, promise) => {
this.#logger.error("Unhandled promise rejection at:", promise, "reason:", reason);
try {
await this.stopAllServices();
} catch (shutdownError) {
this.#logger.error("Error during emergency shutdown:", shutdownError);
}
process.exit(1);
});
}
async startServices(serviceConfigs) {
this.#logger.start(`Starting services...`);
await this.#getOrCreateNetwork();
await this.#createVolumes();
const orderedServiceGroupNames = this.#resolveServiceOrder(serviceConfigs);
this.#logger.info(`Service group startup order: ${orderedServiceGroupNames.join(", ")}`);
for (const serviceGroupName of orderedServiceGroupNames) {
const config = serviceConfigs.find((s) => s.containerName === serviceGroupName);
if (!config) {
this.#logger.warn(`Config for service group '${serviceGroupName}' not found. Skipping.`);
continue;
}
const replicaCount = config.deploy?.replicas || 1;
const serviceInstances = [];
this.#logger.info(`Preparing to start ${replicaCount} instance(s) of service group '${serviceGroupName}'...`);
for (let i = 0; i < replicaCount; i++) {
const instanceName = replicaCount > 1 ? `${serviceGroupName}-${i + 1}` : serviceGroupName;
const instanceConfig = {
...config,
containerName: instanceName
};
const service = new DockerService(instanceConfig, {
serviceName: instanceName,
docker: this.#docker,
networkName: this.#networkName,
logger: this.#logger
});
if (config.dependsOn) for (const depGroupName of Object.keys(config.dependsOn)) {
const depServiceGroupInstances = this.#runningServices.get(depGroupName);
if (!depServiceGroupInstances || depServiceGroupInstances.length === 0) throw new Error(`Dependency group '${depGroupName}' for '${instanceName}' not started or has no instances.`);
if (config.dependsOn[depGroupName]?.condition === "service_healthy") {
this.#logger.start(`Instance '${instanceName}' waiting for all instances of '${depGroupName}' to be healthy...`);
try {
await Promise.all(depServiceGroupInstances.map((depInstance) => depInstance.waitForHealthy()));
this.#logger.success(`All instances of '${depGroupName}' are healthy for '${instanceName}'.`);
} catch (healthError) {
this.#logger.error(`Health check failed for at least one instance of dependency group '${depGroupName}' for '${instanceName}': ${healthError.message}`);
throw new Error(`Dependency group '${depGroupName}' for '${instanceName}' failed to become healthy.`);
}
}
}
try {
this.#logger.start(`Starting instance '${instanceName}' of service group '${serviceGroupName}'...`);
await service.start();
serviceInstances.push(service);
} catch (startError) {
this.#logger.error(`Failed to start instance '${instanceName}' of service group '${serviceGroupName}': ${startError.message}`);
throw startError;
}
}
this.#runningServices.set(serviceGroupName, serviceInstances);
this.#logger.success(`All ${replicaCount} instance(s) of service group '${serviceGroupName}' attempted to start.`);
}
this.#logger.success(`All provided service groups attempted to start.`);
}
async streamAllLogs() {
for (const serviceInstances of this.#runningServices.values()) for (const service of serviceInstances) service.streamLogs().catch((err) => {
this.#logger.error(`Error starting log stream for ${service.serviceName}: ${err.message}`);
});
}
stopAllLogStreams() {
for (const serviceInstances of this.#runningServices.values()) for (const service of serviceInstances) service.stopLogStream();
}
async stopAllServices() {
this.#logger.start(`Gracefully shutting down all services...`);
this.stopAllLogStreams();
const serviceGroupNamesToStop = Array.from(this.#runningServices.keys()).reverse();
for (const serviceGroupName of serviceGroupNamesToStop) {
const serviceInstances = this.#runningServices.get(serviceGroupName);
if (serviceInstances) {
this.#logger.start(`Stopping ${serviceInstances.length} instance(s) of service group '${serviceGroupName}'...`);
await Promise.all(serviceInstances.map(async (service) => {
await service.stop();
await service.remove();
}));
this.#logger.success(`All instances of service group '${serviceGroupName}' stopped and removed.`);
}
}
this.#runningServices.clear();
if (this.#networkId) {
try {
const network = this.#docker.getNetwork(this.#networkId);
const netInfo = await network.inspect().catch(() => null);
if (netInfo && netInfo.Containers && Object.keys(netInfo.Containers).length > 0) this.#logger.warn(`Network '${this.#networkName}' (ID: ${this.#networkId}) still has containers: ${Object.keys(netInfo.Containers).join(", ")}. Manual cleanup may be required.`);
else if (netInfo) {
this.#logger.info(`Removing network '${this.#networkName}' (ID: ${this.#networkId})...`);
await network.remove();
this.#logger.success(`Network '${this.#networkName}' removed.`);
} else this.#logger.info(`Network '${this.#networkName}' (ID: ${this.#networkId}) not found, likely already removed.`);
} catch (error) {
if (error.statusCode === 404) this.#logger.info(`Network '${this.#networkName}' (ID: ${this.#networkId}) was already removed.`);
else this.#logger.warn(`Error removing network '${this.#networkName}':`, error.message || error);
}
this.#networkId = void 0;
}
this.#logger.success(`Service cleanup complete.`);
}
};
//#endregion
//#region src/compose-utils.ts
/**
* Convert Docker Compose service definition to our DockerServiceConfig format
*/
function convertComposeService(serviceName, composeService) {
const config = {
containerName: composeService.container_name || serviceName,
image: composeService.image,
platform: composeService.platform,
restart: composeService.restart,
stopSignal: composeService.stop_signal,
stopGracePeriod: composeService.stop_grace_period ? parseTime(composeService.stop_grace_period) : void 0
};
if (composeService.build) if (typeof composeService.build === "string") config.build = { context: composeService.build };
else config.build = {
context: composeService.build.context,
dockerfile: composeService.build.dockerfile,
args: composeService.build.args,
target: composeService.build.target,
labels: composeService.build.labels,
cache_from: composeService.build.cache_from,
cache_to: composeService.build.cache_to,
network: composeService.build.network,
shm_size: composeService.build.shm_size,
extra_hosts: composeService.build.extra_hosts,
isolation: composeService.build.isolation,
privileged: composeService.build.privileged,
pull: composeService.build.pull,
platforms: composeService.build.platforms,
tags: composeService.build.tags,
ulimits: composeService.build.ulimits
};
if (composeService.ports) config.ports = composeService.ports.map((port) => {
if (typeof port === "string") {
const parts = port.split(":");
if (parts.length === 2) return {
published: parseInt(parts[0]),
target: parseInt(parts[1]),
protocol: "tcp"
};
} else if (typeof port === "object") return {
published: port.published,
target: port.target,
protocol: port.protocol || "tcp",
mode: port.mode
};
return port;
});
if (composeService.volumes) config.volumes = composeService.volumes.map((volume) => {
if (typeof volume === "string") return volume;
else return {
type: volume.type,
source: volume.source,
target: volume.target,
readOnly: volume.read_only,
bind: volume.bind,
volume: volume.volume,
tmpfs: volume.tmpfs
};
});
if (composeService.environment) config.environment = composeService.environment;
if (composeService.env_file) config.envFile = composeService.env_file;
if (composeService.labels) config.labels = composeService.labels;
if (composeService.networks) if (Array.isArray(composeService.networks)) config.networks = composeService.networks;
else config.networks = composeService.networks;
if (composeService.depends_on) if (Array.isArray(composeService.depends_on)) {
config.dependsOn = {};
composeService.depends_on.forEach((dep) => {
config.dependsOn[dep] = { condition: "service_started" };
});
} else config.dependsOn = composeService.depends_on;
config.command = composeService.command;
config.entrypoint = composeService.entrypoint;
config.expose = composeService.expose;
if (composeService.healthcheck) config.healthCheck = {
Test: composeService.healthcheck.test,
Interval: composeService.healthcheck.interval ? parseTime(composeService.healthcheck.interval) * 1e9 : void 0,
Timeout: composeService.healthcheck.timeout ? parseTime(composeService.healthcheck.timeout) * 1e9 : void 0,
Retries: composeService.healthcheck.retries,
StartPeriod: composeService.healthcheck.start_period ? parseTime(composeService.healthcheck.start_period) * 1e9 : void 0
};
if (composeService.deploy) config.deploy = {
replicas: composeService.deploy.replicas,
restartPolicy: composeService.deploy.restart_policy,
updateConfig: composeService.deploy.update_config,
rollbackConfig: composeService.deploy.rollback_config,
resources: composeService.deploy.resources,
placement: composeService.deploy.placement,
labels: composeService.deploy.labels,
mode: composeService.deploy.mode,
endpointMode: composeService.deploy.endpoint_mode
};
config.memLimit = composeService.mem_limit;
config.memReservation = composeService.mem_reservation;
config.memSwapLimit = composeService.memswap_limit;
config.memSwappiness = composeService.mem_swappiness;
config.oomKillDisable = composeService.oom_kill_disable;
config.oomScoreAdj = composeService.oom_score_adj;
config.cpus = composeService.cpus;
config.cpuShares = composeService.cpu_shares;
config.cpuQuota = composeService.cpu_quota;
config.cpuPeriod = composeService.cpu_period;
config.cpusetCpus = composeService.cpuset;
config.cpusetMems = composeService.cpumems;
config.privileged = composeService.privileged;
config.user = composeService.user;
config.workingDir = composeService.working_dir;
config.hostname = composeService.hostname;
config.domainName = composeService.domainname;
config.macAddress = composeService.mac_address;
config.dns = composeService.dns;
config.dnsSearch = composeService.dns_search;
config.dnsOpt = composeService.dns_opt;
config.extraHosts = composeService.extra_hosts;
config.ipc = composeService.ipc;
config.pid = composeService.pid;
config.cgroupns = composeService.cgroup_parent;
config.init = composeService.init;
config.isolation = composeService.isolation;
config.tmpfs = composeService.tmpfs;
config.devices = composeService.devices;
config.capAdd = composeService.cap_add;
config.capDrop = composeService.cap_drop;
config.ulimits = composeService.ulimits?.map((ulimit) => ({
Name: ulimit.name || Object.keys(ulimit)[0],
Soft: ulimit.soft || ulimit[Object.keys(ulimit)[0]],
Hard: ulimit.hard || ulimit[Object.keys(ulimit)[0]]
}));
if (composeService.logging) config.logging = {
driver: composeService.logging.driver,
options: composeService.logging.options
};
config.profiles = composeService.profiles;
return config;
}
/**
* Convert Docker Compose networks to our NetworkConfig format
*/
function convertComposeNetworks(composeNetworks) {
return Object.entries(composeNetworks).map(([name, network]) => ({
name,
driver: network.driver,
driverOpts: network.driver_opts,
attachable: network.attachable,
internal: network.internal,
ipam: network.ipam,
enableIpv6: network.enable_ipv6,
labels: network.labels,
external: network.external
}));
}
/**
* Convert Docker Compose volumes to our VolumeDefinition format
*/
function convertComposeVolumes(composeVolumes) {
return Object.entries(composeVolumes).map(([name, volume]) => ({
name,
driver: volume.driver,
driverOpts: volume.driver_opts,
labels: volume.labels,
external: volume.external
}));
}
/**
* Parse time strings like "1m30s", "45s", "2h" into seconds
*/
function parseTime(timeStr) {
if (typeof timeStr === "number") return timeStr;
const match = timeStr.match(/^(\d+)([smhd]?)$/);
if (!match) {
let total = 0;
const parts = timeStr.match(/(\d+)([smhd])/g);
if (parts) {
for (const part of parts) {
const partMatch = part.match(/(\d+)([smhd])/);
if (partMatch) {
const value$1 = parseInt(partMatch[1]);
const unit$1 = partMatch[2];
switch (unit$1) {
case "s":
total += value$1;
break;
case "m":
total += value$1 * 60;
break;
case "h":
total += value$1 * 3600;
break;
case "d":
total += value$1 * 86400;
break;
}
}
}
return total;
}
return 30;
}
const value = parseInt(match[1]);
const unit = match[2] || "s";
switch (unit) {
case "s": return value;
case "m": return value * 60;
case "h": return value * 3600;
case "d": return value * 86400;
default: return value;
}
}
/**
* Validate service configuration for common issues
*/
function validateServiceConfig(config) {
const issues = [];
if (!config.image && !config.build) issues.push("Service must have either an image or build configuration");
if (config.build && !config.build.context) issues.push("Build configuration must have a context");
if (config.build && config.build.context && !existsSync(config.build.context)) issues.push(`Build context directory does not exist: ${config.build.context}`);
if (config.build && config.build.dockerfile) {
const dockerfilePath = join(config.build.context, config.build.dockerfile);
if (!existsSync(dockerfilePath)) issues.push(`Dockerfile does not exist: ${dockerfilePath}`);
}
if (config.envFile) {
const envFiles = Array.isArray(config.envFile) ? config.envFile : [config.envFile];
for (const envFile of envFiles) if (!existsSync(envFile)) issues.push(`Environment file does not exist: ${envFile}`);
}
if (config.dependsOn) {
for (const [depName, depConfig] of Object.entries(config.dependsOn)) if (!depConfig.condition) issues.push(`Dependency '${depName}' missing condition`);
}
return issues;
}
/**
* Resolve relative paths in service configuration
*/
function resolveServicePaths(config, basePath) {
const resolvedConfig = { ...config };
if (resolvedConfig.build) {
resolvedConfig.build = { ...resolvedConfig.build };
if (resolvedConfig.build.context && !resolvedConfig.build.context.startsWith("/")) resolvedConfig.build.context = join(basePath, resolvedConfig.build.context);
}
if (resolvedConfig.envFile) {
if (Array.isArray(resolvedConfig.envFile)) resolvedConfig.envFile = resolvedConfig.envFile.map((file) => file.startsWith("/") ? file : join(basePath, file));
else if (!resolvedConfig.envFile.startsWith("/")) resolvedConfig.envFile = join(basePath, resolvedConfig.envFile);
}
return resolvedConfig;
}
/**
* Generate a unique container name with optional suffix
*/
function generateContainerName(serviceName, projectName, suffix) {
const parts = [
projectName,
serviceName,
suffix
].filter(Boolean);
return parts.join("_");
}
/**
* Check if a service should be included based on active profiles
*/
function shouldIncludeService(config, activeProfiles) {
if (!config.profiles || config.profiles.length === 0) return activeProfiles.length === 0;
return config.profiles.some((profile) => activeProfiles.includes(profile));
}
//#endregion
//#region src/error-handler.ts
let DockerErrorType = /* @__PURE__ */ function(DockerErrorType$1) {
DockerErrorType$1["ImageNotFound"] = "IMAGE_NOT_FOUND";
DockerErrorType$1["ContainerNotFound"] = "CONTAINER_NOT_FOUND";
DockerErrorType$1["NetworkNotFound"] = "NETWORK_NOT_FOUND";
DockerErrorType$1["VolumeNotFound"] = "VOLUME_NOT_FOUND";
DockerErrorType$1["PortInUse"] = "PORT_IN_USE";
DockerErrorType$1["InsufficientResources"] = "INSUFFICIENT_RESOURCES";
DockerErrorType$1["BuildFailed"] = "BUILD_FAILED";
DockerErrorType$1["HealthCheckFailed"] = "HEALTH_CHECK_FAILED";
DockerErrorType$1["DependencyFailed"] = "DEPENDENCY_FAILED";
DockerErrorType$1["PermissionDenied"] = "PERMISSION_DENIED";
DockerErrorType$1["NetworkConflict"] = "NETWORK_CONFLICT";
DockerErrorType$1["VolumeInUse"] = "VOLUME_IN_USE";
DockerErrorType$1["InvalidConfiguration"] = "INVALID_CONFIGURATION";
DockerErrorType$1["Timeout"] = "TIMEOUT";
DockerErrorType$1["Unknown"] = "UNKNOWN";
return DockerErrorType$1;
}({});
var DockerErrorHandler = class {
logger;
constructor(logger$1) {
this.logger = logger$1;
}
/**
* Parse Docker API error and convert to our enhanced error format
*/
parseDockerError(error, context) {
const dockerError = {
name: "DockerError",
message: error.message || "Unknown Docker error",
type: DockerErrorType.Unknown,
code: error.statusCode || error.code,
context: error,
serviceName: context?.serviceName,
containerName: context?.containerName,
suggestions: []
};
if (error.statusCode) switch (error.statusCode) {
case 404:
dockerError.type = this.determineNotFoundType(error.message);
dockerError.suggestions = this.getNotFoundSuggestions(dockerError.type);
break;
case 409:
dockerError.type = this.determineConflictType(error.message);
dockerError.suggestions = this.getConflictSuggestions(dockerError.type);
break;
case 500:
dockerError.type = DockerErrorType.InsufficientResources;
dockerError.suggestions = [
"Check system resources (memory, disk space)",
"Verify Docker daemon is running properly",
"Check for resource limits or quotas"
];
break;
case 403:
dockerError.type = DockerErrorType.PermissionDenied;
dockerError.suggestions = [
"Check Docker daemon permissions",
"Verify user is in docker group",
"Check if running with sufficient privileges"
];
break;
}
if (error.message) {
const message = error.message.toLowerCase();
if (message.includes("port is already allocated") || message.includes("bind: address already in use")) {
dockerError.type = DockerErrorType.PortInUse;
dockerError.suggestions = [
"Change the host port to an available port",
"Stop the service using the conflicting port",
"Use dynamic port allocation"
];
} else if (message.includes("pull access denied") || message.includes("repository does not exist")) {
dockerError.type = DockerErrorType.ImageNotFound;
dockerError.suggestions = [
"Check if the image name and tag are correct",
"Verify you have access to the registry",
"Try pulling the image manually first"
];
} else if (message.includes("dockerfile") || message.includes("build")) {
dockerError.type = DockerErrorType.BuildFailed;
dockerError.suggestions = [
"Check Dockerfile syntax",
"Verify build context and dependencies",
"Review build logs for specific errors"
];
} else if (message.includes("health check") || message.includes("unhealthy")) {
dockerError.type = DockerErrorType.HealthCheckFailed;
dockerError.suggestions = [
"Review health check configuration",
"Check service logs for startup issues",
"Verify health check command is correct"
];
} else if (message.includes("timeout") || message.includes("timed out")) {
dockerError.type = DockerErrorType.Timeout;
dockerError.suggestions = [
"Increase timeout values",
"Check service startup time",
"Verify dependencies are available"
];
}
}
return dockerError;
}
determineNotFoundType(message) {
const msg = message.toLowerCase();
if (msg.includes("image") || msg.includes("repository")) return DockerErrorType.ImageNotFound;
else if (msg.includes("container")) return DockerErrorType.ContainerNotFound;
else if (msg.includes("network")) return DockerErrorType.NetworkNotFound;
else if (msg.includes("volume")) return DockerErrorType.VolumeNotFound;
return DockerErrorType.Unknown;
}
determineConflictType(message) {
const msg = message.toLowerCase();
if (msg.includes("network")) return DockerErrorType.NetworkConflict;
else if (msg.includes("volume") && msg.includes("in use")) return DockerErrorType.VolumeInUse;
else if (msg.includes("port") || msg.includes("address already in use")) return DockerErrorType.PortInUse;
return DockerErrorType.Unknown;
}
getNotFoundSuggestions(type) {
switch (type) {
case DockerErrorType.ImageNotFound: return [
"Verify the image name and tag are correct",
"Check if you have access to the registry",
"Try building the image if using a build configuration"
];
case DockerErrorType.ContainerNotFound: return [
"Check if the container was created successfully",
"Verify the container name is correct",
"Check if the container was removed"
];
case DockerErrorType.NetworkNotFound: return [
"Verify the network name is correct",
"Check if the network was created",
"Use the default bridge network if appropriate"
];
case DockerErrorType.VolumeNotFound: return [
"Verify the volume name is correct",
"Check if the volume was created",
"Use bind mounts instead of named volumes"
];
def