807 lines
25 KiB
JavaScript
807 lines
25 KiB
JavaScript
/**
|
|
* 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<string, object>, 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<string, object>} */
|
|
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<string, string>,
|
|
* }} 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<string, string>,
|
|
* 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<string>} 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<typeof parseComposeFile>} parsed
|
|
* @param {string} stackName
|
|
* @param {{ rollback?: boolean, beforeIds?: Set<string> }} [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<Array<{ id: string, name: string, success: boolean, error?: string }>>}
|
|
*/
|
|
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')
|
|
}
|