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_PEER_ALLOWLIST` | `1` to require registered peers |
|
||||||
| `PEARDOCK_AUDIT` | `1` to append privileged actions to audit log |
|
| `PEARDOCK_AUDIT` | `1` to append privileged actions to audit log |
|
||||||
| `PEARDOCK_BROWSE_ROOTS` | Allowed host paths for file browse |
|
| `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_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) |
|
| `PEARDOCK_ENGINE` | Prefer `docker` or `podman` (default: Docker socket first, then Podman) |
|
||||||
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit engine socket (`unix://…`); wins over auto-detect |
|
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit engine socket (`unix://…`); wins over auto-detect |
|
||||||
| `PEARDOCK_MAX_TUNNELS` | `20` |
|
| `PEARDOCK_MAX_TUNNELS` | `20` |
|
||||||
|
|||||||
@@ -1216,6 +1216,14 @@ async function loadFleetView() {
|
|||||||
const active = manager.active === conn;
|
const active = manager.active === conn;
|
||||||
const lat = conn.latency != null ? `${conn.latency} ms` : '—';
|
const lat = conn.latency != null ? `${conn.latency} ms` : '—';
|
||||||
const dockerOk = conn.dockerHealth?.ok;
|
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 role = conn.role || '—';
|
||||||
const env = envMap[id] || '';
|
const env = envMap[id] || '';
|
||||||
return `
|
return `
|
||||||
@@ -1229,7 +1237,7 @@ async function loadFleetView() {
|
|||||||
<p class="small text-muted mb-2 font-monospace">${escapeHtmlLite((conn.publicKeyHex || '').slice(0, 24))}…</p>
|
<p class="small text-muted mb-2 font-monospace">${escapeHtmlLite((conn.publicKeyHex || '').slice(0, 24))}…</p>
|
||||||
<ul class="list-unstyled small mb-2">
|
<ul class="list-unstyled small mb-2">
|
||||||
<li><i class="fas fa-gauge-high me-1"></i> Latency: ${lat}</li>
|
<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-user-shield me-1"></i> Role: ${escapeHtmlLite(role)}</li>
|
||||||
<li><i class="fas fa-heart me-1"></i> ${escapeHtmlLite(conn.healthStatus || 'unknown')}</li>
|
<li><i class="fas fa-heart me-1"></i> ${escapeHtmlLite(conn.healthStatus || 'unknown')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1689,8 +1697,66 @@ function applyRoleUI() {
|
|||||||
el.classList.toggle('role-hidden', !invitesAllowed);
|
el.classList.toggle('role-hidden', !invitesAllowed);
|
||||||
el.setAttribute('aria-hidden', invitesAllowed ? 'false' : 'true');
|
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() {
|
function showFirstConnectChecklist() {
|
||||||
try {
|
try {
|
||||||
if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') {
|
if (typeof window.peardockOps?.shouldShowFirstConnectTip === 'function') {
|
||||||
@@ -1926,16 +1992,22 @@ function loadDashboard() {
|
|||||||
const dockerInfoEl = document.getElementById('docker-info-content');
|
const dockerInfoEl = document.getElementById('docker-info-content');
|
||||||
if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) {
|
if (dockerInfoEl && snap.engine && !dockerInfoEl.dataset.filled) {
|
||||||
const eng = snap.engine;
|
const eng = snap.engine;
|
||||||
|
const features = manager.active?.features;
|
||||||
|
const swarmVal =
|
||||||
|
features?.swarm === false
|
||||||
|
? 'n/a'
|
||||||
|
: eng.swarm || 'inactive';
|
||||||
const m = (label, value) =>
|
const m = (label, value) =>
|
||||||
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
|
`<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('Host', eng.name || 'host'),
|
||||||
m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`),
|
m('OS', `${eng.operatingSystem || '—'} · ${eng.architecture || ''}`),
|
||||||
m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'),
|
m('API', eng.version?.ApiVersion || eng.version?.apiVersion || '—'),
|
||||||
m('Swarm', eng.swarm || 'inactive'),
|
m('Engine', features?.engineLabel || features?.engine || '—'),
|
||||||
m('CPUs', eng.ncpu ?? '—'),
|
];
|
||||||
m('Volumes', snap.counts?.volumes ?? 0),
|
if (features?.swarm !== false) rows.push(m('Swarm', swarmVal));
|
||||||
].join('');
|
rows.push(m('CPUs', eng.ncpu ?? '—'), m('Volumes', snap.counts?.volumes ?? 0));
|
||||||
|
dockerInfoEl.innerHTML = rows.join('');
|
||||||
dockerInfoEl.dataset.filled = '1';
|
dockerInfoEl.dataset.filled = '1';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2060,7 +2132,7 @@ function updateHealthBadge(info, conn) {
|
|||||||
if (!info && !conn?.connected) {
|
if (!info && !conn?.connected) {
|
||||||
dot.className = 'health-dot health-dot--unknown';
|
dot.className = 'health-dot health-dot--unknown';
|
||||||
lat.textContent = '—';
|
lat.textContent = '—';
|
||||||
dock.textContent = 'Docker —';
|
dock.textContent = 'Engine —';
|
||||||
if (roleEl) roleEl.textContent = '';
|
if (roleEl) roleEl.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2071,14 +2143,18 @@ function updateHealthBadge(info, conn) {
|
|||||||
const role = conn?.role || info?.role || null;
|
const role = conn?.role || info?.role || null;
|
||||||
|
|
||||||
lat.textContent = latency != null ? `${latency} ms` : '—';
|
lat.textContent = latency != null ? `${latency} ms` : '—';
|
||||||
|
const engineLabel =
|
||||||
|
docker?.label ||
|
||||||
|
(docker?.engine === 'podman' ? 'Podman' : docker?.engine === 'docker' ? 'Docker' : 'Engine');
|
||||||
if (docker?.ok === true) {
|
if (docker?.ok === true) {
|
||||||
dock.textContent = `Docker ${docker.apiVersion || 'ok'}`;
|
dock.textContent = `${engineLabel} ${docker.apiVersion || 'ok'}`;
|
||||||
} else if (docker?.ok === false) {
|
} else if (docker?.ok === false) {
|
||||||
dock.textContent = 'Docker down';
|
dock.textContent = `${engineLabel} down`;
|
||||||
} else {
|
} else {
|
||||||
dock.textContent = 'Docker —';
|
dock.textContent = `${engineLabel} —`;
|
||||||
}
|
}
|
||||||
if (roleEl) roleEl.textContent = role ? role : '';
|
if (roleEl) roleEl.textContent = role ? role : '';
|
||||||
|
if (typeof applyEngineUI === 'function') applyEngineUI();
|
||||||
|
|
||||||
let cls = 'health-dot--unknown';
|
let cls = 'health-dot--unknown';
|
||||||
if (status === 'healthy' || (docker?.ok && status !== 'degraded')) cls = 'health-dot--ok';
|
if (status === 'healthy' || (docker?.ok && status !== 'degraded')) cls = 'health-dot--ok';
|
||||||
@@ -2971,9 +3047,11 @@ function updateSystemInfo(systemInfo) {
|
|||||||
|
|
||||||
if (dockerInfoEl && systemInfo.info) {
|
if (dockerInfoEl && systemInfo.info) {
|
||||||
const info = systemInfo.info;
|
const info = systemInfo.info;
|
||||||
|
const features = systemInfo.engine || manager.active?.features;
|
||||||
const metric = (label, value) =>
|
const metric = (label, value) =>
|
||||||
`<div class="dash-metric"><span class="dash-metric-label">${label}</span><span class="dash-metric-value">${value}</span></div>`;
|
`<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('Version', systemInfo.version?.Version || 'Unknown'),
|
||||||
metric('Containers', info.Containers || 0),
|
metric('Containers', info.Containers || 0),
|
||||||
metric('Running', info.ContainersRunning || 0),
|
metric('Running', info.ContainersRunning || 0),
|
||||||
@@ -2981,7 +3059,13 @@ function updateSystemInfo(systemInfo) {
|
|||||||
metric('Stopped', info.ContainersStopped || 0),
|
metric('Stopped', info.ContainersStopped || 0),
|
||||||
metric('Images', info.Images || 0),
|
metric('Images', info.Images || 0),
|
||||||
metric('Storage driver', info.Driver || 'Unknown'),
|
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) {
|
if (resourcesEl && systemInfo.info) {
|
||||||
@@ -4658,8 +4742,16 @@ function populateOverviewTab(config, container) {
|
|||||||
'';
|
'';
|
||||||
const labels = config.Config?.Labels || {};
|
const labels = config.Config?.Labels || {};
|
||||||
const composeProject =
|
const composeProject =
|
||||||
labels['com.docker.compose.project'] || labels['com.docker.compose.project.working_dir'] || '';
|
labels['com.docker.compose.project'] ||
|
||||||
const composeService = labels['com.docker.compose.service'] || '';
|
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 composeWorkdir = labels['com.docker.compose.project.working_dir'] || '';
|
||||||
const logDriver = config.HostConfig?.LogConfig?.Type || 'json-file';
|
const logDriver = config.HostConfig?.LogConfig?.Type || 'json-file';
|
||||||
const healthStatus = config.State?.Health?.Status || '';
|
const healthStatus = config.State?.Health?.Status || '';
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export class PearDockConnection extends EventEmitter {
|
|||||||
this.authMode = null
|
this.authMode = null
|
||||||
this.protocolVersion = null
|
this.protocolVersion = null
|
||||||
this.dockerHealth = null
|
this.dockerHealth = null
|
||||||
|
/** @type {object|null} engine feature matrix from ping */
|
||||||
|
this.features = null
|
||||||
this.clientPublicKeyHex = null
|
this.clientPublicKeyHex = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +330,21 @@ export class PearDockConnection extends EventEmitter {
|
|||||||
if (res?.role) this.role = res.role
|
if (res?.role) this.role = res.role
|
||||||
if (res?.protocolVersion != null) this.protocolVersion = res.protocolVersion
|
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) {
|
if (res?.docker && res.docker.ok === false) {
|
||||||
this.healthStatus = 'degraded'
|
this.healthStatus = 'degraded'
|
||||||
this.state = 'degraded'
|
this.state = 'degraded'
|
||||||
@@ -338,6 +355,7 @@ export class PearDockConnection extends EventEmitter {
|
|||||||
this.emit('health', {
|
this.emit('health', {
|
||||||
latency: this.latency,
|
latency: this.latency,
|
||||||
docker: this.dockerHealth,
|
docker: this.dockerHealth,
|
||||||
|
features: this.features,
|
||||||
status: this.healthStatus,
|
status: this.healthStatus,
|
||||||
})
|
})
|
||||||
return this.latency
|
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` |
|
| Peer invites / revoke / roles | shipped | on | `handlers/peers.js`, `core/peer-policy.js` |
|
||||||
| Audit log | shipped | opt-in env | `core/audit.js` |
|
| Audit log | shipped | opt-in env | `core/audit.js` |
|
||||||
| Holesail tunnels | shipped | **on** | `handlers/tunnels.js`, `services/holesail-tunnels.js` |
|
| Holesail tunnels | shipped | **on** | `handlers/tunnels.js`, `services/holesail-tunnels.js` |
|
||||||
| Docker Swarm | shipped | **on** | `handlers/swarm.js` |
|
| Docker Swarm | shipped | **on** (Docker only; hidden on Podman) | `handlers/swarm.js` |
|
||||||
| Engine plugins | shipped | **off** | `handlers/plugins.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` |
|
| Suggestions / smart defaults | shipped | on | `handlers/suggestions.js` |
|
||||||
| Binary streams | shipped | on | `rpc/binary-stream.js` |
|
| Binary streams | shipped | on | `rpc/binary-stream.js` |
|
||||||
| Multi-peer fleet UI | shipped | on | `client/manager.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)
|
### 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 |
|
| Preference | Behavior |
|
||||||
|------------|----------|
|
|------------|----------|
|
||||||
@@ -89,7 +89,18 @@ export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
|
|||||||
# or: PEARDOCK_ENGINE=podman
|
# 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
|
## Manual install from source
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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>
|
<i class="fas fa-project-diagram"></i>
|
||||||
<span class="nav-label">Swarm</span>
|
<span class="nav-label">Swarm</span>
|
||||||
</a>
|
</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 { manager, Methods } from '../client/manager.js'
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,26 @@ import {
|
|||||||
safeFit,
|
safeFit,
|
||||||
} from './xtermUtils.js'
|
} 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 Terminal = getTerminalCtor()
|
||||||
const FitAddon = getFitAddonCtor()
|
const FitAddon = getFitAddonCtor()
|
||||||
|
|
||||||
@@ -122,22 +142,30 @@ const BLOCKED_PATTERNS = [
|
|||||||
|
|
||||||
const MAX_COMMAND_LENGTH = 500
|
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
|
if (!command || command.length > MAX_COMMAND_LENGTH) return null
|
||||||
for (const pattern of BLOCKED_PATTERNS) {
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
if (pattern.test(command)) return null
|
if (pattern.test(command)) return null
|
||||||
}
|
}
|
||||||
const normalized = command.trim().toLowerCase()
|
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 parts = rest.split(/\s+/).filter(Boolean)
|
||||||
const base = parts[0] || ''
|
const base = parts[0] || ''
|
||||||
if (BLOCKED_COMMANDS.has(base)) return null
|
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])) {
|
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') {
|
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
|
return null
|
||||||
}
|
}
|
||||||
@@ -149,7 +177,7 @@ function validateDockerCommand(command) {
|
|||||||
function startDockerTerminal(connectionId, peer) {
|
function startDockerTerminal(connectionId, peer) {
|
||||||
const conn = peer || manager.active
|
const conn = peer || manager.active
|
||||||
if (!conn?.connected) {
|
if (!conn?.connected) {
|
||||||
console.error('[ERROR] No active peer for Docker CLI terminal.')
|
console.error(`[ERROR] No active peer for ${cliLabel(conn)} terminal.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +192,11 @@ function startDockerTerminal(connectionId, peer) {
|
|||||||
const dockerTerminalTitle = document.getElementById('docker-terminal-title')
|
const dockerTerminalTitle = document.getElementById('docker-terminal-title')
|
||||||
const dockerTerminalModal = document.getElementById('dockerTerminalModal')
|
const dockerTerminalModal = document.getElementById('dockerTerminalModal')
|
||||||
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn')
|
const dockerKillTerminalBtn = document.getElementById('docker-kill-terminal-btn')
|
||||||
|
const preferred = preferredCliBinary(conn)
|
||||||
|
const label = cliLabel(conn)
|
||||||
|
|
||||||
if (!dockerTerminalContainer || !dockerTerminalModal) {
|
if (!dockerTerminalContainer || !dockerTerminalModal) {
|
||||||
console.error('[ERROR] Missing Docker CLI terminal DOM')
|
console.error(`[ERROR] Missing ${label} terminal DOM`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +252,7 @@ function startDockerTerminal(connectionId, peer) {
|
|||||||
xterm.write('\x1b[32m$\x1b[0m ')
|
xterm.write('\x1b[32m$\x1b[0m ')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fullCommand = validateDockerCommand(line)
|
const fullCommand = validateDockerCommand(line, preferred)
|
||||||
if (!fullCommand) {
|
if (!fullCommand) {
|
||||||
xterm.write('\x1b[31m[ERROR]\x1b[0m Invalid or blocked command.\r\n')
|
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')
|
xterm.write('Allowed: ps, images, logs, inspect, stats, system df, …\r\n')
|
||||||
@@ -282,7 +312,7 @@ function startDockerTerminal(connectionId, peer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dockerTerminalTitle) {
|
if (dockerTerminalTitle) {
|
||||||
dockerTerminalTitle.textContent = `Docker CLI Terminal: ${connectionId}`
|
dockerTerminalTitle.textContent = `${label} Terminal: ${connectionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const modalInstance = bootstrap.Modal.getOrCreateInstance(dockerTerminalModal)
|
const modalInstance = bootstrap.Modal.getOrCreateInstance(dockerTerminalModal)
|
||||||
@@ -291,7 +321,7 @@ function startDockerTerminal(connectionId, peer) {
|
|||||||
() => {
|
() => {
|
||||||
fitController.fitNow()
|
fitController.fitNow()
|
||||||
xterm.focus()
|
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 }
|
{ once: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Restricted Docker CLI command execution.
|
* Restricted container CLI execution (docker or podman, matching the engine).
|
||||||
* Set PEARDOCK_UNRESTRICTED_CLI=1 to allow broader docker CLI (still admin-gated via roles).
|
* Set PEARDOCK_UNRESTRICTED_CLI=1 to allow broader CLI (still admin-gated via roles).
|
||||||
*/
|
*/
|
||||||
import { spawn } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import * as validation from '../utils/validation.js'
|
import * as validation from '../utils/validation.js'
|
||||||
import { Pushes } from '../../shared/protocol.js'
|
import { Pushes } from '../../shared/protocol.js'
|
||||||
import { Roles } from '../../shared/protocol.js'
|
import { Roles } from '../../shared/protocol.js'
|
||||||
|
import {
|
||||||
|
containerEngineKind,
|
||||||
|
dockerSocketPath,
|
||||||
|
} from '../services/docker.js'
|
||||||
|
|
||||||
const UNRESTRICTED =
|
const UNRESTRICTED =
|
||||||
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
|
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
|
||||||
@@ -13,16 +17,55 @@ const UNRESTRICTED =
|
|||||||
|
|
||||||
const DANGEROUS_PATTERNS = ['exec', 'run', 'rm -f', 'prune', 'system prune', 'swarm', 'plugin']
|
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) {
|
export function registerDockerCliHandlers(session) {
|
||||||
session.respond('dockerCommand', async (args) => {
|
session.respond('dockerCommand', async (args) => {
|
||||||
const commandStr = validation.sanitizeString(args.data || args.command, 500)
|
const commandStr = validation.sanitizeString(args.data || args.command, 500)
|
||||||
if (!commandStr || !commandStr.startsWith('docker ')) {
|
if (!commandStr) {
|
||||||
throw new Error('Invalid command format')
|
throw new Error('Invalid command format')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (UNRESTRICTED) {
|
if (UNRESTRICTED) {
|
||||||
if (session.role !== Roles.admin) {
|
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))) {
|
} else if (DANGEROUS_PATTERNS.some((pattern) => commandStr.includes(pattern))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -30,17 +73,22 @@ export function registerDockerCliHandlers(session) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = commandStr.split(' ')
|
if (containerEngineKind === 'podman' && /\b(swarm|plugin)\b/i.test(commandStr)) {
|
||||||
const executable = parts[0]
|
throw new Error('Swarm and Docker plugin commands are not available on Podman')
|
||||||
const cmdArgs = parts.slice(1)
|
|
||||||
if (executable !== 'docker') {
|
|
||||||
throw new Error('Only docker commands are allowed')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { executable, cmdArgs } = parseContainerCliCommand(commandStr)
|
||||||
const connectionId = args.connectionId
|
const connectionId = args.connectionId
|
||||||
|
const unix = `unix://${dockerSocketPath}`
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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
|
let settled = false
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
@@ -69,7 +117,7 @@ export function registerDockerCliHandlers(session) {
|
|||||||
})
|
})
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true
|
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 () => {
|
session.respond('dockerTerminalResize', async () => {
|
||||||
// No PTY for docker CLI yet — acknowledge for UI
|
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Docker plugin handlers.
|
* 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 * as validation from '../utils/validation.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
import { isPluginsFeatureAvailable } from '../services/engine-features.js'
|
||||||
|
|
||||||
export function isPluginsEnabled() {
|
export function isPluginsEnabled() {
|
||||||
return process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true'
|
return isPluginsFeatureAvailable()
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPlugins() {
|
function assertPlugins() {
|
||||||
if (!isPluginsEnabled()) {
|
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.code = 'FEATURE_DISABLED'
|
||||||
|
err.feature = 'plugins'
|
||||||
|
err.engine = containerEngineKind
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Swarm / Services / Nodes / Tasks / Secrets / Configs handlers.
|
* 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 * as validation from '../utils/validation.js'
|
||||||
import logger from '../utils/logger.js'
|
import logger from '../utils/logger.js'
|
||||||
|
import { isSwarmFeatureAvailable } from '../services/engine-features.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swarm APIs are on by default.
|
* Whether Swarm RPC is available for this process.
|
||||||
* Opt out with ENABLE_SWARM=0 / false / off / no.
|
* Podman always returns false.
|
||||||
*/
|
*/
|
||||||
export function isSwarmEnabled() {
|
export function isSwarmEnabled() {
|
||||||
const v = String(process.env.ENABLE_SWARM ?? '1').trim().toLowerCase()
|
return isSwarmFeatureAvailable()
|
||||||
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSwarm() {
|
function assertSwarm() {
|
||||||
if (!isSwarmEnabled()) {
|
if (!isSwarmEnabled()) {
|
||||||
const err = new Error(
|
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.code = 'FEATURE_DISABLED'
|
||||||
|
err.feature = 'swarm'
|
||||||
|
err.engine = containerEngineKind
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-11
@@ -12,8 +12,7 @@ import * as validation from '../utils/validation.js'
|
|||||||
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
||||||
import { getMetricsSnapshot } from '../services/metrics.js'
|
import { getMetricsSnapshot } from '../services/metrics.js'
|
||||||
import { peers } from '../core/peer-registry.js'
|
import { peers } from '../core/peer-registry.js'
|
||||||
import { isSwarmEnabled } from './swarm.js'
|
import { getEngineFeatures } from '../services/engine-features.js'
|
||||||
import { isPluginsEnabled } from './plugins.js'
|
|
||||||
import {
|
import {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
upsertSchedule,
|
upsertSchedule,
|
||||||
@@ -29,15 +28,19 @@ export function registerSystemHandlers(session) {
|
|||||||
let dockerOk = false
|
let dockerOk = false
|
||||||
let apiVersion = null
|
let apiVersion = null
|
||||||
let osType = null
|
let osType = null
|
||||||
|
let platform = null
|
||||||
let error = null
|
let error = null
|
||||||
|
let version = null
|
||||||
try {
|
try {
|
||||||
const version = await docker.version()
|
version = await docker.version()
|
||||||
dockerOk = true
|
dockerOk = true
|
||||||
apiVersion = version.ApiVersion || version.apiVersion || null
|
apiVersion = version.ApiVersion || version.apiVersion || null
|
||||||
osType = version.Os || version.os || null
|
osType = version.Os || version.os || null
|
||||||
|
platform = version.Platform?.Name || version.Platform || null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
}
|
}
|
||||||
|
const features = getEngineFeatures({ version })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pong: Date.now(),
|
pong: Date.now(),
|
||||||
@@ -48,21 +51,23 @@ export function registerSystemHandlers(session) {
|
|||||||
ok: dockerOk,
|
ok: dockerOk,
|
||||||
apiVersion,
|
apiVersion,
|
||||||
os: osType,
|
os: osType,
|
||||||
|
platform,
|
||||||
error,
|
error,
|
||||||
/** docker | podman | unknown — socket used by this server process */
|
engine: features.engine,
|
||||||
engine: containerEngineKind,
|
socketPath: features.socketPath,
|
||||||
socketPath: dockerSocketPath,
|
label: features.engineLabel,
|
||||||
},
|
|
||||||
features: {
|
|
||||||
swarm: isSwarmEnabled(),
|
|
||||||
plugins: isPluginsEnabled(),
|
|
||||||
},
|
},
|
||||||
|
features,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
session.respond('getSystemInfo', async () => {
|
session.respond('getSystemInfo', async () => {
|
||||||
const [info, version] = await Promise.all([docker.info(), docker.version()])
|
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 () => {
|
session.respond('getSystemDf', async () => {
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ export function registerHandshake(session) {
|
|||||||
schemaValidation: true,
|
schemaValidation: true,
|
||||||
hmacAuth: true,
|
hmacAuth: true,
|
||||||
connectionInvites: 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)}` : ''}`
|
: `on but package missing${hs?.loadError ? ` · ${String(hs.loadError).slice(0, 80)}` : ''}`
|
||||||
}`,
|
}`,
|
||||||
`Auth: HMAC capabilities · default role viewer${isInsecureOpenAdmin() ? ' · INSECURE OPEN ADMIN' : ''}`,
|
`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'}`,
|
`Log: ${logger.format} · level ${['error', 'warn', 'info', 'debug'][logger.level] || 'info'}`,
|
||||||
`Boot ${bootMs}ms · pid ${process.pid} · Node ${process.version}`,
|
`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.
|
* Lightweight process / RPC metrics for production observability.
|
||||||
*/
|
*/
|
||||||
|
import { getEngineFeatures } from './engine-features.js'
|
||||||
|
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
const counters = {
|
const counters = {
|
||||||
@@ -87,15 +89,7 @@ export function getMetricsSnapshot(extra = {}) {
|
|||||||
},
|
},
|
||||||
pushes: counters.pushes,
|
pushes: counters.pushes,
|
||||||
features: {
|
features: {
|
||||||
swarm: (() => {
|
...getEngineFeatures(),
|
||||||
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')
|
|
||||||
})(),
|
|
||||||
peerAllowlist:
|
peerAllowlist:
|
||||||
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
|
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
|
||||||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
|
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
|
||||||
|
|||||||
@@ -10,19 +10,27 @@ import logger from './logger.js'
|
|||||||
import {
|
import {
|
||||||
startContainerNoBody,
|
startContainerNoBody,
|
||||||
containerEngineKind,
|
containerEngineKind,
|
||||||
|
dockerSocketPath,
|
||||||
} from '../services/docker.js'
|
} from '../services/docker.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve compose CLI binary + argv prefix based on preferred engine.
|
* Resolve compose CLI binary + argv prefix based on preferred engine.
|
||||||
* Docker: `docker compose …`
|
* Docker: `docker compose …`
|
||||||
* Podman: `podman compose …` (falls back to docker if podman missing at spawn time)
|
* Podman: `podman compose …` (falls back to docker / podman-compose)
|
||||||
* @returns {{ cmd: string, prefix: string[] }}
|
* 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) {
|
export function resolveComposeCli(kind = containerEngineKind) {
|
||||||
if (kind === 'podman') {
|
const unix = `unix://${dockerSocketPath}`
|
||||||
return { cmd: 'podman', prefix: ['compose'] }
|
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))
|
if (p) profileArgs.push('--profile', String(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cmd, prefix } = resolveComposeCli()
|
const { cmd, prefix, envExtra } = resolveComposeCli()
|
||||||
const fullArgs = [...prefix, ...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
const fullArgs = [...prefix, ...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
||||||
/** @type {Set<string>} */
|
/** @type {Set<string>} */
|
||||||
const tried = new Set()
|
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)) {
|
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)
|
cleanup(dir)
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
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
|
return
|
||||||
}
|
}
|
||||||
tried.add(executable)
|
tried.add(executable)
|
||||||
|
|
||||||
const child = spawn(executable, fullArgs, {
|
const child = spawn(executable, argv, {
|
||||||
env: { ...process.env, ...env },
|
env: { ...process.env, ...envExtra, ...env },
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -246,11 +271,14 @@ export function runComposeCli(args, {
|
|||||||
})
|
})
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
// Fall back to the other CLI when the preferred binary is missing
|
|
||||||
if (err?.code === 'ENOENT') {
|
if (err?.code === 'ENOENT') {
|
||||||
const alt = executable === 'docker' ? 'podman' : 'docker'
|
const next = fallbacks.find((f) => !tried.has(f))
|
||||||
if (!tried.has(alt)) {
|
if (next) {
|
||||||
trySpawn(alt)
|
const nextArgs =
|
||||||
|
next === 'podman-compose'
|
||||||
|
? [...fileArgs, '-p', projectName, ...profileArgs, ...args]
|
||||||
|
: fullArgs
|
||||||
|
trySpawn(next, nextArgs)
|
||||||
return
|
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) {
|
async function listProjectContainerIds(docker, stackName) {
|
||||||
const containers = await docker.listContainers({ all: true })
|
const containers = await docker.listContainers({ all: true })
|
||||||
return containers
|
return containers
|
||||||
.filter((c) => c.Labels?.['com.docker.compose.project'] === stackName)
|
.filter((c) => composeLabelsFrom(c.Labels || {}).project === stackName)
|
||||||
.map((c) => c.Id)
|
.map((c) => c.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +605,29 @@ function parsePortMapping(portStr) {
|
|||||||
/**
|
/**
|
||||||
* @param {import('dockerode')} docker
|
* @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) {
|
export async function listStacks(docker) {
|
||||||
try {
|
try {
|
||||||
const containers = await docker.listContainers({ all: true })
|
const containers = await docker.listContainers({ all: true })
|
||||||
@@ -584,8 +635,7 @@ export async function listStacks(docker) {
|
|||||||
|
|
||||||
for (const container of containers) {
|
for (const container of containers) {
|
||||||
const labels = container.Labels || {}
|
const labels = container.Labels || {}
|
||||||
const project = labels['com.docker.compose.project']
|
const { project, service } = composeLabelsFrom(labels)
|
||||||
const service = labels['com.docker.compose.service']
|
|
||||||
if (!project) continue
|
if (!project) continue
|
||||||
|
|
||||||
if (!stacks[project]) {
|
if (!stacks[project]) {
|
||||||
@@ -614,10 +664,10 @@ export async function listStacks(docker) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Built-in Docker networks that must never be removed. */
|
/** 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 {import('dockerode')} docker
|
||||||
* @param {string} stackName
|
* @param {string} stackName
|
||||||
*/
|
*/
|
||||||
@@ -626,7 +676,8 @@ async function listProjectNetworks(docker, stackName) {
|
|||||||
return networks.filter((n) => {
|
return networks.filter((n) => {
|
||||||
if (!n?.Name || PREDEFINED_NETWORKS.has(n.Name)) return false
|
if (!n?.Name || PREDEFINED_NETWORKS.has(n.Name)) return false
|
||||||
const labels = n.Labels || {}
|
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.
|
// CLI `compose down` needs a compose file; we use label-based dockerode cleanup instead.
|
||||||
const containers = await docker.listContainers({ all: true })
|
const containers = await docker.listContainers({ all: true })
|
||||||
const stackContainers = containers.filter((c) => {
|
const stackContainers = containers.filter((c) => {
|
||||||
const labels = c.Labels || {}
|
return composeLabelsFrom(c.Labels || {}).project === stackName
|
||||||
return labels['com.docker.compose.project'] === stackName
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const networkCandidates = await listProjectNetworks(docker, stackName)
|
const networkCandidates = await listProjectNetworks(docker, stackName)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Smart defaults / auto-populate helpers for dynamic UI.
|
* Smart defaults / auto-populate helpers for dynamic UI.
|
||||||
*/
|
*/
|
||||||
import { docker } from '../services/docker.js'
|
import { docker } from '../services/docker.js'
|
||||||
|
import { composeLabelsFrom } from './composeManager.js'
|
||||||
import logger from './logger.js'
|
import logger from './logger.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +170,7 @@ export async function suggestResourceName(kind, base) {
|
|||||||
} else if (kind === 'stack') {
|
} else if (kind === 'stack') {
|
||||||
const list = await docker.listContainers({ all: true })
|
const list = await docker.listContainers({ all: true })
|
||||||
for (const c of list) {
|
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())
|
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) => {
|
test('resolveComposeCli picks podman for podman engine', (t) => {
|
||||||
t.alike(resolveComposeCli('podman'), { cmd: 'podman', prefix: ['compose'] })
|
const pod = resolveComposeCli('podman')
|
||||||
t.alike(resolveComposeCli('docker'), { cmd: 'docker', prefix: ['compose'] })
|
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('podman'), 'Podman')
|
||||||
t.is(engineDisplayName('docker'), 'Docker')
|
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 {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
+17
-6
@@ -1306,16 +1306,19 @@ export async function loadSwarmView(opts = {}) {
|
|||||||
const code = err?.code || ''
|
const code = err?.code || ''
|
||||||
const msg = err?.message || String(err)
|
const msg = err?.message || String(err)
|
||||||
if (banner) {
|
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.className = 'alert alert-secondary small mb-3'
|
||||||
banner.innerHTML =
|
const podman =
|
||||||
'Swarm APIs are <strong>off</strong>. Remove <code>ENABLE_SWARM=0</code> (on by default).'
|
/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 {
|
} else {
|
||||||
banner.className = 'alert alert-danger small mb-3'
|
banner.className = 'alert alert-danger small mb-3'
|
||||||
banner.textContent = msg
|
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: 'Networks', icon: 'fa-diagram-project', view: 'networks', keywords: 'g n' },
|
||||||
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
|
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes', keywords: 'g v' },
|
||||||
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks', keywords: 'g s' },
|
{ 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: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
|
||||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
||||||
{
|
{
|
||||||
@@ -1977,13 +1983,18 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
manager.on('connect', () => {
|
manager.on('connect', () => {
|
||||||
// Role may arrive after handshake
|
// Role / engine features may arrive after handshake + ping
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
|
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
|
||||||
|
if (typeof window.applyEngineUI === 'function') window.applyEngineUI()
|
||||||
}, 50)
|
}, 50)
|
||||||
})
|
})
|
||||||
manager.on('active', () => {
|
manager.on('active', () => {
|
||||||
if (typeof window.applyRoleUI === 'function') window.applyRoleUI()
|
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
|
// Allow clicking the job panel header area to dismiss early
|
||||||
|
|||||||
@@ -317,6 +317,12 @@ export function initTrackGUx(ctx = {}) {
|
|||||||
|
|
||||||
const go = (dest) => {
|
const go = (dest) => {
|
||||||
if (!dest) return
|
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
|
// Compound targets: "settings:peers" → settings view + peers subtab
|
||||||
if (String(dest).includes(':')) {
|
if (String(dest).includes(':')) {
|
||||||
const [view, tab] = String(dest).split(':')
|
const [view, tab] = String(dest).split(':')
|
||||||
|
|||||||
Reference in New Issue
Block a user