Further Podman Support work
Release rolling / release (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-07-16 05:33:32 -04:00
parent 8e6a0505a9
commit f9d16b7cf8
22 changed files with 645 additions and 109 deletions
+2 -2
View File
@@ -283,9 +283,9 @@ Full technical docs live under **[`docs/`](docs/README.md)** (architecture diagr
| `PEARDOCK_PEER_ALLOWLIST` | `1` to require registered peers |
| `PEARDOCK_AUDIT` | `1` to append privileged actions to audit log |
| `PEARDOCK_BROWSE_ROOTS` | Allowed host paths for file browse |
| `ENABLE_SWARM` | On (`1`); set `0` to disable Swarm RPC |
| `ENABLE_SWARM` | On (`1`) for Docker; set `0` to disable. Always off on Podman (UI hidden) |
| `ENABLE_HOLESAIL` | On (`1`); set `0` to disable tunnels |
| `ENABLE_PLUGINS` | Off unless `1` |
| `ENABLE_PLUGINS` | Off unless `1` (Docker only; never on Podman) |
| `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` |
+106 -14
View File
@@ -1216,6 +1216,14 @@ async function loadFleetView() {
const active = manager.active === conn;
const lat = conn.latency != null ? `${conn.latency} ms` : '—';
const dockerOk = conn.dockerHealth?.ok;
const engLabel =
conn.features?.engineLabel ||
conn.dockerHealth?.label ||
(conn.dockerHealth?.engine === 'podman'
? 'Podman'
: conn.dockerHealth?.engine === 'docker'
? 'Docker'
: 'Engine');
const role = conn.role || '—';
const env = envMap[id] || '';
return `
@@ -1229,7 +1237,7 @@ async function loadFleetView() {
<p class="small text-muted mb-2 font-monospace">${escapeHtmlLite((conn.publicKeyHex || '').slice(0, 24))}</p>
<ul class="list-unstyled small mb-2">
<li><i class="fas fa-gauge-high me-1"></i> Latency: ${lat}</li>
<li><i class="fab fa-docker me-1"></i> Docker: ${dockerOk === true ? 'ok' : dockerOk === false ? 'down' : ''}</li>
<li><i class="fas fa-cube me-1"></i> ${escapeHtmlLite(engLabel)}: ${dockerOk === true ? 'ok' : dockerOk === false ? 'down' : ''}</li>
<li><i class="fas fa-user-shield me-1"></i> Role: ${escapeHtmlLite(role)}</li>
<li><i class="fas fa-heart me-1"></i> ${escapeHtmlLite(conn.healthStatus || 'unknown')}</li>
</ul>
@@ -1689,8 +1697,66 @@ function applyRoleUI() {
el.classList.toggle('role-hidden', !invitesAllowed);
el.setAttribute('aria-hidden', invitesAllowed ? 'false' : 'true');
});
applyEngineUI();
}
/**
* Hide engine-unsupported features (Swarm/plugins on Podman, etc.).
* Driven by ping `features` from the active connection.
*/
function applyEngineUI() {
const conn = manager.active;
const features = conn?.features || null;
const docker = conn?.dockerHealth || null;
const engine =
features?.engine ||
docker?.engine ||
(features?.podmanCli ? 'podman' : null) ||
'docker';
const swarmOn = features ? features.swarm !== false : engine !== 'podman';
const pluginsOn = features ? features.plugins === true : false;
document.body.dataset.engine = engine;
document.body.classList.toggle('engine-podman', engine === 'podman');
document.body.classList.toggle('engine-docker', engine === 'docker');
document.body.classList.toggle('feature-swarm-off', !swarmOn);
document.body.classList.toggle('feature-plugins-off', !pluginsOn);
// Sidebar + any nav with data-view="swarm"
document.querySelectorAll('[data-view="swarm"]').forEach((el) => {
el.classList.toggle('engine-feature-hidden', !swarmOn);
el.setAttribute('aria-hidden', swarmOn ? 'false' : 'true');
if (!swarmOn) {
el.title = 'Swarm is not available on this engine (Podman or ENABLE_SWARM=0)';
} else if (el.title?.includes('not available')) {
el.removeAttribute('title');
}
});
document.querySelectorAll('[data-feature="swarm"]').forEach((el) => {
el.classList.toggle('engine-feature-hidden', !swarmOn);
});
document.querySelectorAll('[data-feature="plugins"]').forEach((el) => {
el.classList.toggle('engine-feature-hidden', !pluginsOn);
});
// Leave swarm view if it is hidden for this engine
if (!swarmOn && typeof currentView !== 'undefined' && currentView === 'swarm') {
if (typeof navigateToView === 'function') navigateToView('containers');
}
// Host CLI button label
document.querySelectorAll('.docker-terminal-btn, [data-cli-label]').forEach((el) => {
const label = engine === 'podman' ? 'Podman CLI' : 'Docker CLI';
if (el.getAttribute('aria-label')) el.setAttribute('aria-label', label);
if (el.getAttribute('title') && /cli/i.test(el.getAttribute('title') || '')) {
el.setAttribute('title', label);
}
});
}
window.applyEngineUI = applyEngineUI;
function showFirstConnectChecklist() {
try {
if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') {
@@ -1926,16 +1992,22 @@ function loadDashboard() {
const dockerInfoEl = document.getElementById('docker-info-content');
if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) {
const eng = snap.engine;
const features = manager.active?.features;
const swarmVal =
features?.swarm === false
? 'n/a'
: eng.swarm || 'inactive';
const m = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
dockerInfoEl.innerHTML = [
const rows = [
m('Host', eng.name || 'host'),
m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`),
m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'),
m('Swarm', eng.swarm || 'inactive'),
m('CPUs', eng.ncpu ?? '—'),
m('Volumes', snap.counts?.volumes ?? 0),
].join('');
m('Engine', features?.engineLabel || features?.engine || '—'),
];
if (features?.swarm !== false) rows.push(m('Swarm', swarmVal));
rows.push(m('CPUs', eng.ncpu ?? '—'), m('Volumes', snap.counts?.volumes ?? 0));
dockerInfoEl.innerHTML = rows.join('');
dockerInfoEl.dataset.filled = '1';
}
})
@@ -2060,7 +2132,7 @@ function updateHealthBadge(info, conn) {
if (!info && !conn?.connected) {
dot.className = 'health-dot health-dot--unknown';
lat.textContent = '—';
dock.textContent = 'Docker —';
dock.textContent = 'Engine —';
if (roleEl) roleEl.textContent = '';
return;
}
@@ -2071,14 +2143,18 @@ function updateHealthBadge(info, conn) {
const role = conn?.role || info?.role || null;
lat.textContent = latency != null ? `${latency} ms` : '—';
const engineLabel =
docker?.label ||
(docker?.engine === 'podman' ? 'Podman' : docker?.engine === 'docker' ? 'Docker' : 'Engine');
if (docker?.ok === true) {
dock.textContent = `Docker ${docker.apiVersion || 'ok'}`;
dock.textContent = `${engineLabel} ${docker.apiVersion || 'ok'}`;
} else if (docker?.ok === false) {
dock.textContent = 'Docker down';
dock.textContent = `${engineLabel} down`;
} else {
dock.textContent = 'Docker —';
dock.textContent = `${engineLabel}`;
}
if (roleEl) roleEl.textContent = role ? role : '';
if (typeof applyEngineUI === 'function') applyEngineUI();
let cls = 'health-dot--unknown';
if (status === 'healthy' || (docker?.ok && status !== 'degraded')) cls = 'health-dot--ok';
@@ -2971,9 +3047,11 @@ function updateSystemInfo(systemInfo) {
if (dockerInfoEl && systemInfo.info) {
const info = systemInfo.info;
const features = systemInfo.engine || manager.active?.features;
const metric = (label, value) =>
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
dockerInfoEl.innerHTML = [
const rows = [
metric('Engine', features?.engineLabel || features?.engine || '—'),
metric('Version', systemInfo.version?.Version || 'Unknown'),
metric('Containers', info.Containers || 0),
metric('Running', info.ContainersRunning || 0),
@@ -2981,7 +3059,13 @@ function updateSystemInfo(systemInfo) {
metric('Stopped', info.ContainersStopped || 0),
metric('Images', info.Images || 0),
metric('Storage driver', info.Driver || 'Unknown'),
].join('');
];
if (features?.swarm === false) {
rows.push(metric('Swarm', 'n/a (Podman)'));
} else if (info.Swarm?.LocalNodeState) {
rows.push(metric('Swarm', info.Swarm.LocalNodeState));
}
dockerInfoEl.innerHTML = rows.join('');
}
if (resourcesEl && systemInfo.info) {
@@ -4658,8 +4742,16 @@ function populateOverviewTab(config, container) {
'';
const labels = config.Config?.Labels || {};
const composeProject =
labels['com.docker.compose.project'] || labels['com.docker.compose.project.working_dir'] || '';
const composeService = labels['com.docker.compose.service'] || '';
labels['com.docker.compose.project'] ||
labels['io.podman.compose.project'] ||
labels['io.compose.project'] ||
labels['com.docker.compose.project.working_dir'] ||
'';
const composeService =
labels['com.docker.compose.service'] ||
labels['io.podman.compose.service'] ||
labels['io.compose.service'] ||
'';
const composeWorkdir = labels['com.docker.compose.project.working_dir'] || '';
const logDriver = config.HostConfig?.LogConfig?.Type || 'json-file';
const healthStatus = config.State?.Health?.Status || '';
+18
View File
@@ -66,6 +66,8 @@ export class PearDockConnection extends EventEmitter {
this.authMode = null
this.protocolVersion = null
this.dockerHealth = null
/** @type {object|null} engine feature matrix from ping */
this.features = null
this.clientPublicKeyHex = null
}
@@ -328,6 +330,21 @@ export class PearDockConnection extends EventEmitter {
if (res?.role) this.role = res.role
if (res?.protocolVersion != null) this.protocolVersion = res.protocolVersion
if (res?.features && typeof res.features === 'object') {
this.features = res.features
} else if (res?.docker?.engine) {
// Older servers: synthesize minimal matrix
const eng = res.docker.engine
this.features = {
engine: eng,
engineLabel: res.docker.label || (eng === 'podman' ? 'Podman' : 'Docker'),
swarm: eng !== 'podman',
plugins: false,
compose: true,
stacks: true,
unsupported: eng === 'podman' ? ['swarm', 'plugins'] : [],
}
}
if (res?.docker && res.docker.ok === false) {
this.healthStatus = 'degraded'
this.state = 'degraded'
@@ -338,6 +355,7 @@ export class PearDockConnection extends EventEmitter {
this.emit('health', {
latency: this.latency,
docker: this.dockerHealth,
features: this.features,
status: this.healthStatus,
})
return this.latency
+3 -2
View File
@@ -26,8 +26,9 @@ What PearDock can do today, mapped to code and protocol surfaces.
| Peer invites / revoke / roles | shipped | on | `handlers/peers.js`, `core/peer-policy.js` |
| Audit log | shipped | opt-in env | `core/audit.js` |
| Holesail tunnels | shipped | **on** | `handlers/tunnels.js`, `services/holesail-tunnels.js` |
| Docker Swarm | shipped | **on** | `handlers/swarm.js` |
| Engine plugins | shipped | **off** | `handlers/plugins.js` |
| Docker Swarm | shipped | **on** (Docker only; hidden on Podman) | `handlers/swarm.js` |
| Engine plugins | shipped | **off** (Docker only; never on Podman) | `handlers/plugins.js` |
| Podman engine | shipped | auto | `services/docker.js`, `engine-features.js` |
| Suggestions / smart defaults | shipped | on | `handlers/suggestions.js` |
| Binary streams | shipped | on | `rpc/binary-stream.js` |
| Multi-peer fleet UI | shipped | on | `client/manager.js` |
+13 -2
View File
@@ -70,7 +70,7 @@ Symptoms of a missing group: empty container lists, start failures, “permissio
### Podman (no Docker)
If no Docker socket is found, the server automatically falls back to a **Podman** socket (Docker-compatible API via dockerode):
If no Docker socket is found, the server automatically falls back to a **Podman** socket (Docker-compatible API via dockerode). The desktop client **hides Swarm** (and Docker plugins) when the engine is Podman.
| Preference | Behavior |
|------------|----------|
@@ -89,7 +89,18 @@ 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).
| Surface on Podman | Status |
|-------------------|--------|
| Containers, images, volumes, networks, logs, terminals, stats, events | Supported |
| Deploy / add container / always-pull / duplicate | Supported |
| Stacks + compose CLI (`podman compose` / `podman-compose`) | Supported |
| Registry vault + browser, image update checks | Supported |
| Holesail tunnels | Supported |
| Host CLI (`docker` commands rewritten to `podman`) | Supported |
| **Docker Swarm** (services/nodes/tasks/secrets/configs) | **Not available** — UI hidden |
| **Docker Engine plugins** | **Not available** — remains off |
`ping` / metrics expose a full `features` matrix (`engine`, `swarm`, `plugins`, `unsupported`, …) so clients can gate the UI.
## Manual install from source
+1 -1
View File
@@ -161,7 +161,7 @@
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="swarm" title="Swarm">
<a href="#" class="nav-link" data-view="swarm" data-feature="swarm" title="Swarm">
<i class="fas fa-project-diagram"></i>
<span class="nav-label">Swarm</span>
</a>
+41 -11
View File
@@ -1,5 +1,5 @@
/**
* Host Docker CLI terminal — line-buffered input, xterm FitAddon, binary-safe I/O.
* Host container CLI terminal (Docker or Podman) — line-buffered input, xterm FitAddon.
*/
import { manager, Methods } from '../client/manager.js'
import {
@@ -11,6 +11,26 @@ import {
safeFit,
} from './xtermUtils.js'
/**
* Preferred host CLI label/binary for a peer connection.
* @param {import('../client/connection.js').PearDockConnection|null|undefined} conn
* @returns {'docker'|'podman'}
*/
function preferredCliBinary(conn) {
const eng =
conn?.features?.engine ||
conn?.dockerHealth?.engine ||
(typeof window !== 'undefined' ? document.body?.dataset?.engine : null)
return eng === 'podman' ? 'podman' : 'docker'
}
/**
* @param {import('../client/connection.js').PearDockConnection|null|undefined} conn
*/
function cliLabel(conn) {
return preferredCliBinary(conn) === 'podman' ? 'Podman CLI' : 'Docker CLI'
}
const Terminal = getTerminalCtor()
const FitAddon = getFitAddonCtor()
@@ -122,22 +142,30 @@ const BLOCKED_PATTERNS = [
const MAX_COMMAND_LENGTH = 500
function validateDockerCommand(command) {
/**
* @param {string} command
* @param {'docker'|'podman'} [preferred='docker']
*/
function validateDockerCommand(command, preferred = 'docker') {
if (!command || command.length > MAX_COMMAND_LENGTH) return null
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(command)) return null
}
const normalized = command.trim().toLowerCase()
let rest = normalized.startsWith('docker ') ? normalized.slice(7).trim() : normalized
let rest = normalized
if (normalized.startsWith('docker ')) rest = normalized.slice(7).trim()
else if (normalized.startsWith('podman ')) rest = normalized.slice(7).trim()
const parts = rest.split(/\s+/).filter(Boolean)
const base = parts[0] || ''
if (BLOCKED_COMMANDS.has(base)) return null
// docker system df / docker container ls etc.
const hasPrefix = /^(docker|podman)\s/i.test(command.trim())
const withPrefix = (cmd) => (hasPrefix ? cmd : `${preferred} ${cmd}`)
// docker/podman system df / container ls etc.
if (base === 'system' && parts[1] && ['df', 'info', 'events'].includes(parts[1])) {
return command.startsWith('docker ') ? command : `docker ${command}`
return withPrefix(command.trim())
}
if (ALLOWED_DOCKER_COMMANDS.has(base) || base === 'help' || base === '--help' || base === '-h') {
return command.startsWith('docker ') ? command : `docker ${command}`
return withPrefix(command.trim())
}
return null
}
@@ -149,7 +177,7 @@ function validateDockerCommand(command) {
function startDockerTerminal(connectionId, peer) {
const conn = peer || manager.active
if (!conn?.connected) {
console.error('[ERROR] No active peer for Docker CLI terminal.')
console.error(`[ERROR] No active peer for ${cliLabel(conn)} terminal.`)
return
}
@@ -164,9 +192,11 @@ function startDockerTerminal(connectionId, peer) {
const dockerTerminalTitle = document.getElementById('docker-terminal-title')
const dockerTerminalModal = document.getElementById('dockerTerminalModal')
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn')
const preferred = preferredCliBinary(conn)
const label = cliLabel(conn)
if (!dockerTerminalContainer || !dockerTerminalModal) {
console.error('[ERROR] Missing Docker CLI terminal DOM')
console.error(`[ERROR] Missing ${label} terminal DOM`)
return
}
@@ -222,7 +252,7 @@ function startDockerTerminal(connectionId, peer) {
xterm.write('\x1b[32m$\x1b[0m ')
return
}
const fullCommand = validateDockerCommand(line)
const fullCommand = validateDockerCommand(line, preferred)
if (!fullCommand) {
xterm.write('\x1b[31m[ERROR]\x1b[0m Invalid or blocked command.\r\n')
xterm.write('Allowed: ps, images, logs, inspect, stats, system df, …\r\n')
@@ -282,7 +312,7 @@ function startDockerTerminal(connectionId, peer) {
}
if (dockerTerminalTitle) {
dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`
dockerTerminalTitle.textContent = `${label} Terminal: ${connectionId}`
}
const modalInstance = bootstrap.Modal.getOrCreateInstance(dockerTerminalModal)
@@ -291,7 +321,7 @@ function startDockerTerminal(connectionId, peer) {
() => {
fitController.fitNow()
xterm.focus()
xterm.write('\x1b[1mDocker CLI\x1b[0m (read-only allow-list)\r\n\x1b[32m$\x1b[0m ')
xterm.write(`\x1b[1m${label}\x1b[0m (read-only allow-list)\r\n\x1b[32m$\x1b[0m `)
},
{ once: true }
)
+59 -12
View File
@@ -1,11 +1,15 @@
/**
* Restricted Docker CLI command execution.
* Set PEARDOCK_UNRESTRICTED_CLI=1 to allow broader docker CLI (still admin-gated via roles).
* Restricted container CLI execution (docker or podman, matching the engine).
* Set PEARDOCK_UNRESTRICTED_CLI=1 to allow broader CLI (still admin-gated via roles).
*/
import { spawn } from 'child_process'
import * as validation from '../utils/validation.js'
import { Pushes } from '../../shared/protocol.js'
import { Roles } from '../../shared/protocol.js'
import {
containerEngineKind,
dockerSocketPath,
} from '../services/docker.js'
const UNRESTRICTED =
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
@@ -13,16 +17,55 @@ const UNRESTRICTED =
const DANGEROUS_PATTERNS = ['exec', 'run', 'rm -f', 'prune', 'system prune', 'swarm', 'plugin']
/**
* Preferred CLI binary for this server engine.
* @returns {'docker'|'podman'}
*/
export function preferredCliBinary() {
return containerEngineKind === 'podman' ? 'podman' : 'docker'
}
/**
* Normalize user command to spawn argv.
* Accepts `docker …`, `podman …`, or bare args (uses preferred engine CLI).
* @param {string} commandStr
* @returns {{ executable: string, cmdArgs: string[] }}
*/
export function parseContainerCliCommand(commandStr) {
const raw = String(commandStr || '').trim()
if (!raw) throw new Error('Command required')
const parts = raw.split(/\s+/).filter(Boolean)
let executable = parts[0]
let cmdArgs = parts.slice(1)
if (executable !== 'docker' && executable !== 'podman') {
// bare args — prefix preferred engine
executable = preferredCliBinary()
cmdArgs = parts
}
// Rewrite to match the live engine so UI "Docker CLI" works on Podman hosts
if (containerEngineKind === 'podman' && executable === 'docker') {
executable = 'podman'
}
if (containerEngineKind !== 'podman' && executable === 'podman') {
executable = 'docker'
}
return { executable, cmdArgs }
}
export function registerDockerCliHandlers(session) {
session.respond('dockerCommand', async (args) => {
const commandStr = validation.sanitizeString(args.data || args.command, 500)
if (!commandStr || !commandStr.startsWith('docker ')) {
if (!commandStr) {
throw new Error('Invalid command format')
}
if (UNRESTRICTED) {
if (session.role !== Roles.admin) {
throw new Error('Unrestricted Docker CLI requires admin role')
throw new Error('Unrestricted container CLI requires admin role')
}
} else if (DANGEROUS_PATTERNS.some((pattern) => commandStr.includes(pattern))) {
throw new Error(
@@ -30,17 +73,22 @@ export function registerDockerCliHandlers(session) {
)
}
const parts = commandStr.split(' ')
const executable = parts[0]
const cmdArgs = parts.slice(1)
if (executable !== 'docker') {
throw new Error('Only docker commands are allowed')
if (containerEngineKind === 'podman' && /\b(swarm|plugin)\b/i.test(commandStr)) {
throw new Error('Swarm and Docker plugin commands are not available on Podman')
}
const { executable, cmdArgs } = parseContainerCliCommand(commandStr)
const connectionId = args.connectionId
const unix = `unix://${dockerSocketPath}`
return new Promise((resolve, reject) => {
const child = spawn(executable, cmdArgs)
const child = spawn(executable, cmdArgs, {
env: {
...process.env,
DOCKER_HOST: unix,
CONTAINER_HOST: unix,
},
})
let settled = false
child.stdout.on('data', (data) => {
@@ -69,7 +117,7 @@ export function registerDockerCliHandlers(session) {
})
if (!settled) {
settled = true
resolve({ success: true, exitCode: code })
resolve({ success: true, exitCode: code, executable })
}
})
@@ -83,7 +131,6 @@ export function registerDockerCliHandlers(session) {
})
session.respond('dockerTerminalResize', async () => {
// No PTY for docker CLI yet — acknowledge for UI
return { success: true }
})
}
+11 -4
View File
@@ -1,19 +1,26 @@
/**
* Docker plugin handlers.
* Gated by ENABLE_PLUGINS=1 (or true).
* Gated by ENABLE_PLUGINS=1 (or true). Always unavailable on Podman.
*/
import { docker } from '../services/docker.js'
import { docker, containerEngineKind } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js'
import { isPluginsFeatureAvailable } from '../services/engine-features.js'
export function isPluginsEnabled() {
return process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true'
return isPluginsFeatureAvailable()
}
function assertPlugins() {
if (!isPluginsEnabled()) {
const err = new Error('Plugin APIs disabled. Set ENABLE_PLUGINS=1 to enable.')
const err = new Error(
containerEngineKind === 'podman'
? 'Docker Engine plugins are not available on Podman.'
: 'Plugin APIs disabled. Set ENABLE_PLUGINS=1 to enable (Docker only).'
)
err.code = 'FEATURE_DISABLED'
err.feature = 'plugins'
err.engine = containerEngineKind
throw err
}
}
+12 -8
View File
@@ -1,27 +1,31 @@
/**
* Swarm / Services / Nodes / Tasks / Secrets / Configs handlers.
* On by default. Opt out with ENABLE_SWARM=0 / false / off / no.
* On by default for Docker. Opt out with ENABLE_SWARM=0.
* Always unavailable on Podman (no Swarm mode).
*/
import { docker } from '../services/docker.js'
import { docker, containerEngineKind } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js'
import { isSwarmFeatureAvailable } from '../services/engine-features.js'
/**
* Swarm APIs are on by default.
* Opt out with ENABLE_SWARM=0 / false / off / no.
* Whether Swarm RPC is available for this process.
* Podman always returns false.
*/
export function isSwarmEnabled() {
const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
return true
return isSwarmFeatureAvailable()
}
function assertSwarm() {
if (!isSwarmEnabled()) {
const err = new Error(
'Swarm APIs disabled. Remove ENABLE_SWARM=0 to re-enable (on by default).'
containerEngineKind === 'podman'
? 'Swarm is not available on Podman. Use Docker Engine in Swarm mode, or manage services as compose stacks / containers.'
: 'Swarm APIs disabled. Remove ENABLE_SWARM=0 to re-enable (on by default for Docker).'
)
err.code = 'FEATURE_DISABLED'
err.feature = 'swarm'
err.engine = containerEngineKind
throw err
}
}
+16 -11
View File
@@ -12,8 +12,7 @@ import * as validation from '../utils/validation.js'
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import { getMetricsSnapshot } from '../services/metrics.js'
import { peers } from '../core/peer-registry.js'
import { isSwarmEnabled } from './swarm.js'
import { isPluginsEnabled } from './plugins.js'
import { getEngineFeatures } from '../services/engine-features.js'
import {
listSchedules,
upsertSchedule,
@@ -29,15 +28,19 @@ export function registerSystemHandlers(session) {
let dockerOk = false
let apiVersion = null
let osType = null
let platform = null
let error = null
let version = null
try {
const version = await docker.version()
version = await docker.version()
dockerOk = true
apiVersion = version.ApiVersion || version.apiVersion || null
osType = version.Os || version.os || null
platform = version.Platform?.Name || version.Platform || null
} catch (err) {
error = err.message
}
const features = getEngineFeatures({ version })
return {
success: true,
pong: Date.now(),
@@ -48,21 +51,23 @@ export function registerSystemHandlers(session) {
ok: dockerOk,
apiVersion,
os: osType,
platform,
error,
/** docker | podman | unknown — socket used by this server process */
engine: containerEngineKind,
socketPath: dockerSocketPath,
},
features: {
swarm: isSwarmEnabled(),
plugins: isPluginsEnabled(),
engine: features.engine,
socketPath: features.socketPath,
label: features.engineLabel,
},
features,
}
})
session.respond('getSystemInfo', async () => {
const [info, version] = await Promise.all([docker.info(), docker.version()])
return { type: 'systemInfo', data: { info, version } }
const features = getEngineFeatures({ version })
return {
type: 'systemInfo',
data: { info, version, engine: features },
}
})
session.respond('getSystemDf', async () => {
+1
View File
@@ -355,6 +355,7 @@ export function registerHandshake(session) {
schemaValidation: true,
hmacAuth: true,
connectionInvites: true,
// Engine matrix filled in by ping; handshake stays light
},
}
})
+1 -1
View File
@@ -151,7 +151,7 @@ logger.banner([
: `on but package missing${hs?.loadError ? ` · ${String(hs.loadError).slice(0, 80)}` : ''}`
}`,
`Auth: HMAC capabilities · default role viewer${isInsecureOpenAdmin() ? ' · INSECURE OPEN ADMIN' : ''}`,
`Swarm RPC: ${isSwarmEnabled() ? 'on' : 'off'} · Plugins: ${isPluginsEnabled() ? 'on' : 'off'}`,
`Swarm RPC: ${isSwarmEnabled() ? 'on' : containerEngineKind === 'podman' ? 'n/a (Podman)' : 'off'} · Plugins: ${isPluginsEnabled() ? 'on' : containerEngineKind === 'podman' ? 'n/a (Podman)' : 'off'}`,
`Log: ${logger.format} · level ${['error', 'warn', 'info', 'debug'][logger.level] || 'info'}`,
`Boot ${bootMs}ms · pid ${process.pid} · Node ${process.version}`,
])
+158
View File
@@ -0,0 +1,158 @@
/**
* Runtime feature matrix for the connected container engine.
*
* Docker: full surface (Swarm/plugins gated by env).
* Podman: Docker-compatible Engine API for containers/images/networks/volumes/
* logs/exec/build/compose — but no Swarm and no Docker Engine plugins.
*/
import {
containerEngineKind,
dockerSocketPath,
engineDisplayName,
classifySocketPath,
} from './docker.js'
import { isHolesailEnabled } from './holesail-tunnels.js'
/**
* @typedef {'docker'|'podman'|'unknown'} EngineKind
* @typedef {{
* engine: EngineKind,
* engineLabel: string,
* socketPath: string,
* swarm: boolean,
* plugins: boolean,
* compose: boolean,
* stacks: boolean,
* containers: boolean,
* images: boolean,
* networks: boolean,
* volumes: boolean,
* logs: boolean,
* terminals: boolean,
* build: boolean,
* systemPrune: boolean,
* events: boolean,
* registry: boolean,
* holesail: boolean,
* dockerCli: boolean,
* podmanCli: boolean,
* unsupported: string[],
* }} EngineFeatures
*/
/**
* Env-only Swarm switch (ignores engine).
* @returns {boolean}
*/
export function envSwarmEnabled() {
const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
return true
}
/**
* Env-only plugins switch (ignores engine).
* @returns {boolean}
*/
export function envPluginsEnabled() {
return process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true'
}
/**
* Infer engine kind from Engine `/version` JSON when socket path is ambiguous.
* @param {object|null|undefined} version
* @param {EngineKind} [fallback]
* @returns {EngineKind}
*/
export function engineKindFromVersion(version, fallback = containerEngineKind) {
if (!version || typeof version !== 'object') return fallback
const blob = JSON.stringify(version).toLowerCase()
if (blob.includes('podman') || blob.includes('libpod') || blob.includes('buildah')) {
return 'podman'
}
if (blob.includes('docker') || blob.includes('moby')) {
return 'docker'
}
const platform = String(version.Platform?.Name || version.Platform || '').toLowerCase()
if (platform.includes('podman')) return 'podman'
const components = version.Components
if (Array.isArray(components)) {
for (const c of components) {
const name = String(c?.Name || c?.name || '').toLowerCase()
if (name.includes('podman')) return 'podman'
if (name.includes('engine') && name.includes('docker')) return 'docker'
}
}
return fallback
}
/**
* Effective engine kind: refined from version when available, else socket classification.
* @param {object|null|undefined} [version]
* @returns {EngineKind}
*/
export function effectiveEngineKind(version = null) {
if (version) return engineKindFromVersion(version, containerEngineKind)
// Socket path classification is the boot-time truth
const fromPath = classifySocketPath(dockerSocketPath)
if (fromPath !== 'unknown') return fromPath
return containerEngineKind
}
/**
* Full feature flags for clients / banner / metrics.
* @param {{ version?: object|null }} [opts]
* @returns {EngineFeatures}
*/
export function getEngineFeatures(opts = {}) {
const engine = effectiveEngineKind(opts.version)
const isPodman = engine === 'podman'
const swarm = !isPodman && envSwarmEnabled()
const plugins = !isPodman && envPluginsEnabled()
/** @type {string[]} */
const unsupported = []
if (isPodman) {
unsupported.push('swarm', 'plugins')
}
return {
engine,
engineLabel: engineDisplayName(engine),
socketPath: dockerSocketPath,
// Always-on Engine API surface (Docker + Podman)
containers: true,
images: true,
networks: true,
volumes: true,
logs: true,
terminals: true,
build: true,
systemPrune: true,
events: true,
compose: true,
stacks: true,
registry: true,
holesail: isHolesailEnabled(),
// Engine-specific
swarm,
plugins,
dockerCli: !isPodman,
podmanCli: isPodman,
unsupported,
}
}
/**
* Whether Swarm RPC should accept requests.
* Podman never supports Swarm regardless of ENABLE_SWARM.
*/
export function isSwarmFeatureAvailable() {
return getEngineFeatures().swarm
}
/**
* Whether Docker Engine plugin RPC should accept requests.
*/
export function isPluginsFeatureAvailable() {
return getEngineFeatures().plugins
}
+3 -9
View File
@@ -1,6 +1,8 @@
/**
* Lightweight process / RPC metrics for production observability.
*/
import { getEngineFeatures } from './engine-features.js'
const startedAt = Date.now()
const counters = {
@@ -87,15 +89,7 @@ export function getMetricsSnapshot(extra = {}) {
},
pushes: counters.pushes,
features: {
swarm: (() => {
const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
})(),
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
holesail: (() => {
const v = String(process.env.ENABLE_HOLESAIL ?? '1').trim().toLowerCase()
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
})(),
...getEngineFeatures(),
peerAllowlist:
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
+73 -23
View File
@@ -10,19 +10,27 @@ import logger from './logger.js'
import {
startContainerNoBody,
containerEngineKind,
dockerSocketPath,
} 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[] }}
* Podman: `podman compose …` (falls back to docker / podman-compose)
* Child inherits DOCKER_HOST/CONTAINER_HOST pointing at our resolved socket.
* @param {string} [kind]
* @returns {{ cmd: string, prefix: string[], envExtra: Record<string, string> }}
*/
export function resolveComposeCli(kind = containerEngineKind) {
if (kind === 'podman') {
return { cmd: 'podman', prefix: ['compose'] }
const unix = `unix://${dockerSocketPath}`
const envExtra = {
DOCKER_HOST: unix,
CONTAINER_HOST: unix,
}
return { cmd: 'docker', prefix: ['compose'] }
if (kind === 'podman') {
return { cmd: 'podman', prefix: ['compose'], envExtra }
}
return { cmd: 'docker', prefix: ['compose'], envExtra }
}
/**
@@ -209,25 +217,42 @@ export function runComposeCli(args, {
if (p) profileArgs.push('--profile', String(p))
}
const { cmd, prefix } = resolveComposeCli()
const { cmd, prefix, envExtra } = resolveComposeCli()
const fullArgs = [...prefix, ...fileArgs, '-p', projectName, ...profileArgs, ...args]
/** @type {Set<string>} */
const tried = new Set()
/** Alternate CLIs if preferred missing (order: primary → peer → podman-compose) */
const fallbacks =
cmd === 'podman' ? ['docker', 'podman-compose'] : ['podman', 'podman-compose']
const trySpawn = (executable) => {
/**
* @param {string} executable
* @param {string[]} [argv]
*/
const trySpawn = (executable, argv = fullArgs) => {
if (tried.has(executable)) {
const next = fallbacks.find((f) => !tried.has(f))
if (next) {
// podman-compose is a single binary (no "compose" subcommand)
const nextArgs =
next === 'podman-compose'
? [...fileArgs, '-p', projectName, ...profileArgs, ...args]
: fullArgs
trySpawn(next, nextArgs)
return
}
cleanup(dir)
reject(
new Error(
`No compose CLI available (tried ${[...tried].join(', ')}). Install docker or podman compose.`
`No compose CLI available (tried ${[...tried].join(', ')}). Install docker compose or podman compose.`
)
)
return
}
tried.add(executable)
const child = spawn(executable, fullArgs, {
env: { ...process.env, ...env },
const child = spawn(executable, argv, {
env: { ...process.env, ...envExtra, ...env },
cwd: dir,
})
@@ -246,11 +271,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)
const next = fallbacks.find((f) => !tried.has(f))
if (next) {
const nextArgs =
next === 'podman-compose'
? [...fileArgs, '-p', projectName, ...profileArgs, ...args]
: fullArgs
trySpawn(next, nextArgs)
return
}
}
@@ -264,7 +292,7 @@ export function runComposeCli(args, {
})
}
trySpawn(cmd)
trySpawn(cmd, fullArgs)
})
}
@@ -366,7 +394,7 @@ export async function deployComposeStack(docker, composeContent, stackName, opti
async function listProjectContainerIds(docker, stackName) {
const containers = await docker.listContainers({ all: true })
return containers
.filter((c) => c.Labels?.['com.docker.compose.project'] === stackName)
.filter((c) => composeLabelsFrom(c.Labels || {}).project === stackName)
.map((c) => c.Id)
}
@@ -577,6 +605,29 @@ function parsePortMapping(portStr) {
/**
* @param {import('dockerode')} docker
*/
/**
* Compose project/service labels — Docker Compose + Podman compose variants.
* @param {Record<string, string>} labels
* @returns {{ project: string|null, service: string|null }}
*/
export function composeLabelsFrom(labels = {}) {
let project =
labels['com.docker.compose.project'] ||
labels['io.podman.compose.project'] ||
labels['io.compose.project'] ||
null
if (!project && labels['com.docker.compose.project.working_dir']) {
const wd = String(labels['com.docker.compose.project.working_dir'])
project = wd.split(/[/\\]/).filter(Boolean).pop() || null
}
const service =
labels['com.docker.compose.service'] ||
labels['io.podman.compose.service'] ||
labels['io.compose.service'] ||
null
return { project, service }
}
export async function listStacks(docker) {
try {
const containers = await docker.listContainers({ all: true })
@@ -584,8 +635,7 @@ export async function listStacks(docker) {
for (const container of containers) {
const labels = container.Labels || {}
const project = labels['com.docker.compose.project']
const service = labels['com.docker.compose.service']
const { project, service } = composeLabelsFrom(labels)
if (!project) continue
if (!stacks[project]) {
@@ -614,10 +664,10 @@ export async function listStacks(docker) {
}
/** Built-in Docker networks that must never be removed. */
const PREDEFINED_NETWORKS = new Set(['bridge', 'host', 'none'])
const PREDEFINED_NETWORKS = new Set(['bridge', 'host', 'none', 'podman'])
/**
* Networks owned by a compose project (created via `docker compose up`).
* Networks owned by a compose project (Docker or Podman compose labels).
* @param {import('dockerode')} docker
* @param {string} stackName
*/
@@ -626,7 +676,8 @@ async function listProjectNetworks(docker, stackName) {
return networks.filter((n) => {
if (!n?.Name || PREDEFINED_NETWORKS.has(n.Name)) return false
const labels = n.Labels || {}
return labels['com.docker.compose.project'] === stackName
const { project } = composeLabelsFrom(labels)
return project === stackName || labels['com.docker.compose.project'] === stackName
})
}
@@ -668,8 +719,7 @@ export async function removeComposeStack(docker, stackName) {
// CLI `compose down` needs a compose file; we use label-based dockerode cleanup instead.
const containers = await docker.listContainers({ all: true })
const stackContainers = containers.filter((c) => {
const labels = c.Labels || {}
return labels['com.docker.compose.project'] === stackName
return composeLabelsFrom(c.Labels || {}).project === stackName
})
const networkCandidates = await listProjectNetworks(docker, stackName)
+2 -1
View File
@@ -2,6 +2,7 @@
* Smart defaults / auto-populate helpers for dynamic UI.
*/
import { docker } from '../services/docker.js'
import { composeLabelsFrom } from './composeManager.js'
import logger from './logger.js'
/**
@@ -169,7 +170,7 @@ export async function suggestResourceName(kind, base) {
} else if (kind === 'stack') {
const list = await docker.listContainers({ all: true })
for (const c of list) {
const p = c.Labels?.['com.docker.compose.project']
const p = composeLabelsFrom(c.Labels || {}).project
if (p) taken.add(String(p).toLowerCase())
}
}
+7 -2
View File
@@ -108,8 +108,13 @@ test('resolveContainerSocket CONTAINER_HOST wins when no DOCKER_HOST', (t) => {
})
test('resolveComposeCli picks podman for podman engine', (t) => {
t.alike(resolveComposeCli('podman'), { cmd: 'podman', prefix: ['compose'] })
t.alike(resolveComposeCli('docker'), { cmd: 'docker', prefix: ['compose'] })
const pod = resolveComposeCli('podman')
t.is(pod.cmd, 'podman')
t.alike(pod.prefix, ['compose'])
t.ok(pod.envExtra?.DOCKER_HOST?.startsWith('unix://'))
const dock = resolveComposeCli('docker')
t.is(dock.cmd, 'docker')
t.alike(dock.prefix, ['compose'])
t.is(engineDisplayName('podman'), 'Podman')
t.is(engineDisplayName('docker'), 'Docker')
})
+88
View File
@@ -0,0 +1,88 @@
import test from 'brittle'
import {
engineKindFromVersion,
envSwarmEnabled,
envPluginsEnabled,
getEngineFeatures,
} from '../server/services/engine-features.js'
import {
parseContainerCliCommand,
preferredCliBinary,
} from '../server/handlers/docker-cli.js'
import { composeLabelsFrom } from '../server/utils/composeManager.js'
test('engineKindFromVersion detects podman from version blob', (t) => {
t.is(
engineKindFromVersion({ Platform: { Name: 'linux/amd64/podman-5.0' } }, 'docker'),
'podman'
)
t.is(
engineKindFromVersion(
{ Components: [{ Name: 'Podman Engine', Version: '5.0' }] },
'unknown'
),
'podman'
)
t.is(engineKindFromVersion({ Platform: { Name: 'Docker Engine - Community' } }, 'unknown'), 'docker')
})
test('getEngineFeatures disables swarm/plugins for podman kind', (t) => {
// Force via version refinement even if socket was classified docker
const f = getEngineFeatures({
version: { Platform: { Name: 'podman' }, ApiVersion: '1.41' },
})
t.is(f.engine, 'podman')
t.is(f.swarm, false)
t.is(f.plugins, false)
t.ok(f.containers)
t.ok(f.compose)
t.ok(f.stacks)
t.ok(f.unsupported.includes('swarm'))
t.ok(f.unsupported.includes('plugins'))
})
test('envSwarmEnabled respects ENABLE_SWARM=0', (t) => {
const prev = process.env.ENABLE_SWARM
process.env.ENABLE_SWARM = '0'
t.is(envSwarmEnabled(), false)
process.env.ENABLE_SWARM = '1'
t.is(envSwarmEnabled(), true)
if (prev === undefined) delete process.env.ENABLE_SWARM
else process.env.ENABLE_SWARM = prev
})
test('envPluginsEnabled defaults off', (t) => {
const prev = process.env.ENABLE_PLUGINS
delete process.env.ENABLE_PLUGINS
t.is(envPluginsEnabled(), false)
if (prev !== undefined) process.env.ENABLE_PLUGINS = prev
})
test('composeLabelsFrom accepts podman labels', (t) => {
t.alike(
composeLabelsFrom({
'io.podman.compose.project': 'web',
'io.podman.compose.service': 'api',
}),
{ project: 'web', service: 'api' }
)
t.alike(
composeLabelsFrom({
'com.docker.compose.project': 'web',
'com.docker.compose.service': 'api',
}),
{ project: 'web', service: 'api' }
)
})
test('parseContainerCliCommand rewrites for preferred engine', (t) => {
// preferredCliBinary matches live engine of this process
const pref = preferredCliBinary()
t.ok(pref === 'docker' || pref === 'podman')
const parsed = parseContainerCliCommand('docker ps -a')
t.ok(parsed.executable === 'docker' || parsed.executable === 'podman')
t.alike(parsed.cmdArgs, ['ps', '-a'])
const bare = parseContainerCliCommand('ps -a')
t.is(bare.executable, pref)
t.alike(bare.cmdArgs, ['ps', '-a'])
})
+7
View File
@@ -1492,6 +1492,13 @@ body {
}
}
/* Engine feature gating — Swarm/plugins hidden on Podman */
.engine-feature-hidden,
body.feature-swarm-off [data-view="swarm"],
body.feature-plugins-off [data-feature="plugins"] {
display: none !important;
}
.page-header {
display: flex;
align-items: flex-start;
+17 -6
View File
@@ -1306,16 +1306,19 @@ export async function loadSwarmView(opts = {}) {
const code = err?.code || ''
const msg = err?.message || String(err)
if (banner) {
if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) {
if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled|Podman/i.test(msg)) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML =
'Swarm APIs are <strong>off</strong>. Remove <code>ENABLE_SWARM=0</code> (on by default).'
const podman =
/podman/i.test(msg) || manager.active?.features?.engine === 'podman'
banner.innerHTML = podman
? 'Swarm is <strong>not available on Podman</strong>. Use <strong>Stacks</strong> or containers instead. Swarm requires Docker Engine.'
: 'Swarm APIs are <strong>off</strong>. Remove <code>ENABLE_SWARM=0</code> (on by default for Docker).'
} else {
banner.className = 'alert alert-danger small mb-3'
banner.textContent = msg
}
}
if (!silent) presentError(err, 'swarmInspect', { showAlert, silent: /FEATURE_DISABLED|disabled/i.test(msg) })
if (!silent) presentError(err, 'swarmInspect', { showAlert, silent: /FEATURE_DISABLED|disabled|Podman/i.test(msg) })
}
}
@@ -1683,7 +1686,10 @@ export function openPalette(navigateToView) {
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' },
// Swarm only when server features.swarm is true (hidden on Podman)
...(manager.active?.features?.swarm === false
? []
: [{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' }]),
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
{
@@ -1977,13 +1983,18 @@ export function initOpsApp({ navigateToView, sendCommand }) {
})
manager.on('connect', () => {
// Role may arrive after handshake
// Role / engine features may arrive after handshake + ping
setTimeout(() => {
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
if (typeof window.applyEngineUI === 'function') window.applyEngineUI()
}, 50)
})
manager.on('active', () => {
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
if (typeof window.applyEngineUI === 'function') window.applyEngineUI()
})
manager.on('health', () => {
if (typeof window.applyEngineUI === 'function') window.applyEngineUI()
})
// Allow clicking the job panel header area to dismiss early
+6
View File
@@ -317,6 +317,12 @@ export function initTrackGUx(ctx = {}) {
const go = (dest) => {
if (!dest) return
// Swarm go-chord is a no-op when the engine does not support Swarm (Podman)
if (dest === 'swarm') {
const swarmOn =
typeof window !== 'undefined' ? window.manager?.active?.features?.swarm : undefined
if (swarmOn === false) return
}
// Compound targets: "settings:peers" → settings view + peers subtab
if (String(dest).includes(':')) {
const [view, tab] = String(dest).split(':')