This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(':')
|
||||
|
||||
Reference in New Issue
Block a user