Files
peardock/server/utils/dockerErrors.js
T
snxraven 1b99224f16
CI / test (push) Successful in 9m56s
Improve deploy errors and use bottom job tray as sole feedback.
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.
2026-07-10 22:40:13 -04:00

272 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 Dockers 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,
}