/** * Template / container deploy RPC handler. */ import { docker } 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' 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 const existingContainers = await docker.listContainers({ all: true }) if (existingContainers.some((c) => c.Names.includes(`/${args.containerName}`))) { throw formatDeployError( new Error( `Conflict. The container name "/${args.containerName}" is already in use by container. You have to remove (or rename) that container to be able to reuse that name.` ), { stage: 'create', containerName: args.containerName, image: args.image } ) } // Skip pull when client already pulled (job stepper) unless force if (!args.skipPull) { logger.info(`Pulling Docker image: ${args.image}`) try { const pullStream = await docker.pull(args.image) await new Promise((resolve, reject) => { docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve())) }) } catch (pullErr) { throw formatDeployError(pullErr, { stage: 'pull', containerName: args.containerName, image: args.image, }) } } 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 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.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 (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) { throw formatDeployError(createErr, { stage: 'create', containerName: args.containerName, image: args.image, }) } 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 } throw new Error( `Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message}` ) } console.warn(`[WARN] Failed to connect to network ${args.customNetwork}: ${netErr.message}`) } } try { await container.start() } catch (startErr) { const formatted = formatDeployError(startErr, { stage: 'start', containerName: args.containerName, image: args.image, }) if (rollback) { try { await container.remove({ force: true }) } catch { // ignore } throw new Error( `Deploy rolled back after start failed for ${args.containerName}. ${formatted.message}` ) } throw formatted } logger.info('Container deployed successfully', { name: args.containerName, image: args.image, }) await broadcastContainers() return { success: true, message: `Container "${args.containerName}" deployed successfully from image "${args.image}"`, id: container.id, } }) }