/** * 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, }