/** * Template / container deploy RPC handler. */ import { docker, startContainerNoBody } from '../services/docker.js' import * as validation from '../utils/validation.js' import { broadcastContainers } from './containers.js' import logger from '../utils/logger.js' import { formatDeployError } from '../utils/dockerErrors.js' /** * @param {string} imageRef * @returns {Promise} */ async function imageExistsLocally(imageRef) { if (!imageRef) return false try { await docker.getImage(imageRef).inspect() return true } catch { return false } } /** @param {string|undefined|null} a @param {string|undefined|null} b */ function containerIdsMatch(a, b) { if (!a || !b) return false const A = String(a) const B = String(b) return A === B || A.startsWith(B) || B.startsWith(A) } /** * Host ports published by a docker listContainers entry. * @param {object} c * @returns {Set} */ function hostPortsFromListEntry(c) { /** @type {Set} */ const ports = new Set() for (const p of c?.Ports || []) { if (p?.PublicPort) ports.add(Number(p.PublicPort)) } return ports } /** * Host ports requested by a deploy payload. * @param {{ ports?: string[] }} args * @returns {Set} */ function requestedHostPortsFromArgs(args) { /** @type {Set} */ const ports = new Set() if (!Array.isArray(args?.ports)) return ports for (const portStr of args.ports) { const sanitizedPort = validation.sanitizeString(portStr, 50) if (!sanitizedPort || !validation.isValidPortMapping(sanitizedPort)) continue if (sanitizedPort.includes(':')) { const [hostPort] = sanitizedPort.split(':') const hp = Number(String(hostPort || '').trim()) if (Number.isFinite(hp) && hp >= 1 && hp <= 65535) ports.add(hp) } else { const [containerPort] = sanitizedPort.split('/') const cp = Number(String(containerPort || '').trim()) if (Number.isFinite(cp) && cp >= 1 && cp <= 65535) ports.add(cp) } } return ports } /** * @param {string} base * @param {Set} taken */ function uniqueParkName(base, taken) { const root = String(base || 'container') .replace(/^\//, '') .replace(/[^a-zA-Z0-9._-]+/g, '-') .slice(0, 40) || 'container' const suffix = Date.now().toString(36) let candidate = `${root}-old-${suffix}`.slice(0, 63) if (!taken.has(candidate.toLowerCase()) && validation.isValidContainerName(candidate)) { return candidate } for (let i = 2; i < 1000; i++) { candidate = `${root}-old-${suffix}-${i}`.slice(0, 63) if (!taken.has(candidate.toLowerCase()) && validation.isValidContainerName(candidate)) { return candidate } } return `pd-old-${suffix}`.slice(0, 63) } /** * Stop + rename source so name and host ports free up for the new container. * Old container is kept until the new one is verified. * * @param {object} source - listContainers entry * @param {{ containerName?: string }} args * @returns {Promise<{ id: string, originalName: string, parkName: string, wasRunning: boolean }>} */ async function parkSourceContainer(source, args) { const id = source.Id const originalName = String(source.Names?.[0] || '') .replace(/^\//, '') || String(args?.sourceContainerName || '').replace(/^\//, '') || id.slice(0, 12) const wasRunning = String(source.State || '').toLowerCase() === 'running' const container = docker.getContainer(id) if (wasRunning) { try { await container.stop({ t: 10 }) } catch { // already stopped / gone } } const all = await docker.listContainers({ all: true }) /** @type {Set} */ const taken = new Set() for (const c of all) { for (const n of c.Names || []) { taken.add(String(n).replace(/^\//, '').toLowerCase()) } } const parkName = uniqueParkName(originalName, taken) try { await container.rename({ name: parkName }) } catch (renameErr) { // Best-effort restore if we stopped it if (wasRunning) { try { await startContainerNoBody(id) } catch { // ignore } } throw formatDeployError(renameErr, { stage: 'replace', containerName: originalName, image: args?.image, }) } logger.info('Parked source container for swap deploy', { id: String(id).slice(0, 12), originalName, parkName, wasRunning, }) return { id, originalName, parkName, wasRunning } } /** * Wait until container is running (and healthy when a healthcheck exists). * @param {string} id * @param {{ timeoutMs?: number }} [opts] */ async function verifyContainerReady(id, opts = {}) { const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 45000 const started = Date.now() let lastStatus = 'unknown' while (Date.now() - started < timeoutMs) { const info = await docker.getContainer(id).inspect() const state = info?.State || {} lastStatus = state.Status || lastStatus if (state.Dead || state.OOMKilled) { throw new Error( `Container died after start (status=${state.Status || 'dead'}${ state.ExitCode != null ? `, exit=${state.ExitCode}` : '' })` ) } if (state.Status === 'exited' || state.Status === 'dead') { throw new Error( `Container exited after start (exit=${state.ExitCode ?? '?'}${ state.Error ? `: ${state.Error}` : '' })` ) } if (state.Running) { const health = state.Health?.Status if (!health || health === 'none' || health === 'healthy') { return { ok: true, health: health || null, status: state.Status } } if (health === 'unhealthy') { throw new Error('Container started but healthcheck is unhealthy') } // health === 'starting' — keep waiting } await new Promise((r) => setTimeout(r, 1000)) } // Soft accept: still running after timeout even if health is starting try { const info = await docker.getContainer(id).inspect() if (info?.State?.Running) { return { ok: true, health: info.State.Health?.Status || null, status: info.State.Status, timedOut: true, } } } catch { // fall through } throw new Error( `Container did not become ready in time (last status: ${lastStatus})` ) } /** * Remove a parked source after successful verify. Never throws to caller path * after logging — leftover park is recoverable by name. * @param {{ id: string, parkName: string }} parked */ async function removeParkedSource(parked) { try { await docker.getContainer(parked.id).remove({ force: true }) logger.info('Removed parked source after verified deploy', { id: String(parked.id).slice(0, 12), parkName: parked.parkName, }) } catch (err) { logger.warn('Failed to remove parked source (left in place)', { id: String(parked.id).slice(0, 12), parkName: parked.parkName, error: err?.message || String(err), }) } } /** * Undo a park: rename origin back and start it if it was running before the swap. * Best-effort; never throws. * * @param {{ id: string, originalName: string, parkName: string, wasRunning: boolean }} parked * @returns {Promise<{ restored: boolean, renamed: boolean, started: boolean, error: string|null }>} */ async function restoreParkedSource(parked) { /** @type {{ restored: boolean, renamed: boolean, started: boolean, error: string|null }} */ const result = { restored: false, renamed: false, started: false, error: null } if (!parked?.id) { result.error = 'missing parked source id' return result } const container = docker.getContainer(parked.id) const targetName = String(parked.originalName || '').replace(/^\//, '') || null // Rename parkName → originalName (skip if already correct) try { const info = await container.inspect() const current = String(info?.Name || '').replace(/^\//, '') if (targetName && current !== targetName) { await container.rename({ name: targetName }) } result.renamed = true } catch (renameErr) { // Name may still be held by a failed new container — report clearly result.error = `could not rename "${parked.parkName}" back to "${parked.originalName}": ${ renameErr?.message || renameErr }` logger.warn('Failed to restore parked source name', { id: String(parked.id).slice(0, 12), parkName: parked.parkName, originalName: parked.originalName, error: result.error, }) return result } if (parked.wasRunning) { try { await startContainerNoBody(parked.id) result.started = true } catch (startErr) { try { const info = await container.inspect() if (info?.State?.Running) { result.started = true } else { result.error = `renamed to "${parked.originalName}" but failed to start: ${ startErr?.message || startErr }` logger.warn('Failed to start restored origin after swap rollback', { id: String(parked.id).slice(0, 12), originalName: parked.originalName, error: result.error, }) return result } } catch (inspectErr) { result.error = `renamed but could not confirm start: ${ inspectErr?.message || startErr?.message || startErr }` return result } } } result.restored = true logger.info('Restored parked source after failed swap deploy', { id: String(parked.id).slice(0, 12), originalName: parked.originalName, parkName: parked.parkName, wasRunning: parked.wasRunning, started: result.started, }) return result } /** * Human-readable suffix describing origin restore outcome. * @param {{ restored: boolean, renamed?: boolean, started?: boolean, error: string|null }} restoreResult * @param {{ originalName: string, parkName: string, wasRunning: boolean }} parked */ function originRestoreMessage(restoreResult, parked) { if (restoreResult?.restored) { if (parked.wasRunning && restoreResult.started) { return ` Origin "${parked.originalName}" was restored and started.` } if (parked.wasRunning && !restoreResult.started) { return ` Origin "${parked.originalName}" was renamed back but may not be running.` } return ` Origin "${parked.originalName}" was restored (it was stopped before the swap).` } const detail = restoreResult?.error || 'unknown error' return ( ` Could not automatically restore origin (${detail}). ` + `It may still be parked as "${parked.parkName}" — rename it to "${parked.originalName}"` + (parked.wasRunning ? ' and start it' : '') + '.' ) } /** * Restore parked origin then throw a single error including restore outcome. * @param {{ id: string, originalName: string, parkName: string, wasRunning: boolean }} parked * @param {string} message * @returns {Promise} */ async function abortSwapAndThrow(parked, message) { const restoreResult = await restoreParkedSource(parked) throw new Error(String(message || 'Deploy failed') + originRestoreMessage(restoreResult, parked)) } export function registerDeployHandlers(session) { session.respond('deployContainer', async (args) => { const containerName = validation.sanitizeString(args.containerName, 63) if (!containerName || !validation.isValidContainerName(containerName)) { throw new Error( 'Invalid or missing container name. Must be alphanumeric with dashes/underscores, 1-63 characters. How to fix: enter a valid name like my-app or web_1.' ) } args.containerName = containerName const image = validation.sanitizeString(args.image, 255) if (!image || !validation.isValidImageName(image)) { throw new Error( 'Invalid or missing Docker image name. How to fix: use a valid image reference such as nginx:alpine or ghcr.io/org/app:1.0.' ) } args.image = image let existingContainers = await docker.listContainers({ all: true }) const findByName = (list, name) => list.find((c) => (c.Names || []).some((n) => { const bare = String(n || '').replace(/^\//, '') return bare === name || n === `/${name}` }) ) /** @type {{ id: string, originalName: string, parkName: string, wasRunning: boolean }|null} */ let parked = null const sourceId = args.sourceContainerId ? String(args.sourceContainerId) : '' // Duplicate / swap: stop+rename origin when it holds the target name or host ports. // New container is verified before the parked origin is removed. if (sourceId) { const source = existingContainers.find((c) => containerIdsMatch(c.Id, sourceId)) if (source) { const existingForName = findByName(existingContainers, args.containerName) const nameIsSource = existingForName && containerIdsMatch(existingForName.Id, source.Id) const wantPorts = requestedHostPortsFromArgs(args) const sourcePorts = hostPortsFromListEntry(source) let portConflictWithSource = false for (const p of wantPorts) { if (sourcePorts.has(p)) { portConflictWithSource = true break } } const forceSwap = args.swapSource === true if (nameIsSource || portConflictWithSource || forceSwap) { parked = await parkSourceContainer(source, { ...args, sourceContainerName: args.sourceContainerName || String(source.Names?.[0] || '').replace(/^\//, ''), }) existingContainers = await docker.listContainers({ all: true }) } } } let existing = findByName(existingContainers, args.containerName) // Optional replace: stop+remove the name holder, then create new // (skip hard-delete when we already parked that container for verified swap) if (existing) { if (parked && containerIdsMatch(existing.Id, parked.id)) { // Name was not freed somehow — should not happen after rename existing = null } else if (args.replace === true) { logger.info('Replacing existing container', { name: args.containerName, id: String(existing.Id || '').slice(0, 12), state: existing.State, }) const old = docker.getContainer(existing.Id) try { await old.stop({ t: 10 }) } catch { // already stopped / gone } try { await old.remove({ force: true }) } catch (rmErr) { const formatted = formatDeployError(rmErr, { stage: 'replace', containerName: args.containerName, image: args.image, }) if (parked) { await abortSwapAndThrow(parked, formatted.message) } throw formatted } } else { const err = new Error( `A container named "${args.containerName}" already exists` + (existing.State ? ` (${existing.State})` : '') + (existing.Image ? ` · image ${existing.Image}` : '') + '. Deploying will stop and remove it, then create a new container with your settings.' ) err.code = 'CONTAINER_NAME_CONFLICT' err.existing = { id: existing.Id, name: args.containerName, state: existing.State || 'unknown', image: existing.Image || '', } // We may have parked for port reuse before discovering a different name holder if (parked) { const restoreResult = await restoreParkedSource(parked) err.message += originRestoreMessage(restoreResult, parked) } throw err } } // Image pull policy: // - skipPull: client already pulled successfully — do nothing // - alwaysPull === false: use local image if present; pull only when missing // - alwaysPull true/undefined: force pull (legacy deploy / template behavior) if (!args.skipPull) { const forcePull = args.alwaysPull !== false let needPull = forcePull if (!forcePull) { needPull = !(await imageExistsLocally(args.image)) if (!needPull) { logger.info('Using local image (alwaysPull=false)', { image: args.image }) } } if (needPull) { logger.info(`Pulling Docker image: ${args.image}`, { force: forcePull }) try { const pullStream = await docker.pull(args.image) await new Promise((resolve, reject) => { docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve())) }) } catch (pullErr) { const formatted = formatDeployError(pullErr, { stage: 'pull', containerName: args.containerName, image: args.image, }) if (parked) { await abortSwapAndThrow(parked, formatted.message) } throw formatted } } } const containerConfig = { name: args.containerName, Image: args.image, } if (args.command) containerConfig.Cmd = args.command.split(' ') if (args.entrypoint) containerConfig.Entrypoint = args.entrypoint.split(' ') if (args.workingDir) containerConfig.WorkingDir = args.workingDir if (args.env && Array.isArray(args.env)) { containerConfig.Env = args.env .filter((e) => e.name && e.value !== undefined) .map((e) => { const name = validation.sanitizeEnvVarName(e.name) const value = validation.sanitizeEnvVarValue(e.value) return name && value !== null ? `${name}=${value}` : null }) .filter(Boolean) } if (args.labels && typeof args.labels === 'object') { containerConfig.Labels = {} for (const [key, value] of Object.entries(args.labels)) { const sanitizedKey = validation.sanitizeLabelKey(key) const sanitizedValue = validation.sanitizeLabelValue(value) if (sanitizedKey && sanitizedValue !== null) { containerConfig.Labels[sanitizedKey] = sanitizedValue } } } if (args.hostname) { const hostname = validation.sanitizeString(args.hostname, 253) if (validation.isValidHostname(hostname)) containerConfig.Hostname = hostname } if (args.domainname) { const domainname = validation.sanitizeString(args.domainname, 253) if (validation.isValidHostname(domainname)) containerConfig.Domainname = domainname } if (args.user) containerConfig.User = args.user // Extra groups (comma/space separated from Duplicate form, or array) let groupAdd = null if (args.group || args.groupAdd) { const raw = args.groupAdd || args.group const groups = Array.isArray(raw) ? raw.map((g) => String(g).trim()).filter(Boolean) : String(raw) .split(/[,\s]+/) .map((g) => g.trim()) .filter(Boolean) if (groups.length) groupAdd = groups } if (args.healthCmd) { containerConfig.Healthcheck = { Test: args.healthCmd.startsWith('CMD-SHELL') ? args.healthCmd.split(' ').slice(1) : ['CMD-SHELL', args.healthCmd], Interval: args.healthInterval ? args.healthInterval * 1e9 : 30e9, Timeout: args.healthTimeout ? args.healthTimeout * 1e9 : 10e9, Retries: args.healthRetries || 3, StartPeriod: args.healthStartPeriod ? args.healthStartPeriod * 1e9 : 0, } } containerConfig.Tty = args.tty === true containerConfig.OpenStdin = args.stdinOpen === true containerConfig.AttachStdin = args.stdinOpen === true containerConfig.AttachStdout = true containerConfig.AttachStderr = true if (args.readonlyRootfs === true) containerConfig.ReadonlyRootfs = true const hostConfig = { NetworkMode: args.networkMode || 'bridge', } if (args.publishAllPorts === true) { hostConfig.PublishAllPorts = true } if (args.ports && Array.isArray(args.ports)) { hostConfig.PortBindings = {} for (const portStr of args.ports) { const sanitizedPort = validation.sanitizeString(portStr, 50) if (!validation.isValidPortMapping(sanitizedPort)) continue if (sanitizedPort.includes(':')) { const [hostPort, rest] = sanitizedPort.split(':') const [containerPort, protocol] = rest.split('/') hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [ { HostPort: hostPort }, ] } else { const [containerPort, protocol] = sanitizedPort.split('/') hostConfig.PortBindings[`${containerPort}/${protocol || 'tcp'}`] = [ { HostPort: containerPort }, ] } } } if (args.volumes && Array.isArray(args.volumes)) { hostConfig.Binds = args.volumes .map((v) => validation.sanitizeString(v, 500)) .filter((v) => v && validation.isValidVolumeMount(v)) } if (args.tmpfs && Array.isArray(args.tmpfs)) { hostConfig.Tmpfs = {} for (const tmpfsStr of args.tmpfs) { const [p, ...opts] = tmpfsStr.split(':') if (p) hostConfig.Tmpfs[p] = opts.join(':') || '' } } if (args.cpuLimit) hostConfig.NanoCpus = args.cpuLimit * 1e9 if (args.cpuReservation) hostConfig.CpuQuota = args.cpuReservation * 1e9 if (args.cpuShares) hostConfig.CpuShares = args.cpuShares if (args.memoryLimit) hostConfig.Memory = args.memoryLimit * 1024 * 1024 if (args.memoryReservation) { hostConfig.MemoryReservation = args.memoryReservation * 1024 * 1024 } if (args.memorySwap !== undefined && args.memorySwap !== null) { hostConfig.MemorySwap = args.memorySwap === -1 ? -1 : args.memorySwap * 1024 * 1024 } if (args.devices && Array.isArray(args.devices)) { hostConfig.Devices = args.devices.map((deviceStr) => { const parts = deviceStr.split(':') return { PathOnHost: parts[0], PathInContainer: parts[1] || parts[0], CgroupPermissions: parts[2] || 'rwm', } }) } if (args.dns && Array.isArray(args.dns)) { hostConfig.Dns = args.dns .map((d) => validation.sanitizeString(d, 50)) .filter((d) => validation.isValidDnsServer(d)) } if (args.extraHosts && Array.isArray(args.extraHosts)) { hostConfig.ExtraHosts = args.extraHosts } if (args.restartPolicy) { hostConfig.RestartPolicy = { Name: args.restartPolicy, MaximumRetryCount: args.restartMaxRetries || 0, } } if (args.autoRemove === true) hostConfig.AutoRemove = true if (args.privileged === true) hostConfig.Privileged = true if (groupAdd?.length) hostConfig.GroupAdd = groupAdd if (args.capabilities && Array.isArray(args.capabilities)) { hostConfig.CapAdd = args.capabilities } if (args.securityOpts && Array.isArray(args.securityOpts)) { hostConfig.SecurityOpt = args.securityOpts } if (args.sysctls && typeof args.sysctls === 'object') hostConfig.Sysctls = args.sysctls if (args.ulimits && Array.isArray(args.ulimits)) hostConfig.Ulimits = args.ulimits if (args.oomKillDisable === true) hostConfig.OomKillDisable = true if (args.pidsLimit !== undefined && args.pidsLimit !== null) { hostConfig.PidsLimit = args.pidsLimit === -1 ? 0 : args.pidsLimit } if (args.shmSize) hostConfig.ShmSize = args.shmSize * 1024 * 1024 if (args.init === true) hostConfig.Init = true if (args.logDriver) { hostConfig.LogConfig = { Type: args.logDriver, Config: args.logOpts || {} } } if (args.networkMode === 'container' && args.customNetwork) { hostConfig.NetworkMode = `container:${args.customNetwork}` } containerConfig.HostConfig = hostConfig logger.info('Creating container', { name: args.containerName }) let container try { container = await docker.createContainer(containerConfig) } catch (createErr) { const formatted = formatDeployError(createErr, { stage: 'create', containerName: args.containerName, image: args.image, }) if (parked) { await abortSwapAndThrow(parked, formatted.message) } throw formatted } const rollback = args.rollback !== false if ( args.customNetwork && args.networkMode !== 'container' && args.networkMode !== 'host' && args.networkMode !== 'none' ) { try { await docker.getNetwork(args.customNetwork).connect({ Container: container.id }) } catch (netErr) { const formatted = formatDeployError(netErr, { stage: 'network', containerName: args.containerName, image: args.image, }) if (rollback) { try { await container.remove({ force: true }) } catch { // ignore } if (parked) { await abortSwapAndThrow( parked, `Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message}` ) } throw new Error( `Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message}` ) } logger.warn('Failed to connect container to network', { network: args.customNetwork, error: netErr.message, }) } } try { await startContainerNoBody(container.id) } catch (startErr) { const formatted = formatDeployError(startErr, { stage: 'start', containerName: args.containerName, image: args.image, }) if (rollback) { try { await container.remove({ force: true }) } catch { // ignore } if (parked) { await abortSwapAndThrow( parked, `Deploy rolled back after start failed for ${args.containerName}. ${formatted.message}` ) } throw new Error( `Deploy rolled back after start failed for ${args.containerName}. ${formatted.message}` ) } throw formatted } // Verified swap: only remove parked origin after the new container is ready if (parked) { try { await verifyContainerReady(container.id) } catch (verifyErr) { if (rollback) { try { await container.remove({ force: true }) } catch { // ignore } await abortSwapAndThrow( parked, `New container failed verification: ${verifyErr?.message || verifyErr}` ) } // rollback disabled: leave new container + parked origin; still try restore is unsafe // if new still holds name/ports — only report parked state throw new Error( `New container failed verification: ${verifyErr?.message || verifyErr}. ` + `Origin was parked as "${parked.parkName}" (not removed; rollback disabled). ` + `How to fix: remove the new container, rename origin to "${parked.originalName}"` + (parked.wasRunning ? ' and start it' : '') + '.' ) } await removeParkedSource(parked) } const swapped = Boolean(parked) logger.info('Container deployed successfully', { name: args.containerName, image: args.image, replaced: args.replace === true || swapped, swapped, parkedRemoved: swapped ? parked.parkName : null, }) await broadcastContainers() return { success: true, message: swapped ? `Container "${args.containerName}" deployed and verified; previous instance removed` : args.replace === true ? `Container "${args.containerName}" replaced successfully from image "${args.image}"` : `Container "${args.containerName}" deployed successfully from image "${args.image}"`, id: container.id, replaced: args.replace === true || swapped, swapped, parkedName: swapped ? parked.parkName : undefined, } }) }