313 lines
9.3 KiB
JavaScript
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'
|
|
}
|