Park source container on duplicate so host ports can be reused
Release rolling / release (push) Has been cancelled

When duplicating with the same host ports, stop+rename the origin, deploy
and verify the new container, then remove the parked source. Client
precheck ignores ports owned only by the origin to avoid false blocks.
This commit is contained in:
Raven Scott
2026-07-16 22:24:49 -04:00
parent ca3770da59
commit bd512df950
7 changed files with 520 additions and 28 deletions
+321 -13
View File
@@ -21,6 +21,226 @@ async function imageExistsLocally(imageRef) {
}
}
/** @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),
})
}
}
export function registerDeployHandlers(session) {
session.respond('deployContainer', async (args) => {
const containerName = validation.sanitizeString(args.containerName, 63)
@@ -39,17 +259,57 @@ export function registerDeployHandlers(session) {
}
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}`
})
)
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 (args.replace === true) {
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),
@@ -300,11 +560,18 @@ export function registerDeployHandlers(session) {
try {
container = await docker.createContainer(containerConfig)
} catch (createErr) {
throw formatDeployError(createErr, {
const formatted = formatDeployError(createErr, {
stage: 'create',
containerName: args.containerName,
image: args.image,
})
if (parked) {
throw new Error(
`${formatted.message} Origin was parked as "${parked.parkName}" (not removed). ` +
`How to fix: rename it back to "${parked.originalName}" and start it, or remove it after fixing the deploy error.`
)
}
throw formatted
}
const rollback = args.rollback !== false
@@ -328,6 +595,12 @@ export function registerDeployHandlers(session) {
} catch {
// ignore
}
if (parked) {
throw new Error(
`Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message} ` +
`Origin was parked as "${parked.parkName}" (not removed).`
)
}
throw new Error(
`Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message}`
)
@@ -353,27 +626,62 @@ export function registerDeployHandlers(session) {
} catch {
// ignore
}
if (parked) {
throw new Error(
`Deploy rolled back after start failed for ${args.containerName}. ${formatted.message} ` +
`Origin was parked as "${parked.parkName}" (not removed). ` +
`How to fix: rename it back to "${parked.originalName}" and start it, or remove it after fixing the port/config issue.`
)
}
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
}
}
throw new Error(
`New container failed verification: ${verifyErr?.message || verifyErr}. ` +
`Origin was parked as "${parked.parkName}" (not removed). ` +
`How to fix: rename it back to "${parked.originalName}" and start it, or inspect logs for "${args.containerName}".`
)
}
await removeParkedSource(parked)
}
const swapped = Boolean(parked)
logger.info('Container deployed successfully', {
name: args.containerName,
image: args.image,
replaced: args.replace === true,
replaced: args.replace === true || swapped,
swapped,
parkedRemoved: swapped ? parked.parkName : null,
})
await broadcastContainers()
return {
success: true,
message:
args.replace === 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,
replaced: args.replace === true || swapped,
swapped,
parkedName: swapped ? parked.parkName : undefined,
}
})
}
+18 -1
View File
@@ -108,19 +108,36 @@ export async function suggestNetworkIPAM(opts = {}) {
/**
* Host ports published by containers.
* Includes per-port owners so clients can ignore the source container when
* duplicating (swap) without false host-port conflicts.
*/
export async function listUsedHostPorts() {
const containers = await docker.listContainers({ all: true })
/** @type {Set<number>} */
const ports = new Set()
/** @type {{ port: number, protocol: string, id: string, name: string }[]} */
const owners = []
for (const c of containers) {
const id = String(c.Id || '')
const name = String(c.Names?.[0] || '')
.replace(/^\//, '') || id.slice(0, 12)
for (const p of c.Ports || []) {
if (p.PublicPort) ports.add(Number(p.PublicPort))
if (!p?.PublicPort) continue
const port = Number(p.PublicPort)
if (!Number.isFinite(port)) continue
ports.add(port)
owners.push({
port,
protocol: String(p.Type || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp',
id,
name,
})
}
}
return {
success: true,
ports: [...ports].sort((a, b) => a - b),
owners,
}
}