Files
peardock/server/handlers/deploy.js
T
Raven Scott b185ea7a55 Add container create page from the containers list with always-pull.
Adds a dedicated blank create form (name, image, always pull, ports,
auto-remove, advanced settings) separate from template Deploy, reuses
deployContainer with pull-if-missing and publish-all-ports support.
2026-07-15 14:27:05 -04:00

366 lines
13 KiB
JavaScript

/**
* 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<boolean>}
*/
async function imageExistsLocally(imageRef) {
if (!imageRef) return false
try {
await docker.getImage(imageRef).inspect()
return true
} catch {
return false
}
}
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 })
const existing = existingContainers.find((c) =>
(c.Names || []).some((n) => {
const bare = String(n || '').replace(/^\//, '')
return bare === args.containerName || n === `/${args.containerName}`
})
)
// Optional replace: stop+remove the name holder, then create new
if (existing) {
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) {
throw formatDeployError(rmErr, {
stage: 'replace',
containerName: args.containerName,
image: args.image,
})
}
} 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 || '',
}
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) {
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.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 (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}`
)
}
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
}
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,
replaced: args.replace === true,
})
await broadcastContainers()
return {
success: true,
message:
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,
}
})
}