Add Podman fallback when Docker socket is missing.
Release rolling / release (push) Successful in 7m51s
Release rolling / release (push) Successful in 7m51s
Auto-detect rootless/rootful Podman sockets after Docker candidates, honor PEARDOCK_ENGINE and CONTAINER_HOST, and use podman compose for stacks when the engine is Podman.
This commit is contained in:
@@ -286,6 +286,8 @@ Full technical docs live under **[`docs/`](docs/README.md)** (architecture diagr
|
||||
| `ENABLE_SWARM` | On (`1`); set `0` to disable Swarm RPC |
|
||||
| `ENABLE_HOLESAIL` | On (`1`); set `0` to disable tunnels |
|
||||
| `ENABLE_PLUGINS` | Off unless `1` |
|
||||
| `PEARDOCK_ENGINE` | Prefer `docker` or `podman` (default: Docker socket first, then Podman) |
|
||||
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit engine socket (`unix://…`); wins over auto-detect |
|
||||
| `PEARDOCK_MAX_TUNNELS` | `20` |
|
||||
| `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald |
|
||||
|
||||
|
||||
@@ -68,6 +68,29 @@ sudo systemctl restart peardock
|
||||
|
||||
Symptoms of a missing group: empty container lists, start failures, “permission denied” on the socket.
|
||||
|
||||
### Podman (no Docker)
|
||||
|
||||
If no Docker socket is found, the server automatically falls back to a **Podman** socket (Docker-compatible API via dockerode):
|
||||
|
||||
| Preference | Behavior |
|
||||
|------------|----------|
|
||||
| Default | Try Docker sockets first, then Podman (rootless + rootful) |
|
||||
| `PEARDOCK_ENGINE=podman` | Prefer Podman sockets |
|
||||
| `PEARDOCK_ENGINE=docker` | Docker only (no Podman fallback) |
|
||||
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit `unix://…` path wins |
|
||||
| `PODMAN_SOCK` | Extra Podman path candidate |
|
||||
|
||||
Common Podman paths: `$XDG_RUNTIME_DIR/podman/podman.sock` (rootless), `/run/podman/podman.sock` (rootful).
|
||||
|
||||
```bash
|
||||
# Rootless example
|
||||
systemctl --user enable --now podman.socket
|
||||
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
|
||||
# or: PEARDOCK_ENGINE=podman
|
||||
```
|
||||
|
||||
Compose stacks use `podman compose` when the engine is Podman (falls back to `docker compose` if the binary is missing).
|
||||
|
||||
## Manual install from source
|
||||
|
||||
```bash
|
||||
|
||||
+11
-6
@@ -1,18 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local healthcheck for peardock server process supervision.
|
||||
* Checks Docker socket reachability (same as server dependency).
|
||||
* Checks container engine socket reachability (Docker or Podman).
|
||||
* Exit 0 = healthy, 1 = unhealthy.
|
||||
*
|
||||
* Usage: node scripts/healthcheck.js
|
||||
* Docker HEALTHCHECK / systemd ExecStartPost / k8s probe compatible.
|
||||
*/
|
||||
import Docker from 'dockerode'
|
||||
import {
|
||||
docker,
|
||||
containerEngineKind,
|
||||
dockerSocketPath,
|
||||
} from '../server/services/docker.js'
|
||||
|
||||
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS) || 5000
|
||||
|
||||
const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.error('healthcheck: timeout')
|
||||
process.exit(1)
|
||||
@@ -21,10 +23,13 @@ const timer = setTimeout(() => {
|
||||
try {
|
||||
await docker.ping()
|
||||
clearTimeout(timer)
|
||||
console.log('ok docker')
|
||||
console.log(`ok ${containerEngineKind} ${dockerSocketPath}`)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
console.error('healthcheck failed:', err.message)
|
||||
console.error(
|
||||
`healthcheck failed (${containerEngineKind} ${dockerSocketPath}):`,
|
||||
err.message
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
+10
-5
@@ -7,10 +7,14 @@
|
||||
* SOAK_DURATION_MS=86400000 node scripts/soak.js # 24h
|
||||
* node scripts/soak.js --duration 300 # 300 seconds
|
||||
*
|
||||
* Checks Docker every interval; fails process if consecutive failures exceed threshold.
|
||||
* Pair with: npm run server (separate process) + this soak against local Docker.
|
||||
* Checks container engine every interval; fails process if consecutive failures exceed threshold.
|
||||
* Pair with: npm run server (separate process) + this soak against local Docker/Podman.
|
||||
*/
|
||||
import Docker from 'dockerode'
|
||||
import {
|
||||
docker,
|
||||
containerEngineKind,
|
||||
dockerSocketPath,
|
||||
} from '../server/services/docker.js'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
let durationMs = Number(process.env.SOAK_DURATION_MS) || 60_000
|
||||
@@ -28,14 +32,15 @@ for (let i = 0; i < args.length; i++) {
|
||||
}
|
||||
}
|
||||
|
||||
const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
|
||||
const started = Date.now()
|
||||
let checks = 0
|
||||
let failures = 0
|
||||
let consecutive = 0
|
||||
const latencies = []
|
||||
|
||||
console.log(`[soak] duration=${durationMs}ms interval=${intervalMs}ms maxConsecutiveFails=${maxConsecutive}`)
|
||||
console.log(
|
||||
`[soak] engine=${containerEngineKind} socket=${dockerSocketPath} duration=${durationMs}ms interval=${intervalMs}ms maxConsecutiveFails=${maxConsecutive}`
|
||||
)
|
||||
|
||||
async function tick() {
|
||||
const t0 = Date.now()
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { docker } from '../services/docker.js'
|
||||
import {
|
||||
docker,
|
||||
containerEngineKind,
|
||||
dockerSocketPath,
|
||||
} from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
||||
import { getMetricsSnapshot } from '../services/metrics.js'
|
||||
@@ -45,6 +49,9 @@ export function registerSystemHandlers(session) {
|
||||
apiVersion,
|
||||
os: osType,
|
||||
error,
|
||||
/** docker | podman | unknown — socket used by this server process */
|
||||
engine: containerEngineKind,
|
||||
socketPath: dockerSocketPath,
|
||||
},
|
||||
features: {
|
||||
swarm: isSwarmEnabled(),
|
||||
|
||||
+30
-6
@@ -25,7 +25,12 @@ import {
|
||||
restoreTunnelsFromDisk,
|
||||
} from './services/holesail-tunnels.js'
|
||||
import { restoreSchedules } from './services/schedules.js'
|
||||
import { docker } from './services/docker.js'
|
||||
import {
|
||||
docker,
|
||||
containerEngineKind,
|
||||
dockerSocketPath,
|
||||
engineDisplayName,
|
||||
} from './services/docker.js'
|
||||
import logger from './utils/logger.js'
|
||||
import { isSwarmEnabled } from './handlers/swarm.js'
|
||||
import { isPluginsEnabled } from './handlers/plugins.js'
|
||||
@@ -98,8 +103,8 @@ server.on('error', (err) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Probe Docker socket for the boot banner.
|
||||
* @returns {Promise<{ ok: boolean, apiVersion?: string, os?: string, error?: string }>}
|
||||
* Probe container engine socket (Docker or Podman) for the boot banner.
|
||||
* @returns {Promise<{ ok: boolean, apiVersion?: string, os?: string, platform?: string, error?: string }>}
|
||||
*/
|
||||
async function probeDocker() {
|
||||
try {
|
||||
@@ -108,6 +113,7 @@ async function probeDocker() {
|
||||
ok: true,
|
||||
apiVersion: version.ApiVersion || version.apiVersion || undefined,
|
||||
os: version.Os || version.os || undefined,
|
||||
platform: version.Platform?.Name || version.Platform || undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message || String(err) }
|
||||
@@ -129,7 +135,14 @@ logger.banner([
|
||||
'Admin: paste public key + SERVER_SEED in the client.',
|
||||
'Operators: create a pd1. invite from Access (never share SERVER_SEED).',
|
||||
'',
|
||||
`Docker: ${dockerStatus.ok ? `ok · API ${dockerStatus.apiVersion || '?'} · ${dockerStatus.os || '?'}` : `unavailable · ${dockerStatus.error || 'socket error'}`}`,
|
||||
`${engineDisplayName()}: ${
|
||||
dockerStatus.ok
|
||||
? `ok · API ${dockerStatus.apiVersion || '?'} · ${dockerStatus.os || '?'}${
|
||||
dockerStatus.platform ? ` · ${dockerStatus.platform}` : ''
|
||||
}`
|
||||
: `unavailable · ${dockerStatus.error || 'socket error'}`
|
||||
}`,
|
||||
`Engine socket: ${dockerSocketPath} (${containerEngineKind})`,
|
||||
`Holesail: ${
|
||||
!isHolesailEnabled()
|
||||
? 'off (ENABLE_HOLESAIL=0)'
|
||||
@@ -146,13 +159,24 @@ logger.banner([
|
||||
log.info('Listening on HyperDHT', {
|
||||
publicKey: publicKeyHex.slice(0, 16) + '…',
|
||||
docker: dockerStatus.ok,
|
||||
engine: containerEngineKind,
|
||||
socketPath: dockerSocketPath,
|
||||
bootMs,
|
||||
})
|
||||
|
||||
if (!dockerStatus.ok) {
|
||||
log.warn('Docker socket not reachable — RPC that needs Docker will fail until dockerd is up', {
|
||||
log.warn(
|
||||
`${engineDisplayName()} socket not reachable — container RPC will fail until the engine is up`,
|
||||
{
|
||||
error: dockerStatus.error,
|
||||
})
|
||||
engine: containerEngineKind,
|
||||
socketPath: dockerSocketPath,
|
||||
hint:
|
||||
containerEngineKind === 'podman'
|
||||
? 'Start podman.socket or set CONTAINER_HOST / DOCKER_HOST'
|
||||
: 'Start Docker, or install Podman and set PEARDOCK_ENGINE=podman',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
startDockerEventStream()
|
||||
|
||||
+277
-26
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* Shared Dockerode client.
|
||||
* Shared Dockerode client (Docker Engine API).
|
||||
*
|
||||
* Prefer the unix socket. Explicit host: undefined so docker-modem does not
|
||||
* fall back to TCP localhost (which bare-http would hit without socketPath).
|
||||
* Resolves a unix socket (or Windows named pipe) for the local container
|
||||
* runtime. Prefer Docker when present; fall back to Podman (rootful or
|
||||
* rootless) so hosts without dockerd still work via the Docker-compatible API.
|
||||
*
|
||||
* Explicit host is left unset so docker-modem stays in socket mode (important
|
||||
* for Bare / bare-http, which would otherwise treat paths as TCP hosts).
|
||||
*/
|
||||
import Docker from 'dockerode'
|
||||
import net from 'net'
|
||||
@@ -10,30 +14,247 @@ 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'
|
||||
/**
|
||||
* @typedef {'docker' | 'podman' | 'unknown'} ContainerEngineKind
|
||||
* @typedef {{
|
||||
* path: string,
|
||||
* kind: ContainerEngineKind,
|
||||
* source: string,
|
||||
* exists: boolean,
|
||||
* }} ResolvedContainerSocket
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} socketPath
|
||||
* @returns {ContainerEngineKind}
|
||||
*/
|
||||
export function classifySocketPath(socketPath) {
|
||||
const p = String(socketPath || '').toLowerCase().replace(/\\/g, '/')
|
||||
if (!p) return 'unknown'
|
||||
if (p.includes('podman')) return 'podman'
|
||||
if (
|
||||
p.includes('docker') ||
|
||||
p.includes('dockerdesktop') ||
|
||||
p.includes('colima') ||
|
||||
p.endsWith('/docker.sock') ||
|
||||
p.includes('pipe/docker')
|
||||
) {
|
||||
return 'docker'
|
||||
}
|
||||
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'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
const socketPath = resolveSocketPath()
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @param {string} [homedir]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listDockerSocketCandidates(env = process.env, homedir = os.homedir()) {
|
||||
/** @type {string[]} */
|
||||
const out = []
|
||||
const push = (p) => {
|
||||
if (p && !out.includes(p)) out.push(p)
|
||||
}
|
||||
|
||||
push(env.DOCKER_SOCK)
|
||||
push(env.DOCKER_SOCKET)
|
||||
push('/var/run/docker.sock')
|
||||
push('/run/docker.sock')
|
||||
if (homedir) {
|
||||
push(`${homedir}/.docker/run/docker.sock`)
|
||||
push(`${homedir}/.colima/default/docker.sock`)
|
||||
push(`${homedir}/.colima/docker.sock`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @param {string} [homedir]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listPodmanSocketCandidates(env = process.env, homedir = os.homedir()) {
|
||||
/** @type {string[]} */
|
||||
const out = []
|
||||
const push = (p) => {
|
||||
if (p && !out.includes(p)) out.push(p)
|
||||
}
|
||||
|
||||
push(env.PODMAN_SOCK)
|
||||
push(env.PODMAN_SOCKET)
|
||||
|
||||
// Podman rootless (primary on many Fedora/RHEL desktops)
|
||||
const xdg = env.XDG_RUNTIME_DIR
|
||||
if (xdg) push(`${xdg.replace(/\/$/, '')}/podman/podman.sock`)
|
||||
|
||||
try {
|
||||
if (typeof process.getuid === 'function') {
|
||||
const uid = process.getuid()
|
||||
if (Number.isFinite(uid)) push(`/run/user/${uid}/podman/podman.sock`)
|
||||
}
|
||||
} catch {
|
||||
// ignore (Windows / restricted)
|
||||
}
|
||||
|
||||
// Rootful
|
||||
push('/run/podman/podman.sock')
|
||||
push('/var/run/podman/podman.sock')
|
||||
|
||||
// Podman machine (macOS / remote VM helpers)
|
||||
if (homedir) {
|
||||
push(`${homedir}/.local/share/containers/podman/machine/podman.sock`)
|
||||
push(`${homedir}/.local/share/containers/podman/machine/qemu/podman.sock`)
|
||||
push(`${homedir}/.local/share/containers/podman/machine/applehv/podman.sock`)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unix:// or npipe:// style host URLs to a local path.
|
||||
* @param {string|undefined|null} raw
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function parseUnixHostEnv(raw) {
|
||||
const s = String(raw || '').trim()
|
||||
if (!s) return null
|
||||
if (s.startsWith('unix://')) {
|
||||
return s.slice('unix://'.length) || '/var/run/docker.sock'
|
||||
}
|
||||
if (s.startsWith('npipe://')) {
|
||||
// docker-modem style: npipe:////./pipe/docker_engine
|
||||
return s.slice('npipe://'.length) || '//./pipe/docker_engine'
|
||||
}
|
||||
// bare path
|
||||
if (s.startsWith('/') || s.startsWith('//./pipe/')) return s
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* env?: NodeJS.ProcessEnv,
|
||||
* platform?: NodeJS.Platform,
|
||||
* homedir?: string,
|
||||
* access?: (path: string) => boolean,
|
||||
* }} [opts]
|
||||
* @returns {ResolvedContainerSocket}
|
||||
*/
|
||||
export function resolveContainerSocket(opts = {}) {
|
||||
const env = opts.env || process.env
|
||||
const platform = opts.platform || os.platform()
|
||||
const homedir = opts.homedir != null ? opts.homedir : os.homedir()
|
||||
const access =
|
||||
opts.access ||
|
||||
((p) => {
|
||||
try {
|
||||
fs.accessSync(p)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const preferRaw = String(env.PEARDOCK_ENGINE || env.CONTAINER_ENGINE || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const preferPodman = preferRaw === 'podman'
|
||||
const preferDocker = preferRaw === 'docker'
|
||||
|
||||
// Explicit host URLs win (operator intent). CONTAINER_HOST is Podman's convention.
|
||||
const explicit =
|
||||
parseUnixHostEnv(env.DOCKER_HOST) ||
|
||||
parseUnixHostEnv(env.CONTAINER_HOST) ||
|
||||
(env.DOCKER_SOCK && String(env.DOCKER_SOCK).trim()) ||
|
||||
(env.DOCKER_SOCKET && String(env.DOCKER_SOCKET).trim()) ||
|
||||
(env.PODMAN_SOCK && String(env.PODMAN_SOCK).trim()) ||
|
||||
null
|
||||
|
||||
if (explicit) {
|
||||
const kind = classifySocketPath(explicit)
|
||||
return {
|
||||
path: explicit,
|
||||
kind: kind === 'unknown' && preferPodman ? 'podman' : kind === 'unknown' && !preferPodman ? 'docker' : kind,
|
||||
source: env.DOCKER_HOST
|
||||
? 'DOCKER_HOST'
|
||||
: env.CONTAINER_HOST
|
||||
? 'CONTAINER_HOST'
|
||||
: env.DOCKER_SOCK || env.DOCKER_SOCKET
|
||||
? 'DOCKER_SOCK'
|
||||
: env.PODMAN_SOCK
|
||||
? 'PODMAN_SOCK'
|
||||
: 'explicit',
|
||||
exists: access(explicit),
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const winCandidates = [
|
||||
'//./pipe/dockerDesktopLinuxEngine',
|
||||
'//./pipe/docker_engine',
|
||||
'//./pipe/podman-machine-default',
|
||||
]
|
||||
for (const p of winCandidates) {
|
||||
if (access(p)) {
|
||||
return {
|
||||
path: p,
|
||||
kind: classifySocketPath(p),
|
||||
source: 'windows-pipe',
|
||||
exists: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: '//./pipe/dockerDesktopLinuxEngine',
|
||||
kind: 'docker',
|
||||
source: 'windows-pipe-default',
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Array<{ path: string, kind: ContainerEngineKind, source: string }>} */
|
||||
const ordered = []
|
||||
const dockerList = listDockerSocketCandidates(env, homedir).map((path) => ({
|
||||
path,
|
||||
kind: /** @type {ContainerEngineKind} */ ('docker'),
|
||||
source: 'docker-candidate',
|
||||
}))
|
||||
const podmanList = listPodmanSocketCandidates(env, homedir).map((path) => ({
|
||||
path,
|
||||
kind: /** @type {ContainerEngineKind} */ ('podman'),
|
||||
source: 'podman-candidate',
|
||||
}))
|
||||
|
||||
if (preferPodman) {
|
||||
ordered.push(...podmanList, ...dockerList)
|
||||
} else if (preferDocker) {
|
||||
ordered.push(...dockerList)
|
||||
} else {
|
||||
// Default: Docker first, then Podman
|
||||
ordered.push(...dockerList, ...podmanList)
|
||||
}
|
||||
|
||||
for (const c of ordered) {
|
||||
if (access(c.path)) {
|
||||
return { path: c.path, kind: c.kind, source: c.source, exists: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing found — keep a sensible default for error messages / later attach
|
||||
if (preferPodman) {
|
||||
const fallback =
|
||||
listPodmanSocketCandidates(env, homedir)[0] || '/run/podman/podman.sock'
|
||||
return { path: fallback, kind: 'podman', source: 'podman-default', exists: false }
|
||||
}
|
||||
return {
|
||||
path: '/var/run/docker.sock',
|
||||
kind: 'docker',
|
||||
source: 'docker-default',
|
||||
exists: false,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSocket = resolveContainerSocket()
|
||||
const socketPath = resolvedSocket.path
|
||||
|
||||
export const docker = new Docker({
|
||||
socketPath,
|
||||
@@ -42,6 +263,36 @@ export const docker = new Docker({
|
||||
})
|
||||
|
||||
export { socketPath as dockerSocketPath }
|
||||
export const containerEngineKind = resolvedSocket.kind
|
||||
export const containerSocketMeta = resolvedSocket
|
||||
|
||||
if (resolvedSocket.exists) {
|
||||
logger.info('Container engine socket resolved', {
|
||||
engine: resolvedSocket.kind,
|
||||
socketPath: resolvedSocket.path,
|
||||
source: resolvedSocket.source,
|
||||
})
|
||||
} else {
|
||||
logger.warn('No container engine socket found yet (will retry on RPC)', {
|
||||
engine: resolvedSocket.kind,
|
||||
triedDefault: resolvedSocket.path,
|
||||
source: resolvedSocket.source,
|
||||
hint:
|
||||
resolvedSocket.kind === 'podman'
|
||||
? 'Start podman (systemctl --user enable --now podman.socket) or set DOCKER_HOST/CONTAINER_HOST'
|
||||
: 'Start Docker or Podman, or set DOCKER_HOST / PODMAN_SOCK / PEARDOCK_ENGINE=podman',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Human label for logs / banners.
|
||||
* @param {ContainerEngineKind} [kind]
|
||||
*/
|
||||
export function engineDisplayName(kind = containerEngineKind) {
|
||||
if (kind === 'podman') return 'Podman'
|
||||
if (kind === 'docker') return 'Docker'
|
||||
return 'Container engine'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize container id/name for Engine API path segment.
|
||||
@@ -100,7 +351,7 @@ function dockerUnixEmpty(method, apiPath, opts = {}) {
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`Docker socket timeout (${verb} ${path})`))
|
||||
finish(new Error(`Container socket timeout (${verb} ${path})`))
|
||||
}, timeoutMs)
|
||||
|
||||
socket.on('connect', () => {
|
||||
@@ -136,7 +387,7 @@ function dockerUnixEmpty(method, apiPath, opts = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
let message = body.trim() || `Docker HTTP ${statusCode || 'error'} for ${path}`
|
||||
let message = body.trim() || `Engine HTTP ${statusCode || 'error'} for ${path}`
|
||||
try {
|
||||
const j = JSON.parse(body)
|
||||
if (j?.message) message = j.message
|
||||
|
||||
@@ -410,11 +410,13 @@ export function startStatsBroadcast() {
|
||||
const dockerDown =
|
||||
msg.includes('ENOENT') ||
|
||||
msg.includes('ECONNREFUSED') ||
|
||||
msg.includes('docker.sock')
|
||||
msg.includes('docker.sock') ||
|
||||
msg.includes('podman.sock') ||
|
||||
msg.includes('connect ENOENT')
|
||||
if (dockerDown) {
|
||||
dockerBackoffUntil = Date.now() + 15000
|
||||
if (!dockerDownLogged) {
|
||||
logger.error('Docker unavailable; stats paused', { error: msg })
|
||||
logger.error('Container engine unavailable; stats paused', { error: msg })
|
||||
dockerDownLogged = true
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Docker Compose helpers: js-yaml parse + docker compose CLI lifecycle.
|
||||
* Compose helpers: js-yaml parse + docker/podman compose CLI lifecycle.
|
||||
*/
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
@@ -7,7 +7,23 @@ import path from 'path'
|
||||
import os from 'os'
|
||||
import yaml from 'js-yaml'
|
||||
import logger from './logger.js'
|
||||
import { startContainerNoBody } from '../services/docker.js'
|
||||
import {
|
||||
startContainerNoBody,
|
||||
containerEngineKind,
|
||||
} from '../services/docker.js'
|
||||
|
||||
/**
|
||||
* Resolve compose CLI binary + argv prefix based on preferred engine.
|
||||
* Docker: `docker compose …`
|
||||
* Podman: `podman compose …` (falls back to docker if podman missing at spawn time)
|
||||
* @returns {{ cmd: string, prefix: string[] }}
|
||||
*/
|
||||
export function resolveComposeCli(kind = containerEngineKind) {
|
||||
if (kind === 'podman') {
|
||||
return { cmd: 'podman', prefix: ['compose'] }
|
||||
}
|
||||
return { cmd: 'docker', prefix: ['compose'] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse docker-compose YAML with js-yaml and normalize service fields.
|
||||
@@ -193,8 +209,24 @@ export function runComposeCli(args, {
|
||||
if (p) profileArgs.push('--profile', String(p))
|
||||
}
|
||||
|
||||
const fullArgs = ['compose', ...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
||||
const child = spawn('docker', fullArgs, {
|
||||
const { cmd, prefix } = resolveComposeCli()
|
||||
const fullArgs = [...prefix, ...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
||||
/** @type {Set<string>} */
|
||||
const tried = new Set()
|
||||
|
||||
const trySpawn = (executable) => {
|
||||
if (tried.has(executable)) {
|
||||
cleanup(dir)
|
||||
reject(
|
||||
new Error(
|
||||
`No compose CLI available (tried ${[...tried].join(', ')}). Install docker or podman compose.`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
tried.add(executable)
|
||||
|
||||
const child = spawn(executable, fullArgs, {
|
||||
env: { ...process.env, ...env },
|
||||
cwd: dir,
|
||||
})
|
||||
@@ -203,7 +235,7 @@ export function runComposeCli(args, {
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
reject(new Error(`docker compose timed out after ${timeoutMs}ms`))
|
||||
reject(new Error(`${executable} compose timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on('data', (d) => {
|
||||
@@ -214,6 +246,14 @@ export function runComposeCli(args, {
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
// Fall back to the other CLI when the preferred binary is missing
|
||||
if (err?.code === 'ENOENT') {
|
||||
const alt = executable === 'docker' ? 'podman' : 'docker'
|
||||
if (!tried.has(alt)) {
|
||||
trySpawn(alt)
|
||||
return
|
||||
}
|
||||
}
|
||||
cleanup(dir)
|
||||
reject(err)
|
||||
})
|
||||
@@ -222,6 +262,9 @@ export function runComposeCli(args, {
|
||||
cleanup(dir)
|
||||
resolve({ code: code ?? 1, stdout, stderr })
|
||||
})
|
||||
}
|
||||
|
||||
trySpawn(cmd)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
classifySocketPath,
|
||||
listDockerSocketCandidates,
|
||||
listPodmanSocketCandidates,
|
||||
parseUnixHostEnv,
|
||||
resolveContainerSocket,
|
||||
engineDisplayName,
|
||||
} from '../server/services/docker.js'
|
||||
import { resolveComposeCli } from '../server/utils/composeManager.js'
|
||||
|
||||
test('classifySocketPath detects docker vs podman', (t) => {
|
||||
t.is(classifySocketPath('/var/run/docker.sock'), 'docker')
|
||||
t.is(classifySocketPath('/run/user/1000/podman/podman.sock'), 'podman')
|
||||
t.is(classifySocketPath('//./pipe/dockerDesktopLinuxEngine'), 'docker')
|
||||
t.is(classifySocketPath('//./pipe/podman-machine-default'), 'podman')
|
||||
t.is(classifySocketPath('/tmp/custom.sock'), 'unknown')
|
||||
})
|
||||
|
||||
test('parseUnixHostEnv accepts unix and bare paths', (t) => {
|
||||
t.is(parseUnixHostEnv('unix:///run/podman/podman.sock'), '/run/podman/podman.sock')
|
||||
t.is(parseUnixHostEnv('unix://'), '/var/run/docker.sock')
|
||||
t.is(parseUnixHostEnv('/var/run/docker.sock'), '/var/run/docker.sock')
|
||||
t.is(parseUnixHostEnv('tcp://1.2.3.4:2375'), null)
|
||||
})
|
||||
|
||||
test('listPodmanSocketCandidates includes rootless and rootful paths', (t) => {
|
||||
const list = listPodmanSocketCandidates(
|
||||
{ XDG_RUNTIME_DIR: '/run/user/42', PODMAN_SOCK: '/custom/podman.sock' },
|
||||
'/home/alice'
|
||||
)
|
||||
t.ok(list.includes('/custom/podman.sock'))
|
||||
t.ok(list.includes('/run/user/42/podman/podman.sock'))
|
||||
t.ok(list.includes('/run/podman/podman.sock'))
|
||||
t.ok(list.includes('/home/alice/.local/share/containers/podman/machine/podman.sock'))
|
||||
})
|
||||
|
||||
test('listDockerSocketCandidates includes env and colima', (t) => {
|
||||
const list = listDockerSocketCandidates(
|
||||
{ DOCKER_SOCK: '/tmp/my-docker.sock' },
|
||||
'/home/alice'
|
||||
)
|
||||
t.ok(list.includes('/tmp/my-docker.sock'))
|
||||
t.ok(list.includes('/var/run/docker.sock'))
|
||||
t.ok(list.includes('/home/alice/.colima/default/docker.sock'))
|
||||
})
|
||||
|
||||
test('resolveContainerSocket prefers Docker when present', (t) => {
|
||||
const accessible = new Set(['/var/run/docker.sock', '/run/podman/podman.sock'])
|
||||
const r = resolveContainerSocket({
|
||||
env: {},
|
||||
platform: 'linux',
|
||||
homedir: '/home/alice',
|
||||
access: (p) => accessible.has(p),
|
||||
})
|
||||
t.is(r.path, '/var/run/docker.sock')
|
||||
t.is(r.kind, 'docker')
|
||||
t.ok(r.exists)
|
||||
})
|
||||
|
||||
test('resolveContainerSocket falls back to Podman when Docker missing', (t) => {
|
||||
const accessible = new Set(['/run/user/1000/podman/podman.sock'])
|
||||
const r = resolveContainerSocket({
|
||||
env: { XDG_RUNTIME_DIR: '/run/user/1000' },
|
||||
platform: 'linux',
|
||||
homedir: '/home/alice',
|
||||
access: (p) => accessible.has(p),
|
||||
})
|
||||
t.is(r.path, '/run/user/1000/podman/podman.sock')
|
||||
t.is(r.kind, 'podman')
|
||||
t.ok(r.exists)
|
||||
})
|
||||
|
||||
test('resolveContainerSocket PEARDOCK_ENGINE=podman prefers podman', (t) => {
|
||||
const accessible = new Set(['/var/run/docker.sock', '/run/podman/podman.sock'])
|
||||
const r = resolveContainerSocket({
|
||||
env: { PEARDOCK_ENGINE: 'podman' },
|
||||
platform: 'linux',
|
||||
homedir: '/home/alice',
|
||||
access: (p) => accessible.has(p),
|
||||
})
|
||||
t.is(r.path, '/run/podman/podman.sock')
|
||||
t.is(r.kind, 'podman')
|
||||
})
|
||||
|
||||
test('resolveContainerSocket DOCKER_HOST wins', (t) => {
|
||||
const r = resolveContainerSocket({
|
||||
env: { DOCKER_HOST: 'unix:///custom/engine.sock' },
|
||||
platform: 'linux',
|
||||
homedir: '/home/alice',
|
||||
access: () => true,
|
||||
})
|
||||
t.is(r.path, '/custom/engine.sock')
|
||||
t.is(r.source, 'DOCKER_HOST')
|
||||
})
|
||||
|
||||
test('resolveContainerSocket CONTAINER_HOST wins when no DOCKER_HOST', (t) => {
|
||||
const r = resolveContainerSocket({
|
||||
env: { CONTAINER_HOST: 'unix:///run/user/1/podman/podman.sock' },
|
||||
platform: 'linux',
|
||||
homedir: '/home/alice',
|
||||
access: () => false,
|
||||
})
|
||||
t.is(r.path, '/run/user/1/podman/podman.sock')
|
||||
t.is(r.kind, 'podman')
|
||||
t.is(r.source, 'CONTAINER_HOST')
|
||||
t.is(r.exists, false)
|
||||
})
|
||||
|
||||
test('resolveComposeCli picks podman for podman engine', (t) => {
|
||||
t.alike(resolveComposeCli('podman'), { cmd: 'podman', prefix: ['compose'] })
|
||||
t.alike(resolveComposeCli('docker'), { cmd: 'docker', prefix: ['compose'] })
|
||||
t.is(engineDisplayName('podman'), 'Podman')
|
||||
t.is(engineDisplayName('docker'), 'Docker')
|
||||
})
|
||||
Reference in New Issue
Block a user