Files
peardock/server/handlers/deploy.js
T
Raven Scott c49d7301d0
Release rolling / release (push) Successful in 8m25s
Stabilize multi-peer UI and fix container start API body
Keep the containers table scoped to the active peer with a merge store,
restore the last active server on boot with a restoring screen, start
containers without a Docker API body (v1.24+), and stop ⋮ menus from
sticking to the viewport bottom after refreshes.
2026-07-11 16:52:57 -04:00

291 lines
10 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'
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}`
)
}
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,
})
await broadcastContainers()
return {
success: true,
message: `Container "${args.containerName}" deployed successfully from image "${args.image}"`,
id: container.id,
}
})
}