Files
peardock/server/handlers/deploy.js
T
Raven Scott abc34b39d8
Release rolling / release (push) Successful in 8m45s
Restore and restart origin container when swap deploy fails
On duplicate/swap failure after parking, rename the origin back and start
it if it was running so a bad deploy does not leave the source offline.
2026-07-16 22:26:22 -04:00

820 lines
28 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
}
}
/** @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<number>}
*/
function hostPortsFromListEntry(c) {
/** @type {Set<number>} */
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<number>}
*/
function requestedHostPortsFromArgs(args) {
/** @type {Set<number>} */
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<string>} 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<string>} */
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<never>}
*/
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,
}
})
}