Park source container on duplicate so host ports can be reused
Release rolling / release (push) Has been cancelled
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:
+321
-13
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user