Add Podman fallback when Docker socket is missing.
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:
Raven Scott
2026-07-16 05:21:32 -04:00
parent 658bc1ee59
commit 8e6a0505a9
10 changed files with 553 additions and 76 deletions
+2
View File
@@ -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_SWARM` | On (`1`); set `0` to disable Swarm RPC |
| `ENABLE_HOLESAIL` | On (`1`); set `0` to disable tunnels | | `ENABLE_HOLESAIL` | On (`1`); set `0` to disable tunnels |
| `ENABLE_PLUGINS` | Off unless `1` | | `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` | | `PEARDOCK_MAX_TUNNELS` | `20` |
| `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald | | `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald |
+23
View File
@@ -68,6 +68,29 @@ sudo systemctl restart peardock
Symptoms of a missing group: empty container lists, start failures, “permission denied” on the socket. 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 ## Manual install from source
```bash ```bash
+11 -6
View File
@@ -1,18 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Local healthcheck for peardock server process supervision. * 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. * Exit 0 = healthy, 1 = unhealthy.
* *
* Usage: node scripts/healthcheck.js * Usage: node scripts/healthcheck.js
* Docker HEALTHCHECK / systemd ExecStartPost / k8s probe compatible. * 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 timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS) || 5000
const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
const timer = setTimeout(() => { const timer = setTimeout(() => {
console.error('healthcheck: timeout') console.error('healthcheck: timeout')
process.exit(1) process.exit(1)
@@ -21,10 +23,13 @@ const timer = setTimeout(() => {
try { try {
await docker.ping() await docker.ping()
clearTimeout(timer) clearTimeout(timer)
console.log('ok docker') console.log(`ok ${containerEngineKind} ${dockerSocketPath}`)
process.exit(0) process.exit(0)
} catch (err) { } catch (err) {
clearTimeout(timer) clearTimeout(timer)
console.error('healthcheck failed:', err.message) console.error(
`healthcheck failed (${containerEngineKind} ${dockerSocketPath}):`,
err.message
)
process.exit(1) process.exit(1)
} }
+10 -5
View File
@@ -7,10 +7,14 @@
* SOAK_DURATION_MS=86400000 node scripts/soak.js # 24h * SOAK_DURATION_MS=86400000 node scripts/soak.js # 24h
* node scripts/soak.js --duration 300 # 300 seconds * node scripts/soak.js --duration 300 # 300 seconds
* *
* Checks Docker every interval; fails process if consecutive failures exceed threshold. * Checks container engine every interval; fails process if consecutive failures exceed threshold.
* Pair with: npm run server (separate process) + this soak against local Docker. * 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) const args = process.argv.slice(2)
let durationMs = Number(process.env.SOAK_DURATION_MS) || 60_000 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() const started = Date.now()
let checks = 0 let checks = 0
let failures = 0 let failures = 0
let consecutive = 0 let consecutive = 0
const latencies = [] 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() { async function tick() {
const t0 = Date.now() const t0 = Date.now()
+8 -1
View File
@@ -3,7 +3,11 @@
*/ */
import fs from 'fs' import fs from 'fs'
import path from 'path' 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 * as validation from '../utils/validation.js'
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js' import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import { getMetricsSnapshot } from '../services/metrics.js' import { getMetricsSnapshot } from '../services/metrics.js'
@@ -45,6 +49,9 @@ export function registerSystemHandlers(session) {
apiVersion, apiVersion,
os: osType, os: osType,
error, error,
/** docker | podman | unknown — socket used by this server process */
engine: containerEngineKind,
socketPath: dockerSocketPath,
}, },
features: { features: {
swarm: isSwarmEnabled(), swarm: isSwarmEnabled(),
+30 -6
View File
@@ -25,7 +25,12 @@ import {
restoreTunnelsFromDisk, restoreTunnelsFromDisk,
} from './services/holesail-tunnels.js' } from './services/holesail-tunnels.js'
import { restoreSchedules } from './services/schedules.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 logger from './utils/logger.js'
import { isSwarmEnabled } from './handlers/swarm.js' import { isSwarmEnabled } from './handlers/swarm.js'
import { isPluginsEnabled } from './handlers/plugins.js' import { isPluginsEnabled } from './handlers/plugins.js'
@@ -98,8 +103,8 @@ server.on('error', (err) => {
}) })
/** /**
* Probe Docker socket for the boot banner. * Probe container engine socket (Docker or Podman) for the boot banner.
* @returns {Promise<{ ok: boolean, apiVersion?: string, os?: string, error?: string }>} * @returns {Promise<{ ok: boolean, apiVersion?: string, os?: string, platform?: string, error?: string }>}
*/ */
async function probeDocker() { async function probeDocker() {
try { try {
@@ -108,6 +113,7 @@ async function probeDocker() {
ok: true, ok: true,
apiVersion: version.ApiVersion || version.apiVersion || undefined, apiVersion: version.ApiVersion || version.apiVersion || undefined,
os: version.Os || version.os || undefined, os: version.Os || version.os || undefined,
platform: version.Platform?.Name || version.Platform || undefined,
} }
} catch (err) { } catch (err) {
return { ok: false, error: err.message || String(err) } return { ok: false, error: err.message || String(err) }
@@ -129,7 +135,14 @@ logger.banner([
'Admin: paste public key + SERVER_SEED in the client.', 'Admin: paste public key + SERVER_SEED in the client.',
'Operators: create a pd1. invite from Access (never share SERVER_SEED).', '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: ${ `Holesail: ${
!isHolesailEnabled() !isHolesailEnabled()
? 'off (ENABLE_HOLESAIL=0)' ? 'off (ENABLE_HOLESAIL=0)'
@@ -146,13 +159,24 @@ logger.banner([
log.info('Listening on HyperDHT', { log.info('Listening on HyperDHT', {
publicKey: publicKeyHex.slice(0, 16) + '…', publicKey: publicKeyHex.slice(0, 16) + '…',
docker: dockerStatus.ok, docker: dockerStatus.ok,
engine: containerEngineKind,
socketPath: dockerSocketPath,
bootMs, bootMs,
}) })
if (!dockerStatus.ok) { 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, 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() startDockerEventStream()
+277 -26
View File
@@ -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 * Resolves a unix socket (or Windows named pipe) for the local container
* fall back to TCP localhost (which bare-http would hit without socketPath). * 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 Docker from 'dockerode'
import net from 'net' import net from 'net'
@@ -10,30 +14,247 @@ import os from 'os'
import fs from 'fs' import fs from 'fs'
import logger from '../utils/logger.js' import logger from '../utils/logger.js'
function resolveSocketPath() { /**
if (process.env.DOCKER_HOST?.startsWith('unix://')) { * @typedef {'docker' | 'podman' | 'unknown'} ContainerEngineKind
return process.env.DOCKER_HOST.slice('unix://'.length) || '/var/run/docker.sock' * @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 'unknown'
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() /**
* @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({ export const docker = new Docker({
socketPath, socketPath,
@@ -42,6 +263,36 @@ export const docker = new Docker({
}) })
export { socketPath as dockerSocketPath } 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. * Normalize container id/name for Engine API path segment.
@@ -100,7 +351,7 @@ function dockerUnixEmpty(method, apiPath, opts = {}) {
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
finish(new Error(`Docker socket timeout (${verb} ${path})`)) finish(new Error(`Container socket timeout (${verb} ${path})`))
}, timeoutMs) }, timeoutMs)
socket.on('connect', () => { socket.on('connect', () => {
@@ -136,7 +387,7 @@ function dockerUnixEmpty(method, apiPath, opts = {}) {
return return
} }
let message = body.trim() || `Docker HTTP ${statusCode || 'error'} for ${path}` let message = body.trim() || `Engine HTTP ${statusCode || 'error'} for ${path}`
try { try {
const j = JSON.parse(body) const j = JSON.parse(body)
if (j?.message) message = j.message if (j?.message) message = j.message
+4 -2
View File
@@ -410,11 +410,13 @@ export function startStatsBroadcast() {
const dockerDown = const dockerDown =
msg.includes('ENOENT') || msg.includes('ENOENT') ||
msg.includes('ECONNREFUSED') || msg.includes('ECONNREFUSED') ||
msg.includes('docker.sock') msg.includes('docker.sock') ||
msg.includes('podman.sock') ||
msg.includes('connect ENOENT')
if (dockerDown) { if (dockerDown) {
dockerBackoffUntil = Date.now() + 15000 dockerBackoffUntil = Date.now() + 15000
if (!dockerDownLogged) { if (!dockerDownLogged) {
logger.error('Docker unavailable; stats paused', { error: msg }) logger.error('Container engine unavailable; stats paused', { error: msg })
dockerDownLogged = true dockerDownLogged = true
} }
} else { } else {
+48 -5
View File
@@ -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 { spawn } from 'child_process'
import fs from 'fs' import fs from 'fs'
@@ -7,7 +7,23 @@ import path from 'path'
import os from 'os' import os from 'os'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import logger from './logger.js' 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. * 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)) if (p) profileArgs.push('--profile', String(p))
} }
const fullArgs = ['compose', ...fileArgs, '-p', projectName, ...profileArgs, ...args] const { cmd, prefix } = resolveComposeCli()
const child = spawn('docker', fullArgs, { 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 }, env: { ...process.env, ...env },
cwd: dir, cwd: dir,
}) })
@@ -203,7 +235,7 @@ export function runComposeCli(args, {
let stderr = '' let stderr = ''
const timer = setTimeout(() => { const timer = setTimeout(() => {
child.kill('SIGTERM') child.kill('SIGTERM')
reject(new Error(`docker compose timed out after ${timeoutMs}ms`)) reject(new Error(`${executable} compose timed out after ${timeoutMs}ms`))
}, timeoutMs) }, timeoutMs)
child.stdout?.on('data', (d) => { child.stdout?.on('data', (d) => {
@@ -214,6 +246,14 @@ export function runComposeCli(args, {
}) })
child.on('error', (err) => { child.on('error', (err) => {
clearTimeout(timer) 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) cleanup(dir)
reject(err) reject(err)
}) })
@@ -222,6 +262,9 @@ export function runComposeCli(args, {
cleanup(dir) cleanup(dir)
resolve({ code: code ?? 1, stdout, stderr }) resolve({ code: code ?? 1, stdout, stderr })
}) })
}
trySpawn(cmd)
}) })
} }
+115
View File
@@ -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')
})