/** * Docker Compose helpers: js-yaml parse + docker compose CLI lifecycle. */ import { spawn } from 'child_process' import fs from 'fs' import path from 'path' import os from 'os' import yaml from 'js-yaml' import logger from './logger.js' import { startContainerNoBody } from '../services/docker.js' /** * Parse docker-compose YAML with js-yaml and normalize service fields. * @param {string} composeContent * @returns {{ version: string|null, services: Record, networks: object, volumes: object, raw: object }} */ export function parseComposeFile(composeContent) { if (!composeContent || typeof composeContent !== 'string') { throw new Error('Compose content is required') } let raw try { raw = yaml.load(composeContent, { schema: yaml.DEFAULT_SCHEMA }) } catch (err) { throw new Error(`Invalid compose YAML: ${err.message}`) } if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Compose file must be a YAML mapping') } const servicesIn = raw.services if (!servicesIn || typeof servicesIn !== 'object' || Array.isArray(servicesIn)) { throw new Error('Compose file must define a "services" mapping') } /** @type {Record} */ const services = {} for (const [name, def] of Object.entries(servicesIn)) { if (!def || typeof def !== 'object') { throw new Error(`Service "${name}" must be a mapping`) } services[name] = normalizeService(name, def) } return { version: raw.version != null ? String(raw.version) : null, services, networks: raw.networks && typeof raw.networks === 'object' ? raw.networks : {}, volumes: raw.volumes && typeof raw.volumes === 'object' ? raw.volumes : {}, raw, } } /** * @param {string} name * @param {object} def */ function normalizeService(name, def) { const environment = normalizeEnvironment(def.environment) const ports = normalizeStringList(def.ports) const volumes = normalizeStringList(def.volumes) const networks = normalizeNetworks(def.networks) const depends_on = normalizeDependsOn(def.depends_on) let command = def.command ?? null if (Array.isArray(command)) command = command.map(String) else if (command != null) command = String(command) let entrypoint = def.entrypoint ?? null if (Array.isArray(entrypoint)) entrypoint = entrypoint.map(String) else if (entrypoint != null) entrypoint = String(entrypoint) return { name, image: def.image != null ? String(def.image) : null, build: def.build ?? null, ports, volumes, environment, networks, depends_on, restart: def.restart != null ? String(def.restart) : 'no', command, entrypoint, labels: def.labels && typeof def.labels === 'object' ? def.labels : {}, working_dir: def.working_dir || def.workingDir || null, user: def.user != null ? String(def.user) : null, } } function normalizeEnvironment(env) { if (!env) return [] if (Array.isArray(env)) return env.map(String) if (typeof env === 'object') { return Object.entries(env).map(([k, v]) => (v === null || v === undefined ? k : `${k}=${v}`)) } return [] } function normalizeStringList(value) { if (!value) return [] if (Array.isArray(value)) { return value.map((item) => { if (typeof item === 'string') return item if (item && typeof item === 'object') { // long syntax: { target, published, ... } or volume objects if (item.published != null && item.target != null) { return `${item.published}:${item.target}${item.protocol ? '/' + item.protocol : ''}` } if (item.source != null && item.target != null) { return `${item.source}:${item.target}${item.read_only ? ':ro' : ''}` } return JSON.stringify(item) } return String(item) }) } return [String(value)] } function normalizeNetworks(networks) { if (!networks) return [] if (Array.isArray(networks)) return networks.map(String) if (typeof networks === 'object') return Object.keys(networks) return [] } function normalizeDependsOn(depends) { if (!depends) return [] if (Array.isArray(depends)) return depends.map(String) if (typeof depends === 'object') return Object.keys(depends) return [String(depends)] } /** * Validate compose content; throws on invalid YAML / missing services. * @param {string} composeContent */ export function validateComposeFile(composeContent) { const parsed = parseComposeFile(composeContent) const names = Object.keys(parsed.services) if (names.length === 0) throw new Error('Compose file has no services') for (const [name, svc] of Object.entries(parsed.services)) { if (!svc.image && !svc.build) { throw new Error(`Service "${name}" needs an image or build`) } } return parsed } /** * Run `docker compose` in a temp dir with the given YAML. * @param {string[]} args - args after `docker compose -f file -p project` * @param {{ * composeContent: string, * projectName: string, * timeoutMs?: number, * overrideContent?: string|null, * envFileContent?: string|null, * profiles?: string[], * env?: Record, * }} opts * @returns {Promise<{ code: number, stdout: string, stderr: string }>} */ export function runComposeCli(args, { composeContent, projectName, timeoutMs = 120000, overrideContent = null, envFileContent = null, profiles = [], env = {}, }) { return new Promise((resolve, reject) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-compose-')) const file = path.join(dir, 'docker-compose.yml') fs.writeFileSync(file, composeContent, 'utf8') const fileArgs = ['-f', file] if (overrideContent) { const overrideFile = path.join(dir, 'docker-compose.override.yml') fs.writeFileSync(overrideFile, overrideContent, 'utf8') fileArgs.push('-f', overrideFile) } if (envFileContent) { fs.writeFileSync(path.join(dir, '.env'), envFileContent, 'utf8') } const profileArgs = [] for (const p of profiles || []) { if (p) profileArgs.push('--profile', String(p)) } const fullArgs = ['compose', ...fileArgs, '-p', projectName, ...profileArgs, ...args] const child = spawn('docker', fullArgs, { env: { ...process.env, ...env }, cwd: dir, }) let stdout = '' let stderr = '' const timer = setTimeout(() => { child.kill('SIGTERM') reject(new Error(`docker compose timed out after ${timeoutMs}ms`)) }, timeoutMs) child.stdout?.on('data', (d) => { stdout += d.toString() }) child.stderr?.on('data', (d) => { stderr += d.toString() }) child.on('error', (err) => { clearTimeout(timer) cleanup(dir) reject(err) }) child.on('close', (code) => { clearTimeout(timer) cleanup(dir) resolve({ code: code ?? 1, stdout, stderr }) }) }) } /** * Run compose against an existing project name (no YAML write) when possible. * Uses label-based discovery; for CLI, still needs a file — callers pass content when available. */ function cleanup(dir) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch { // ignore } } /** * Deploy via `docker compose up -d` when CLI is available; else dockerode fallback. * @param {import('dockerode')} docker * @param {string} composeContent * @param {string} stackName * @param {{ * overrideContent?: string|null, * envFileContent?: string|null, * profiles?: string[], * env?: Record, * build?: boolean, * }} [options] */ export async function deployComposeStack(docker, composeContent, stackName, options = {}) { // Validate base compose; overrides may add services so validate base at minimum const parsed = validateComposeFile(composeContent) if (options.overrideContent) { try { parseComposeFile(options.overrideContent) } catch (err) { throw new Error(`Invalid override YAML: ${err.message}`) } } const rollback = options.rollback !== false const cliOpts = { composeContent, projectName: stackName, timeoutMs: 300000, overrideContent: options.overrideContent || null, envFileContent: options.envFileContent || null, profiles: options.profiles || [], env: options.env || {}, } // Snapshot existing project containers for rollback const beforeIds = new Set( (await listProjectContainerIds(docker, stackName)) ) // Prefer official Compose V2 CLI try { const upArgs = ['up', '-d', '--remove-orphans'] if (options.build) upArgs.push('--build') const result = await runComposeCli(upArgs, cliOpts) if (result.code === 0) { return { success: true, stackName, method: 'compose-cli', services: Object.keys(parsed.services).map((s) => ({ service: s, status: 'up' })), message: `Stack "${stackName}" deployed successfully`, stdout: result.stdout, rollback: false, } } // Fall through only if compose binary missing-style errors if (!/not found|No such file|unknown command/i.test(result.stderr + result.stdout)) { if (rollback) { await rollbackNewContainers(docker, stackName, beforeIds) } throw new Error(result.stderr || result.stdout || `docker compose exited ${result.code}`) } logger.warn('docker compose CLI failed, using dockerode fallback', { stderr: result.stderr }) } catch (err) { if (err.message && !/ENOENT|not found|spawn/i.test(err.message)) { if (!/ENOENT|spawn docker/i.test(err.message)) { // Already rolled back if CLI returned non-zero above; rethrow deploy errors if (err.message.includes('docker compose exited') || err.message.includes('rolled back')) { throw err } logger.warn('compose CLI deploy error, trying dockerode', { error: err.message }) } } } return deployViaDockerode(docker, parsed, stackName, { rollback, beforeIds }) } /** * @param {import('dockerode')} docker * @param {string} stackName */ async function listProjectContainerIds(docker, stackName) { const containers = await docker.listContainers({ all: true }) return containers .filter((c) => c.Labels?.['com.docker.compose.project'] === stackName) .map((c) => c.Id) } /** * Remove containers created after `beforeIds` for a project (partial deploy cleanup). * @param {import('dockerode')} docker * @param {string} stackName * @param {Set} beforeIds */ async function rollbackNewContainers(docker, stackName, beforeIds) { const ids = await listProjectContainerIds(docker, stackName) const removed = [] for (const id of ids) { if (beforeIds.has(id)) continue try { const c = docker.getContainer(id) try { await c.stop({ t: 5 }) } catch { // may already be stopped } await c.remove({ force: true }) removed.push(id) } catch (err) { logger.warn('rollback remove failed', { id, error: err.message }) } } if (removed.length) { logger.info('Rolled back partial stack deploy', { stackName, removed: removed.length }) } return removed } /** * @param {import('dockerode')} docker * @param {ReturnType} parsed * @param {string} stackName * @param {{ rollback?: boolean, beforeIds?: Set }} [opts] */ async function deployViaDockerode(docker, parsed, stackName, opts = {}) { const results = [] const deployedServices = new Set() /** @type {string[]} containers we created this deploy (for rollback) */ const createdIds = [] const rollback = opts.rollback !== false async function deployService(serviceName) { if (deployedServices.has(serviceName)) return const service = parsed.services[serviceName] if (!service) throw new Error(`Unknown service dependency: ${serviceName}`) for (const dep of service.depends_on || []) { if (parsed.services[dep] && !deployedServices.has(dep)) { await deployService(dep) } } const containerName = `${stackName}_${serviceName}` const existingContainers = await docker.listContainers({ all: true }) const existing = existingContainers.find((c) => c.Names?.some((n) => n.includes(containerName))) if (existing) { logger.info(`Container ${containerName} already exists, skipping`) deployedServices.add(serviceName) results.push({ service: serviceName, status: 'exists', containerId: existing.Id }) return } if (!service.image) { throw new Error( `Service "${serviceName}" has no image (build-only services require docker compose CLI)` ) } const containerConfig = { name: containerName, Image: service.image, Labels: { 'com.docker.compose.project': stackName, 'com.docker.compose.service': serviceName, ...(flattenLabels(service.labels) || {}), }, } if (service.command) { containerConfig.Cmd = Array.isArray(service.command) ? service.command : String(service.command).split(/\s+/) } if (service.entrypoint) { containerConfig.Entrypoint = Array.isArray(service.entrypoint) ? service.entrypoint : String(service.entrypoint).split(/\s+/) } if (service.environment?.length) containerConfig.Env = service.environment if (service.working_dir) containerConfig.WorkingDir = service.working_dir if (service.user) containerConfig.User = service.user const hostConfig = { RestartPolicy: { Name: service.restart || 'no' }, } if (service.ports?.length) { hostConfig.PortBindings = {} containerConfig.ExposedPorts = {} for (const portStr of service.ports) { const mapping = parsePortMapping(portStr) if (!mapping) continue const key = `${mapping.containerPort}/${mapping.protocol}` containerConfig.ExposedPorts[key] = {} hostConfig.PortBindings[key] = [{ HostPort: mapping.hostPort || '' }] } } if (service.volumes?.length) { hostConfig.Binds = service.volumes.filter((v) => typeof v === 'string' && v.includes(':')) } containerConfig.HostConfig = hostConfig const container = await docker.createContainer(containerConfig) createdIds.push(container.id) try { await startContainerNoBody(container.id) } catch (startErr) { if (rollback) { for (const id of createdIds) { try { await docker.getContainer(id).remove({ force: true }) } catch { // ignore } } throw new Error( `Partial deploy rolled back after service "${serviceName}" failed to start: ${startErr.message}` ) } throw startErr } deployedServices.add(serviceName) results.push({ service: serviceName, status: 'created', containerId: container.id }) logger.info(`Deployed service ${serviceName} as container ${containerName}`) } try { for (const serviceName of Object.keys(parsed.services)) { await deployService(serviceName) } } catch (err) { if (rollback && createdIds.length && !/rolled back/i.test(err.message)) { for (const id of createdIds) { try { await docker.getContainer(id).remove({ force: true }) } catch { // ignore } } throw new Error(`Partial deploy rolled back: ${err.message}`) } throw err } return { success: true, stackName, method: 'dockerode', services: results, message: `Stack "${stackName}" deployed successfully`, rollback: false, } } function flattenLabels(labels) { if (!labels || typeof labels !== 'object') return null if (Array.isArray(labels)) { const out = {} for (const item of labels) { const s = String(item) const i = s.indexOf('=') if (i > 0) out[s.slice(0, i)] = s.slice(i + 1) } return out } const out = {} for (const [k, v] of Object.entries(labels)) out[k] = String(v) return out } function parsePortMapping(portStr) { // "8080:80/tcp", "80", "127.0.0.1:8080:80" const s = String(portStr).replace(/^["']|["']$/g, '') const protocolMatch = s.match(/\/(tcp|udp)$/i) const protocol = protocolMatch ? protocolMatch[1].toLowerCase() : 'tcp' const withoutProto = protocolMatch ? s.slice(0, -protocolMatch[0].length) : s const parts = withoutProto.split(':') if (parts.length === 1) { return { hostPort: '', containerPort: parts[0], protocol } } if (parts.length === 2) { return { hostPort: parts[0], containerPort: parts[1], protocol } } if (parts.length === 3) { return { hostPort: parts[1], containerPort: parts[2], protocol } } return null } /** * @param {import('dockerode')} docker */ export async function listStacks(docker) { try { const containers = await docker.listContainers({ all: true }) const stacks = {} for (const container of containers) { const labels = container.Labels || {} const project = labels['com.docker.compose.project'] const service = labels['com.docker.compose.service'] if (!project) continue if (!stacks[project]) { stacks[project] = { name: project, services: [], containers: [], } } stacks[project].services.push(service || 'unknown') stacks[project].containers.push({ id: container.Id, name: container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12), state: container.State, status: container.Status, image: container.Image, service: service || 'unknown', }) } return Object.values(stacks) } catch (error) { logger.error('Failed to list stacks', { error: error.message }) throw error } } /** Built-in Docker networks that must never be removed. */ const PREDEFINED_NETWORKS = new Set(['bridge', 'host', 'none']) /** * Networks owned by a compose project (created via `docker compose up`). * @param {import('dockerode')} docker * @param {string} stackName */ async function listProjectNetworks(docker, stackName) { const networks = await docker.listNetworks() return networks.filter((n) => { if (!n?.Name || PREDEFINED_NETWORKS.has(n.Name)) return false const labels = n.Labels || {} return labels['com.docker.compose.project'] === stackName }) } /** * Remove compose project networks after containers are gone. * Matches `docker compose down` behavior (networks yes, volumes no). * @param {import('dockerode')} docker * @param {string} stackName * @returns {Promise>} */ async function removeProjectNetworks(docker, stackName) { const projectNetworks = await listProjectNetworks(docker, stackName) const results = [] for (const netInfo of projectNetworks) { const id = netInfo.Id const name = netInfo.Name try { await docker.getNetwork(id).remove() results.push({ id, name, success: true }) logger.info('Removed stack network', { stackName, name, id: id?.slice?.(0, 12) }) } catch (error) { results.push({ id, name, success: false, error: error.message }) logger.warn('Failed to remove stack network', { stackName, name, error: error.message, }) } } return results } /** * @param {import('dockerode')} docker * @param {string} stackName */ export async function removeComposeStack(docker, stackName) { // Remove project containers, then project-scoped networks (compose down parity). // CLI `compose down` needs a compose file; we use label-based dockerode cleanup instead. const containers = await docker.listContainers({ all: true }) const stackContainers = containers.filter((c) => { const labels = c.Labels || {} return labels['com.docker.compose.project'] === stackName }) const networkCandidates = await listProjectNetworks(docker, stackName) if (stackContainers.length === 0 && networkCandidates.length === 0) { return { success: true, stackName, removed: 0, networksRemoved: 0, results: [], networkResults: [], message: `Stack "${stackName}" not found or already removed`, } } const results = [] for (const containerInfo of stackContainers) { try { const container = docker.getContainer(containerInfo.Id) if (containerInfo.State === 'running') { await container.stop({ t: 10 }) } await container.remove({ force: true }) results.push({ id: containerInfo.Id, success: true }) } catch (error) { results.push({ id: containerInfo.Id, success: false, error: error.message }) } } // Containers must be gone (or disconnected) before networks can be removed. const networkResults = await removeProjectNetworks(docker, stackName) const removed = results.filter((r) => r.success).length const networksRemoved = networkResults.filter((r) => r.success).length const parts = [] if (removed) parts.push(`${removed} container(s)`) if (networksRemoved) parts.push(`${networksRemoved} network(s)`) const detail = parts.length ? parts.join(', ') : 'nothing left to remove' return { success: true, stackName, removed, networksRemoved, results, networkResults, message: `Stack "${stackName}" removed successfully (${detail})`, } } /** * List containers for a compose project (ps). * @param {import('dockerode')} docker * @param {string} stackName */ export async function stackPs(docker, stackName) { const stacks = await listStacks(docker) const stack = stacks.find((s) => s.name === stackName) if (!stack) { return { success: true, stackName, containers: [], message: 'Stack not found' } } return { success: true, type: 'stackPs', stackName, containers: stack.containers, services: stack.services, } } /** * Collect recent logs from all containers in a stack. * @param {import('dockerode')} docker * @param {string} stackName * @param {{ tail?: number }} [opts] */ export async function stackLogs(docker, stackName, opts = {}) { const tail = Math.min(Number(opts.tail) || 100, 2000) const stacks = await listStacks(docker) const stack = stacks.find((s) => s.name === stackName) if (!stack) throw new Error(`Stack "${stackName}" not found`) const logs = [] for (const c of stack.containers) { try { const buf = await docker.getContainer(c.id).logs({ stdout: true, stderr: true, tail, timestamps: true, }) const text = Buffer.isBuffer(buf) ? demuxDockerLogs(buf) : String(buf) logs.push({ containerId: c.id, name: c.name, service: c.service, logs: text, }) } catch (err) { logs.push({ containerId: c.id, name: c.name, service: c.service, error: err.message, }) } } return { success: true, type: 'stackLogs', stackName, data: logs } } /** * Pull images for services in a stack (by listing containers' images + compose not required). * @param {import('dockerode')} docker * @param {string} stackName * @param {{ composeContent?: string }} [opts] */ export async function stackPull(docker, stackName, opts = {}) { const images = new Set() if (opts.composeContent) { const parsed = parseComposeFile(opts.composeContent) for (const svc of Object.values(parsed.services)) { if (svc.image) images.add(svc.image) } } else { const stacks = await listStacks(docker) const stack = stacks.find((s) => s.name === stackName) if (!stack) throw new Error(`Stack "${stackName}" not found`) for (const c of stack.containers) { if (c.image) images.add(c.image) } } const results = [] for (const image of images) { try { const stream = await docker.pull(image) await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve())) }) results.push({ image, success: true }) } catch (err) { results.push({ image, success: false, error: err.message }) } } return { success: results.every((r) => r.success), type: 'stackPull', stackName, results, message: `Pulled ${results.filter((r) => r.success).length}/${results.length} images for "${stackName}"`, } } /** Strip docker multiplex headers from log buffers when possible. */ function demuxDockerLogs(buffer) { // Heuristic: if looks like muxed frames, strip 8-byte headers try { const chunks = [] let offset = 0 while (offset + 8 <= buffer.length) { const size = buffer.readUInt32BE(offset + 4) if (size < 0 || offset + 8 + size > buffer.length) { return buffer.toString('utf8') } chunks.push(buffer.subarray(offset + 8, offset + 8 + size).toString('utf8')) offset += 8 + size } if (chunks.length) return chunks.join('') } catch { // fall through } return buffer.toString('utf8') }