CI / test (push) Successful in 9m56s
Map Docker deploy failures to actionable how-to-fix messages, keep full detail through RPC sanitization, and stop top-center toasts from doubling the live job tray so operators only see one notification per action.
272 lines
9.6 KiB
JavaScript
272 lines
9.6 KiB
JavaScript
/**
|
||
* Turn raw Docker / dockerode errors into actionable user-facing messages.
|
||
* Used by deploy and other container operations so operators can fix issues
|
||
* without reading server logs.
|
||
*/
|
||
|
||
const MAX_MSG = 900
|
||
|
||
/**
|
||
* Pull the most useful string out of a dockerode / Docker Engine error.
|
||
* @param {unknown} err
|
||
* @returns {string}
|
||
*/
|
||
export function extractDockerMessage(err) {
|
||
if (!err) return 'Unknown error'
|
||
if (typeof err === 'string') return err
|
||
|
||
// dockerode often puts JSON body on err.json / err.reason
|
||
const fromJson =
|
||
err.json?.message ||
|
||
err.reason ||
|
||
(typeof err.json === 'string' ? err.json : null)
|
||
if (fromJson && String(fromJson).trim()) return String(fromJson).trim()
|
||
|
||
let msg = String(err.message || err || 'Unknown error')
|
||
|
||
// "(HTTP code 409) unexpected - Conflict. The container name …"
|
||
const httpMatch = msg.match(/\(HTTP code \d+\)[^\-]*-\s*(.+)$/is)
|
||
if (httpMatch) msg = httpMatch[1].trim()
|
||
|
||
// Sometimes nested: statusCode + message
|
||
if (err.statusCode && !/HTTP code/i.test(msg)) {
|
||
// keep message as-is; status used by classifiers
|
||
}
|
||
|
||
return msg.replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} err
|
||
* @returns {number|null}
|
||
*/
|
||
export function extractDockerStatus(err) {
|
||
if (!err || typeof err !== 'object') return null
|
||
if (typeof err.statusCode === 'number') return err.statusCode
|
||
const m = String(err.message || '').match(/HTTP code (\d+)/i)
|
||
return m ? Number(m[1]) : null
|
||
}
|
||
|
||
/**
|
||
* @typedef {{ stage?: string, containerName?: string, image?: string }} DeployContext
|
||
*/
|
||
|
||
/**
|
||
* Build a long, fix-oriented message for deploy failures.
|
||
* @param {unknown} err
|
||
* @param {DeployContext} [ctx]
|
||
* @returns {Error}
|
||
*/
|
||
export function formatDeployError(err, ctx = {}) {
|
||
const raw = extractDockerMessage(err)
|
||
const status = extractDockerStatus(err)
|
||
const lower = raw.toLowerCase()
|
||
const name = ctx.containerName ? `"${ctx.containerName}"` : 'the container'
|
||
const image = ctx.image ? `"${ctx.image}"` : 'the image'
|
||
const stage = ctx.stage || 'deploy'
|
||
|
||
let title = `Failed to ${stageLabel(stage)} ${name}`
|
||
let detail = raw
|
||
let fix = 'Check the container options and Docker engine logs, then retry.'
|
||
|
||
// —— Name / conflict ——
|
||
if (
|
||
status === 409 ||
|
||
/already in use|conflict|name.*is already allocated/i.test(raw)
|
||
) {
|
||
if (/port is already allocated|bind.*address already in use|address already in use/i.test(raw)) {
|
||
title = `Port conflict while starting ${name}`
|
||
const port = raw.match(/port[:\s]+(\d+)/i)?.[1] || raw.match(/:(\d+)\//)?.[1]
|
||
detail = port
|
||
? `Host port ${port} is already bound by another process or container.`
|
||
: 'A host port mapping is already in use on this machine.'
|
||
fix =
|
||
'Change the host port in Port mappings, stop the other container using that port, or remove the conflicting publish rule.'
|
||
} else if (/container name|already in use by container/i.test(raw) || /name.*already/i.test(raw)) {
|
||
title = `Container name ${name} is already taken`
|
||
detail = raw
|
||
fix =
|
||
'Pick a different container name, or remove/rename the existing container first (Containers → select → Remove).'
|
||
} else {
|
||
title = `Conflict while deploying ${name}`
|
||
detail = raw
|
||
fix = 'Resolve the conflicting resource (name, network, or volume) and try again.'
|
||
}
|
||
}
|
||
|
||
// —— Image pull / not found ——
|
||
else if (
|
||
stage === 'pull' ||
|
||
/manifest unknown|not found|pull access denied|repository does not exist|no such image|unauthorized|authentication required|toomanyrequests|rate limit/i.test(
|
||
lower
|
||
)
|
||
) {
|
||
if (/toomanyrequests|rate limit/i.test(lower)) {
|
||
title = `Image pull rate-limited for ${image}`
|
||
detail = raw
|
||
fix =
|
||
'Wait and retry, or authenticate to the registry (Docker Hub login / registry credentials in Vault) to raise pull limits.'
|
||
} else if (/unauthorized|authentication required|pull access denied/i.test(lower)) {
|
||
title = `Cannot pull ${image} — authentication required`
|
||
detail = raw
|
||
fix =
|
||
'Log in to the registry on the host, or store credentials in Vault and ensure the image name includes the correct registry path.'
|
||
} else if (/manifest unknown|not found|repository does not exist|no such image/i.test(lower)) {
|
||
title = `Image ${image} not found`
|
||
detail = raw
|
||
fix =
|
||
'Double-check the image name and tag (e.g. nginx:1.27). Private registries need a full path like registry.example.com/org/image:tag.'
|
||
} else if (stage === 'pull') {
|
||
title = `Failed to pull ${image}`
|
||
detail = raw
|
||
fix = 'Check network access to the registry, DNS, and that the image name is correct.'
|
||
}
|
||
}
|
||
|
||
// —— Bind mounts / volumes ——
|
||
else if (
|
||
/bind source path does not exist|no such file or directory.*mount|invalid mount config|mount.*not found|path does not exist/i.test(
|
||
lower
|
||
)
|
||
) {
|
||
title = `Volume/bind mount failed for ${name}`
|
||
detail = raw
|
||
fix =
|
||
'Create the host path first, fix typos in Host path, or switch to a named volume. Paths must exist on the Docker host (not your laptop if Docker is remote).'
|
||
} else if (/volume.*not found|no such volume/i.test(lower)) {
|
||
title = `Named volume missing for ${name}`
|
||
detail = raw
|
||
fix = 'Create the volume under Volumes first, or correct the volume name in the mount list.'
|
||
}
|
||
|
||
// —— Network ——
|
||
else if (/network.*not found|not found.*network|no such network/i.test(lower)) {
|
||
title = `Network not found for ${name}`
|
||
detail = raw
|
||
fix = 'Pick an existing network, create one under Networks, or use bridge/host/none.'
|
||
} else if (/endpoint with name.*already exists|already connected/i.test(lower)) {
|
||
title = `Network attach conflict for ${name}`
|
||
detail = raw
|
||
fix = 'Disconnect the existing endpoint or choose a different network/container name.'
|
||
}
|
||
|
||
// —— Resources / runtime ——
|
||
else if (/cannot set memory|memory limit|out of memory|oci runtime/i.test(lower)) {
|
||
title = `Runtime/resource error starting ${name}`
|
||
detail = raw
|
||
fix =
|
||
'Lower memory/CPU limits, free host resources, or fix invalid HostConfig options (cgroup, privileged, devices).'
|
||
} else if (/permission denied|operation not permitted|cap_|apparmor|seccomp/i.test(lower)) {
|
||
title = `Permission denied starting ${name}`
|
||
detail = raw
|
||
fix =
|
||
'Remove restricted capabilities/security options, or run with the needed CapAdd/privileged flag only if you trust the image.'
|
||
} else if (/driver failed programming external connectivity|iptables|firewall/i.test(lower)) {
|
||
title = `Host networking/firewall blocked publish for ${name}`
|
||
detail = raw
|
||
fix =
|
||
'Check host firewall/iptables rules and that Docker’s bridge networking is healthy; retry after freeing the port.'
|
||
}
|
||
|
||
// —— Engine down ——
|
||
else if (
|
||
/docker.*socket|connect econnrefused|enoent.*docker|is the docker daemon running|cannot connect to the docker/i.test(
|
||
lower
|
||
)
|
||
) {
|
||
title = 'Cannot reach Docker Engine'
|
||
detail = raw
|
||
fix =
|
||
'Start the Docker daemon on the peardock host and ensure the process can access the Docker socket.'
|
||
}
|
||
|
||
// —— Validation already descriptive ——
|
||
else if (/invalid or missing|must be alphanumeric/i.test(lower)) {
|
||
title = `Invalid deploy options for ${name}`
|
||
detail = raw
|
||
fix = 'Fix the highlighted fields (name, image, ports, volumes) and submit again.'
|
||
}
|
||
|
||
const parts = [title]
|
||
if (detail && detail !== title) parts.push(detail)
|
||
if (fix) parts.push(`How to fix: ${fix}`)
|
||
if (status) parts.push(`(Docker HTTP ${status})`)
|
||
|
||
let message = parts.join(' — ').replace(/\s+/g, ' ').trim()
|
||
if (message.length > MAX_MSG) {
|
||
message = message.slice(0, MAX_MSG - 1) + '…'
|
||
}
|
||
|
||
const out = new Error(message)
|
||
out.code = err?.code || (status === 409 ? 'DOCKER_CONFLICT' : 'DOCKER_ERROR')
|
||
out.statusCode = status
|
||
out.cause = err instanceof Error ? err : undefined
|
||
out.stage = stage
|
||
return out
|
||
}
|
||
|
||
function stageLabel(stage) {
|
||
switch (stage) {
|
||
case 'pull':
|
||
return 'pull image for'
|
||
case 'create':
|
||
return 'create'
|
||
case 'start':
|
||
return 'start'
|
||
case 'network':
|
||
return 'attach network for'
|
||
default:
|
||
return 'deploy'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Client-safe sanitization that keeps operational detail (ports, names, image tags)
|
||
* while redacting secrets and absolute home paths.
|
||
* @param {unknown} err
|
||
* @returns {string}
|
||
*/
|
||
export function sanitizeClientError(err) {
|
||
if (err?.code === 'PERMISSION_DENIED' || err?.code === 'RATE_LIMIT_EXCEEDED') {
|
||
return err.message
|
||
}
|
||
if (err?.code === 'INVALID_ARGS' || err?.code === 'FEATURE_DISABLED') {
|
||
return err.message || 'Invalid request'
|
||
}
|
||
|
||
let msg = extractDockerMessage(err)
|
||
if (!msg) return 'An error occurred. Please try again.'
|
||
|
||
// Prefer already-formatted deploy messages as-is (they include How to fix)
|
||
if (/how to fix:/i.test(msg)) {
|
||
return msg.length > MAX_MSG ? msg.slice(0, MAX_MSG - 1) + '…' : msg
|
||
}
|
||
|
||
const sensitive = [
|
||
[/password[=:]\s*\S+/gi, 'password=[REDACTED]'],
|
||
[/token[=:]\s*\S+/gi, 'token=[REDACTED]'],
|
||
[/authorization[=:]\s*\S+/gi, 'authorization=[REDACTED]'],
|
||
[/\bBearer\s+\S+/gi, 'Bearer [REDACTED]'],
|
||
[/\/home\/[^/\s]+/g, '/home/[REDACTED]'],
|
||
[/\/Users\/[^/\s]+/g, '/Users/[REDACTED]'],
|
||
]
|
||
for (const [re, rep] of sensitive) {
|
||
msg = msg.replace(re, rep)
|
||
}
|
||
|
||
// Soften raw socket paths but keep meaning
|
||
msg = msg.replace(/\/var\/run\/docker\.sock/g, 'Docker socket')
|
||
|
||
if (msg.length > MAX_MSG) {
|
||
msg = msg.slice(0, MAX_MSG - 1) + '…'
|
||
}
|
||
return msg
|
||
}
|
||
|
||
export default {
|
||
extractDockerMessage,
|
||
extractDockerStatus,
|
||
formatDeployError,
|
||
sanitizeClientError,
|
||
}
|