Files
peardock/server/services/docker.js
T
2026-07-15 23:22:27 -04:00

313 lines
9.3 KiB
JavaScript

/**
* Shared Dockerode client.
*
* Prefer the unix socket. Explicit host: undefined so docker-modem does not
* fall back to TCP localhost (which bare-http would hit without socketPath).
*/
import Docker from 'dockerode'
import net from 'net'
import os from 'os'
import fs from 'fs'
import logger from '../utils/logger.js'
function resolveSocketPath() {
if (process.env.DOCKER_HOST?.startsWith('unix://')) {
return process.env.DOCKER_HOST.slice('unix://'.length) || '/var/run/docker.sock'
}
if (os.platform() === 'win32') {
return '//./pipe/dockerDesktopLinuxEngine'
}
const candidates = [
process.env.DOCKER_SOCK,
'/var/run/docker.sock',
`${os.homedir()}/.docker/run/docker.sock`,
].filter(Boolean)
for (const p of candidates) {
try {
fs.accessSync(p)
return p
} catch {
// try next
}
}
return '/var/run/docker.sock'
}
const socketPath = resolveSocketPath()
export const docker = new Docker({
socketPath,
// Force unix-socket mode in docker-modem (do not set host)
protocol: 'http',
})
export { socketPath as dockerSocketPath }
/**
* Normalize container id/name for Engine API path segment.
* @param {string} id
* @returns {string}
*/
function normalizeContainerRef(id) {
const cid = String(id || '').replace(/^\//, '').trim()
if (!cid) throw new Error('Container id required')
// Engine accepts id or name; encode only reserved path chars (keep hex ids plain)
return encodeURIComponent(cid).replace(/%3A/gi, ':') // allow sha256: prefix if ever passed
}
/**
* HTTP request to Docker Engine over the unix socket with a guaranteed-empty body.
*
* Why not dockerode `.start()` / modem.dial?
* - dockerode always passes `options: {}` into modem for start
* - bare-http / docker-modem can still emit a JSON body or Content-Type that
* modern Engine (API ≥1.24) rejects:
* "starting container with non-empty request body was deprecated..."
* - dockerode stop/remove can hang indefinitely under busy/hijacked streams;
* raw socket requests always have a hard deadline.
*
* We write the HTTP request by hand: Content-Length: 0, no payload bytes.
*
* @param {string} method HTTP method
* @param {string} apiPath e.g. /containers/abc/start (leading slash, no host)
* @param {{ ok?: number[], timeoutMs?: number }} [opts]
* @returns {Promise<{ statusCode: number, body: string }>}
*/
function dockerUnixEmpty(method, apiPath, opts = {}) {
const okCodes = opts.ok || [204, 304]
const timeoutMs =
Number(opts.timeoutMs) > 0 && Number.isFinite(Number(opts.timeoutMs))
? Number(opts.timeoutMs)
: 60_000
const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
const verb = String(method || 'POST').toUpperCase()
return new Promise((resolve, reject) => {
const socket = net.createConnection({ path: socketPath })
let buf = Buffer.alloc(0)
let settled = false
const finish = (err, result) => {
if (settled) return
settled = true
try {
socket.destroy()
} catch {
// ignore
}
if (err) reject(err)
else resolve(result)
}
const timer = setTimeout(() => {
finish(new Error(`Docker socket timeout (${verb} ${path})`))
}, timeoutMs)
socket.on('connect', () => {
// Minimal HTTP/1.1 — zero-length body, no Content-Type
const req =
`${verb} ${path} HTTP/1.1\r\n` +
`Host: localhost\r\n` +
`Content-Length: 0\r\n` +
`Connection: close\r\n` +
`\r\n`
socket.write(req)
})
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk])
})
socket.on('error', (err) => {
clearTimeout(timer)
finish(err)
})
socket.on('end', () => {
clearTimeout(timer)
const text = buf.toString('utf8')
const statusMatch = /^HTTP\/1\.[01] (\d{3})/.exec(text)
const statusCode = statusMatch ? Number(statusMatch[1]) : 0
const sep = text.indexOf('\r\n\r\n')
const body = sep >= 0 ? text.slice(sep + 4) : ''
if (okCodes.includes(statusCode)) {
finish(null, { statusCode, body })
return
}
let message = body.trim() || `Docker HTTP ${statusCode || 'error'} for ${path}`
try {
const j = JSON.parse(body)
if (j?.message) message = j.message
} catch {
// keep raw body
}
const err = new Error(message)
err.statusCode = statusCode
err.path = path
finish(err)
})
})
}
/**
* POST to Docker Engine over the unix socket with a guaranteed-empty body.
* @param {string} apiPath e.g. /containers/abc/start (leading slash, no host)
* @param {{ ok?: number[], timeoutMs?: number }} [opts]
* @returns {Promise<{ statusCode: number, body: string }>}
*/
function dockerUnixPostEmpty(apiPath, opts = {}) {
return dockerUnixEmpty('POST', apiPath, opts)
}
/**
* Start a container with an empty POST body (Engine API ≥1.24).
* @param {string} id container id or name
* @returns {Promise<void>}
*/
export async function startContainerNoBody(id) {
const cid = normalizeContainerRef(id)
try {
await dockerUnixPostEmpty(`/containers/${cid}/start`, { ok: [204, 304] })
} catch (err) {
logger.error('startContainerNoBody failed', {
id: cid,
error: err.message,
statusCode: err.statusCode,
})
throw err
}
}
/**
* Graceful stop via raw socket (query `t` = seconds before SIGKILL).
* Treats already-stopped as success.
* @param {string} id
* @param {{ t?: number, timeoutMs?: number }} [opts]
*/
export async function stopContainerNoBody(id, opts = {}) {
const cid = normalizeContainerRef(id)
const t = Number(opts.t)
const grace = Number.isFinite(t) && t >= 0 ? Math.min(Math.floor(t), 600) : 5
// Allow grace period + small buffer; never hang forever
const timeoutMs =
Number(opts.timeoutMs) > 0
? Number(opts.timeoutMs)
: Math.max(15_000, (grace + 8) * 1000)
try {
await dockerUnixPostEmpty(`/containers/${cid}/stop?t=${grace}`, {
ok: [204, 304],
timeoutMs,
})
} catch (err) {
if (err?.statusCode === 304 || /is not running|already stopped/i.test(String(err?.message || ''))) {
return
}
if (err?.statusCode === 404 || /no such container/i.test(String(err?.message || ''))) {
const e = new Error(err.message || 'no such container')
e.statusCode = 404
throw e
}
throw err
}
}
/**
* SIGKILL via raw socket. Treats not-running as success.
* @param {string} id
* @param {{ signal?: string, timeoutMs?: number }} [opts]
*/
export async function killContainerNoBody(id, opts = {}) {
const cid = normalizeContainerRef(id)
const signal = encodeURIComponent(String(opts.signal || 'SIGKILL'))
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 10_000
try {
await dockerUnixPostEmpty(`/containers/${cid}/kill?signal=${signal}`, {
// 409 = container is not running
ok: [204, 409],
timeoutMs,
})
} catch (err) {
if (
err?.statusCode === 409 ||
err?.statusCode === 404 ||
/is not running|already stopped|no such container/i.test(String(err?.message || ''))
) {
return
}
throw err
}
}
/**
* Force-remove container via raw DELETE (avoids dockerode hang on busy containers).
* @param {string} id
* @param {{ force?: boolean, v?: boolean, link?: boolean, timeoutMs?: number }} [opts]
*/
export async function removeContainerNoBody(id, opts = {}) {
const cid = normalizeContainerRef(id)
const force = opts.force !== false
const v = Boolean(opts.v)
const link = Boolean(opts.link)
const parts = []
if (force) parts.push('force=1')
if (v) parts.push('v=1')
if (link) parts.push('link=1')
const path = `/containers/${cid}${parts.length ? `?${parts.join('&')}` : ''}`
const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 30_000
try {
await dockerUnixEmpty('DELETE', path, {
ok: [204, 404],
timeoutMs,
})
} catch (err) {
if (err?.statusCode === 404 || /no such container/i.test(String(err?.message || ''))) {
return
}
throw err
}
}
/**
* Body-less container lifecycle POSTs (pause / unpause).
* @param {string} id
* @param {'pause'|'unpause'} action
*/
export async function containerLifecycleNoBody(id, action) {
const cid = normalizeContainerRef(id)
if (action !== 'pause' && action !== 'unpause') {
throw new Error(`Invalid lifecycle action: ${action}`)
}
await dockerUnixPostEmpty(`/containers/${cid}/${action}`, { ok: [204, 304] })
}
/**
* Normalize docker.listVolumes() response shapes across API versions.
* @param {object|Array} volumesResult
* @returns {Array}
*/
export function extractVolumesList(volumesResult) {
if (Array.isArray(volumesResult)) return volumesResult
if (volumesResult?.Volumes && Array.isArray(volumesResult.Volumes)) {
return volumesResult.Volumes
}
if (volumesResult?.volumes && Array.isArray(volumesResult.volumes)) {
return volumesResult.volumes
}
return []
}
/**
* First attached network IP for a container inspect result.
* @param {object} details
* @returns {string}
*/
export function extractIpAddress(details) {
const networks = details?.NetworkSettings?.Networks
if (!networks) return 'No IP Assigned'
const list = Object.values(networks)
if (list.length > 0 && list[0].IPAddress) return list[0].IPAddress
return 'No IP Assigned'
}