UNPKG

@pact-toolbox/docker

Version:

Modern Docker container orchestration for Pact Toolbox

1,274 lines (1,269 loc) 54.8 kB
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