forked from snxraven/peardock
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.
366 lines
13 KiB
JavaScript
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,
|
|
}
|
|
})
|
|
}
|