Improve deploy errors and use bottom job tray as sole feedback.
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.
This commit is contained in:
2026-07-10 22:40:13 -04:00
parent a42f5e4dcf
commit 1b99224f16
14 changed files with 1089 additions and 133 deletions
+59 -11
View File
@@ -5,33 +5,52 @@ import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { broadcastContainers } from './containers.js'
import logger from '../utils/logger.js'
import { formatDeployError } from '../utils/dockerErrors.js'
export function registerDeployHandlers(session) {
session.respond('deployContainer', async (args) => {
const containerName = validation.sanitizeString(args.containerName, 63)
if (!containerName || !validation.isValidContainerName(containerName)) {
throw new Error(
'Invalid or missing container name. Must be alphanumeric with dashes/underscores, 1-63 characters.'
'Invalid or missing container name. Must be alphanumeric with dashes/underscores, 1-63 characters. How to fix: enter a valid name like my-app or web_1.'
)
}
args.containerName = containerName
const image = validation.sanitizeString(args.image, 255)
if (!image || !validation.isValidImageName(image)) {
throw new Error('Invalid or missing Docker image name.')
throw new Error(
'Invalid or missing Docker image name. How to fix: use a valid image reference such as nginx:alpine or ghcr.io/org/app:1.0.'
)
}
args.image = image
const existingContainers = await docker.listContainers({ all: true })
if (existingContainers.some((c) => c.Names.includes(`/${args.containerName}`))) {
throw new Error(`Container name '${args.containerName}' already exists.`)
throw formatDeployError(
new Error(
`Conflict. The container name "/${args.containerName}" is already in use by container. You have to remove (or rename) that container to be able to reuse that name.`
),
{ stage: 'create', containerName: args.containerName, image: args.image }
)
}
logger.info(`Pulling Docker image: ${args.image}`)
const pullStream = await docker.pull(args.image)
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()))
})
// Skip pull when client already pulled (job stepper) unless force
if (!args.skipPull) {
logger.info(`Pulling Docker image: ${args.image}`)
try {
const pullStream = await docker.pull(args.image)
await new Promise((resolve, reject) => {
docker.modem.followProgress(pullStream, (err) => (err ? reject(err) : resolve()))
})
} catch (pullErr) {
throw formatDeployError(pullErr, {
stage: 'pull',
containerName: args.containerName,
image: args.image,
})
}
}
const containerConfig = {
name: args.containerName,
@@ -193,7 +212,16 @@ export function registerDeployHandlers(session) {
containerConfig.HostConfig = hostConfig
logger.info('Creating container', { name: args.containerName })
const container = await docker.createContainer(containerConfig)
let container
try {
container = await docker.createContainer(containerConfig)
} catch (createErr) {
throw formatDeployError(createErr, {
stage: 'create',
containerName: args.containerName,
image: args.image,
})
}
const rollback = args.rollback !== false
if (
@@ -205,6 +233,21 @@ export function registerDeployHandlers(session) {
try {
await docker.getNetwork(args.customNetwork).connect({ Container: container.id })
} catch (netErr) {
const formatted = formatDeployError(netErr, {
stage: 'network',
containerName: args.containerName,
image: args.image,
})
if (rollback) {
try {
await container.remove({ force: true })
} catch {
// ignore
}
throw new Error(
`Deploy rolled back: could not attach network "${args.customNetwork}". ${formatted.message}`
)
}
console.warn(`[WARN] Failed to connect to network ${args.customNetwork}: ${netErr.message}`)
}
}
@@ -212,6 +255,11 @@ export function registerDeployHandlers(session) {
try {
await container.start()
} catch (startErr) {
const formatted = formatDeployError(startErr, {
stage: 'start',
containerName: args.containerName,
image: args.image,
})
if (rollback) {
try {
await container.remove({ force: true })
@@ -219,10 +267,10 @@ export function registerDeployHandlers(session) {
// ignore
}
throw new Error(
`Deploy rolled back after start failed for "${args.containerName}": ${startErr.message}`
`Deploy rolled back after start failed for ${args.containerName}. ${formatted.message}`
)
}
throw startErr
throw formatted
}
logger.info('Container deployed successfully', {
name: args.containerName,
+6 -9
View File
@@ -12,6 +12,7 @@ import { audit, shouldAudit } from '../core/audit.js'
import { recordRpc } from '../services/metrics.js'
import { redeemInvite, isPeerAllowed } from '../core/peer-policy.js'
import { validateMethodArgs, SCHEMA_VERSION } from '../../shared/schema.js'
import { sanitizeClientError } from '../utils/dockerErrors.js'
export class PeerSession {
/**
@@ -221,14 +222,10 @@ export function registerHandshake(session) {
})
}
/**
* Keep operational detail so the UI/job log can tell the user how to fix issues.
* Long Docker messages used to be replaced with a useless generic string.
*/
function sanitizeError(err) {
if (err?.code === 'PERMISSION_DENIED' || err?.code === 'RATE_LIMIT_EXCEEDED') {
return err.message
}
const msg = err?.message || 'Unknown error'
if (msg.includes('ENOENT') || msg.includes('EACCES')) {
return 'Operation failed. Please check permissions and try again.'
}
if (msg.length > 200) return 'An error occurred. Please try again.'
return msg
return sanitizeClientError(err)
}
+271
View File
@@ -0,0 +1,271 @@
/**
* 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,
}