Add Podman Libpod pods, secrets, and operator hardening.
Release rolling / release (push) Successful in 7m56s

Expose pods and file-backed secrets via the Libpod API, pin the Engine
API for Podman, improve rootless/SELinux/port error guidance, and document
socket, linger, and auth.json setup for rootful and rootless hosts.
This commit is contained in:
Raven Scott
2026-07-16 05:40:16 -04:00
parent f9d16b7cf8
commit 69d117b7ed
25 changed files with 1385 additions and 89 deletions
+2
View File
@@ -288,6 +288,8 @@ Full technical docs live under **[`docs/`](docs/README.md)** (architecture diagr
| `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 |
| `DOCKER_API_VERSION` | Optional Engine API pin (Podman defaults to `v1.41`) |
| `REGISTRY_AUTH_FILE` | Podman/containers auth.json for host CLI + compose |
| `PEARDOCK_MAX_TUNNELS` | `20` |
| `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald |
+38 -1
View File
@@ -1050,6 +1050,8 @@ function navigateToView(viewName, opts = {}) {
loadStacks();
} else if (viewName === 'swarm') {
window.peardockOps?.loadSwarmView?.();
} else if (viewName === 'pods') {
window.peardockOps?.loadPodsView?.();
} else if (viewName === 'deploy') {
loadDeployView();
} else if (viewName === 'add-container') {
@@ -1715,12 +1717,21 @@ function applyEngineUI() {
'docker';
const swarmOn = features ? features.swarm !== false : engine !== 'podman';
const pluginsOn = features ? features.plugins === true : false;
const podsOn = features ? features.pods === true : engine === 'podman';
document.body.dataset.engine = engine;
if (features?.rootless != null) {
document.body.dataset.rootless = features.rootless ? '1' : '0';
}
if (features?.selinux != null) {
document.body.dataset.selinux = features.selinux ? '1' : '0';
}
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);
document.body.classList.toggle('feature-pods-off', !podsOn);
document.body.classList.toggle('feature-pods-on', podsOn);
// Sidebar + any nav with data-view="swarm"
document.querySelectorAll('[data-view="swarm"]').forEach((el) => {
@@ -1733,6 +1744,16 @@ function applyEngineUI() {
}
});
document.querySelectorAll('[data-view="pods"], [data-feature="pods"]').forEach((el) => {
el.classList.toggle('engine-feature-hidden', !podsOn);
el.setAttribute('aria-hidden', podsOn ? 'false' : 'true');
if (!podsOn) {
el.title = 'Pods require Podman (Libpod)';
} else if (el.title?.includes('require Podman')) {
el.removeAttribute('title');
}
});
document.querySelectorAll('[data-feature="swarm"]').forEach((el) => {
el.classList.toggle('engine-feature-hidden', !swarmOn);
});
@@ -1740,10 +1761,26 @@ function applyEngineUI() {
el.classList.toggle('engine-feature-hidden', !pluginsOn);
});
// Leave swarm view if it is hidden for this engine
// Leave swarm/pods view if hidden for this engine
if (!swarmOn && typeof currentView !== 'undefined' && currentView === 'swarm') {
if (typeof navigateToView === 'function') navigateToView('containers');
}
if (!podsOn && typeof currentView !== 'undefined' && currentView === 'pods') {
if (typeof navigateToView === 'function') navigateToView('containers');
}
// Show Podman operator notes once in host banner area if present
const notesEl = document.getElementById('engine-notes-banner');
if (notesEl) {
const notes = Array.isArray(features?.notes) ? features.notes : [];
if (notes.length && engine === 'podman') {
notesEl.classList.remove('hidden');
notesEl.innerHTML = notes.map((n) => `<div>${String(n)}</div>`).join('');
} else {
notesEl.classList.add('hidden');
notesEl.innerHTML = '';
}
}
// Host CLI button label
document.querySelectorAll('.docker-terminal-btn, [data-cli-label]').forEach((el) => {
+3 -1
View File
@@ -340,9 +340,11 @@ export class PearDockConnection extends EventEmitter {
engineLabel: res.docker.label || (eng === 'podman' ? 'Podman' : 'Docker'),
swarm: eng !== 'podman',
plugins: false,
pods: eng === 'podman',
podmanSecrets: eng === 'podman',
compose: true,
stacks: true,
unsupported: eng === 'podman' ? ['swarm', 'plugins'] : [],
unsupported: eng === 'podman' ? ['swarm', 'plugins'] : ['pods', 'podmanSecrets'],
}
}
if (res?.docker && res.docker.ok === false) {
+7 -2
View File
@@ -3,8 +3,10 @@ Description=peardock HyperDHT Docker control plane
Documentation=https://git.ssh.surf/snxraven/peardock
After=network-online.target
Wants=network-online.target
# Soft-depend on Docker (rootless / podman setups may not ship docker.service)
# Soft-depend on Docker / Podman (either socket is enough)
Wants=docker.service
# Rootful Podman API (optional; ignore if unit missing)
# Wants=podman.socket
[Service]
Type=simple
@@ -30,9 +32,12 @@ EnvironmentFile=-/opt/peardock/.env
# Example production knobs (prefer .env):
# Environment=PEARDOCK_DEFAULT_ROLE=operator
# Environment=PEARDOCK_PEER_ALLOWLIST=1
# Swarm + Holesail are on by default; opt out with ENABLE_*=0
# Environment=PEARDOCK_ENGINE=podman
# Environment=DOCKER_HOST=unix:///run/podman/podman.sock
# Swarm + Holesail are on by default (Docker); Swarm is always off on Podman
# Environment=ENABLE_SWARM=0
# Environment=ENABLE_HOLESAIL=0
# Environment=DOCKER_API_VERSION=v1.41
ReadWritePaths=/opt/peardock
+3 -1
View File
@@ -28,7 +28,9 @@ What PearDock can do today, mapped to code and protocol surfaces.
| Holesail tunnels | shipped | **on** | `handlers/tunnels.js`, `services/holesail-tunnels.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` |
| Podman engine | shipped | auto | `services/docker.js`, `engine-features.js`, `libpod.js` |
| Podman pods (Libpod) | shipped | Podman only | `handlers/pods.js` |
| Podman secrets (file) | shipped | Podman only | `handlers/pods.js` / Libpod |
| 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` |
+40 -7
View File
@@ -70,37 +70,70 @@ 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). The desktop client **hides Swarm** (and Docker plugins) when the engine is Podman.
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, and **shows Pods** (Libpod) for Podman.
| Preference | Behavior |
|------------|----------|
| Default | Try Docker sockets first, then Podman (rootless + rootful) |
| Default | Try Docker sockets first, then Podman (rootless + rootful + machine) |
| `PEARDOCK_ENGINE=podman` | Prefer Podman sockets |
| `PEARDOCK_ENGINE=docker` | Docker only (no Podman fallback) |
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit `unix://…` path wins |
| `PODMAN_SOCK` | Extra Podman path candidate |
| `DOCKER_API_VERSION` | Optional pin (default `v1.41` on Podman) |
| `REGISTRY_AUTH_FILE` | Podman auth.json path for host CLI / compose pulls |
Common Podman paths: `$XDG_RUNTIME_DIR/podman/podman.sock` (rootless), `/run/podman/podman.sock` (rootful).
Common Podman paths: `$XDG_RUNTIME_DIR/podman/podman.sock` (rootless), `/run/podman/podman.sock` (rootful), Podman Machine under `~/.local/share/containers/podman/machine/…`.
#### Rootful (recommended for systemd service)
```bash
sudo systemctl enable --now podman.socket
# Socket: /run/podman/podman.sock — ensure peardock can read it
export PEARDOCK_ENGINE=podman
# or: DOCKER_HOST=unix:///run/podman/podman.sock
```
The stock unit runs as `User=peardock` / `Group=docker`. For rootful Podman, either:
- Run peardock as root (not ideal), or
- Adjust socket permissions / group so `peardock` can access `/run/podman/podman.sock`, or
- Run peardock as the same user that owns the rootless socket (below).
#### Rootless (desktop / user session)
```bash
# Rootless example
systemctl --user enable --now podman.socket
# Keep user services after logout (required for headless servers):
loginctl enable-linger "$USER"
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
# or: PEARDOCK_ENGINE=podman
```
**Critical:** the peardock **process user** must match the user that owns the rootless socket (`$XDG_RUNTIME_DIR/podman/podman.sock`). A system user like `peardock` cannot use another users rootless socket.
**Privileged ports:** rootless cannot bind host ports &lt; 1024 by default. Map `8080:80` or raise `net.ipv4.ip_unprivileged_port_start`.
**Networking:** rootless uses pasta/slirp4netns — not full bridge/iptables. Prefer published ports for host access.
**SELinux (RHEL/Fedora/CentOS):** bind mounts may need `:Z` or `:z`. The Add Container volume **Mode** menu includes `RW+Z` / `RO+Z`.
**Registry auth:** Podman writes credentials to `${XDG_RUNTIME_DIR}/containers/auth.json` (and may read `~/.docker/config.json`). Prefer **Registry Vault** in PearDock for private pulls; host CLI / compose inherit `REGISTRY_AUTH_FILE` when the file exists.
**macOS / Windows:** enable Podman Machine + Docker compatibility; socket paths under the machine directory are auto-detected. Prefer `DOCKER_HOST` from `podman machine inspect` if auto-detect misses.
| 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 |
| Registry vault + browser, image update checks | Supported (prefer vault) |
| Holesail tunnels | Supported |
| Host CLI (`docker` commands rewritten to `podman`) | Supported |
| Host CLI (`docker` `podman`, authfile-aware) | Supported |
| **Pods** (Libpod) + **Podman secrets** | Supported — **Pods** view |
| **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.
`ping` / metrics expose a full `features` matrix (`engine`, `swarm`, `plugins`, `pods`, `podmanSecrets`, `rootless`, `selinux`, `notes`, `unsupported`, …) so clients can gate the UI.
## Manual install from source
+57
View File
@@ -166,6 +166,12 @@
<span class="nav-label">Swarm</span>
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="pods" data-feature="pods" title="Pods (Podman)">
<i class="fas fa-cubes"></i>
<span class="nav-label">Pods</span>
</a>
</li>
<li class="nav-group-label">Build & storage</li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="images" title="Images">
@@ -2167,6 +2173,57 @@
</div>
</div>
<!-- Pods view (Podman Libpod only) -->
<div id="pods-view" class="view hidden">
<div class="container-fluid">
<div class="page-header">
<div>
<h2><i class="fas fa-cubes"></i>Pods</h2>
<p class="page-subtitle">Podman pods and file-backed secrets (Libpod). Hidden on Docker Engine.</p>
</div>
<button class="btn btn-outline-primary" type="button" id="pods-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button>
</div>
<div id="pods-status-banner" class="alert alert-secondary small mb-3">Checking Podman…</div>
<ul class="nav nav-tabs mb-3" id="pods-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-pods-tab="pods" type="button">Pods</button></li>
<li class="nav-item"><button class="nav-link" data-pods-tab="secrets" type="button">Secrets</button></li>
</ul>
<div id="pods-panel-pods" class="pods-panel">
<div class="settings-section mb-3" data-min-role="operator">
<h3 class="h6">Create pod</h3>
<div class="row g-2">
<div class="col-md-6"><input id="pod-create-name" class="form-control form-control-sm bg-dark text-white" placeholder="Pod name" autocomplete="off"></div>
<div class="col-md-3"><button type="button" class="btn btn-sm btn-primary w-100" id="pod-create-btn">Create</button></div>
</div>
<p class="form-text small text-muted mb-0 mt-1">Creates a pod with a shared network namespace (infra container). Add containers with <code>--pod</code> / HostConfig.Pod on create.</p>
</div>
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Name</th><th>Status</th><th>Containers</th><th>ID</th><th></th></tr></thead>
<tbody id="pods-list-body"><tr><td colspan="5" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
<div id="pods-panel-secrets" class="pods-panel hidden">
<div class="settings-section mb-3" data-min-role="admin">
<h3 class="h6">Create Podman secret</h3>
<div class="row g-2">
<div class="col-md-3"><input id="podman-secret-name" class="form-control form-control-sm bg-dark text-white" placeholder="Name"></div>
<div class="col-md-7"><input id="podman-secret-data" class="form-control form-control-sm bg-dark text-white font-monospace" placeholder="Secret value" type="password" autocomplete="new-password"></div>
<div class="col-md-2"><button type="button" class="btn btn-sm btn-primary w-100" id="podman-secret-create">Create</button></div>
</div>
<p class="form-text small text-muted mb-0 mt-1">File-backed Podman secrets (not Docker Swarm secrets). Mount with <code>--secret</code> / create options.</p>
</div>
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Name</th><th>Driver</th><th>ID</th><th></th></tr></thead>
<tbody id="podman-secrets-body"><tr><td colspan="4" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Swarm view (on by default; ENABLE_SWARM=0 to disable) -->
<div id="swarm-view" class="view hidden">
<div class="container-fluid">
+6 -2
View File
@@ -142,11 +142,15 @@ export function addcAddVolume() {
<label class="volume-field-label">Container path</label>
<input type="text" class="form-control bg-dark text-white volume-container-input" placeholder="/data" required>
</div>
<div class="volume-field-group" style="max-width:6rem">
<div class="volume-field-group" style="max-width:9rem">
<label class="volume-field-label">Mode</label>
<select class="form-select bg-dark text-white volume-mode-input">
<select class="form-select bg-dark text-white volume-mode-input" title="On SELinux hosts (RHEL/Fedora) use RW+Z or RO+Z for bind mounts">
<option value="rw">RW</option>
<option value="ro">RO</option>
<option value="rw,Z">RW+Z (SELinux)</option>
<option value="ro,Z">RO+Z (SELinux)</option>
<option value="rw,z">RW+z (shared)</option>
<option value="ro,z">RO+z (shared)</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-danger align-self-end" data-addc-remove title="Remove">
+4 -7
View File
@@ -6,10 +6,8 @@ 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'
import { containerEngineKind } from '../services/docker.js'
import { containerCliEnvExtras } from '../services/libpod.js'
const UNRESTRICTED =
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
@@ -79,14 +77,13 @@ export function registerDockerCliHandlers(session) {
const { executable, cmdArgs } = parseContainerCliCommand(commandStr)
const connectionId = args.connectionId
const unix = `unix://${dockerSocketPath}`
const envExtra = containerCliEnvExtras()
return new Promise((resolve, reject) => {
const child = spawn(executable, cmdArgs, {
env: {
...process.env,
DOCKER_HOST: unix,
CONTAINER_HOST: unix,
...envExtra,
},
})
let settled = false
+166
View File
@@ -0,0 +1,166 @@
/**
* Podman pods + file-backed secrets (Libpod API).
* Only available when container engine is Podman.
*/
import { containerEngineKind } from '../services/docker.js'
import {
isLibpodAvailable,
listPods,
inspectPod,
createPod,
startPod,
stopPod,
restartPod,
removePod,
listPodmanSecrets,
inspectPodmanSecret,
createPodmanSecret,
removePodmanSecret,
} from '../services/libpod.js'
import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js'
import { isPodsFeatureAvailable, isPodmanSecretsFeatureAvailable } from '../services/engine-features.js'
export function isPodsEnabled() {
return isPodsFeatureAvailable()
}
export function isPodmanSecretsEnabled() {
return isPodmanSecretsFeatureAvailable()
}
function assertPods() {
if (!isPodsEnabled()) {
const err = new Error(
containerEngineKind === 'podman'
? 'Pods API is unavailable (Libpod probe failed or pods disabled). Ensure podman.socket is running.'
: 'Pods are a Podman-only feature. Connect a Podman engine to manage pods.'
)
err.code = 'FEATURE_DISABLED'
err.feature = 'pods'
err.engine = containerEngineKind
throw err
}
}
function assertPodmanSecrets() {
if (!isPodmanSecretsEnabled()) {
const err = new Error(
containerEngineKind === 'podman'
? 'Podman secrets are unavailable. Ensure podman.socket is running.'
: 'Podman secrets require a Podman engine (not Docker Swarm secrets).'
)
err.code = 'FEATURE_DISABLED'
err.feature = 'podmanSecrets'
err.engine = containerEngineKind
throw err
}
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function registerPodHandlers(session) {
// —— Pods ——
session.respond('listPods', async () => {
assertPods()
const data = await listPods()
return { success: true, type: 'pods', data }
})
session.respond('inspectPod', async (args) => {
assertPods()
const id = validation.sanitizeString(args.id || args.name || args.pod || '', 256)
if (!id) throw new Error('Pod id or name required')
const data = await inspectPod(id)
return { success: true, type: 'podInspect', data }
})
session.respond('createPod', async (args) => {
assertPods()
const name = validation.sanitizeString(args.name || '', 128)
if (!name) throw new Error('Pod name required')
const data = await createPod({
name,
labels: args.labels && typeof args.labels === 'object' ? args.labels : undefined,
share: Array.isArray(args.share) ? args.share : undefined,
infra: args.infra !== false,
})
return { success: true, message: `Pod "${name}" created`, data }
})
session.respond('startPod', async (args) => {
assertPods()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Pod id or name required')
await startPod(id)
return { success: true, message: `Pod ${id} started` }
})
session.respond('stopPod', async (args) => {
assertPods()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Pod id or name required')
await stopPod(id, { timeout: args.timeout ?? args.t })
return { success: true, message: `Pod ${id} stopped` }
})
session.respond('restartPod', async (args) => {
assertPods()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Pod id or name required')
await restartPod(id)
return { success: true, message: `Pod ${id} restarted` }
})
session.respond('removePod', async (args) => {
assertPods()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Pod id or name required')
await removePod(id, { force: args.force !== false })
return { success: true, message: `Pod ${id} removed` }
})
// —— Podman secrets (file-backed; distinct from Swarm secrets) ——
session.respond('listPodmanSecrets', async () => {
assertPodmanSecrets()
const data = await listPodmanSecrets()
return { success: true, type: 'podmanSecrets', data }
})
session.respond('inspectPodmanSecret', async (args) => {
assertPodmanSecrets()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Secret id or name required')
const data = await inspectPodmanSecret(id)
return { success: true, type: 'podmanSecretInspect', data }
})
session.respond('createPodmanSecret', async (args) => {
assertPodmanSecrets()
const name = validation.sanitizeString(args.name || '', 128)
if (!name) throw new Error('Secret name required')
const data = args.data ?? args.value ?? args.secret
if (data == null || data === '') throw new Error('Secret data required')
const result = await createPodmanSecret({
name,
data: String(data),
labels: args.labels && typeof args.labels === 'object' ? args.labels : undefined,
driver: args.driver,
})
return { success: true, message: `Secret "${name}" created`, data: result }
})
session.respond('removePodmanSecret', async (args) => {
assertPodmanSecrets()
const id = validation.sanitizeString(args.id || args.name || '', 256)
if (!id) throw new Error('Secret id or name required')
await removePodmanSecret(id)
return { success: true, message: `Secret ${id} removed` }
})
logger.debug('Pod/Libpod handlers registered', {
pods: isLibpodAvailable(),
engine: containerEngineKind,
})
}
+9 -2
View File
@@ -31,16 +31,22 @@ export function registerSystemHandlers(session) {
let platform = null
let error = null
let version = null
let info = null
try {
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
try {
info = await docker.info()
} catch {
info = null
}
} catch (err) {
error = err.message
}
const features = getEngineFeatures({ version })
const features = getEngineFeatures({ version, info })
return {
success: true,
pong: Date.now(),
@@ -56,6 +62,7 @@ export function registerSystemHandlers(session) {
engine: features.engine,
socketPath: features.socketPath,
label: features.engineLabel,
rootless: features.rootless,
},
features,
}
@@ -63,7 +70,7 @@ export function registerSystemHandlers(session) {
session.respond('getSystemInfo', async () => {
const [info, version] = await Promise.all([docker.info(), docker.version()])
const features = getEngineFeatures({ version })
const features = getEngineFeatures({ version, info })
return {
type: 'systemInfo',
data: { info, version, engine: features },
+2
View File
@@ -20,6 +20,7 @@ import { registerRegistryHandlers } from '../handlers/registry.js'
import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js'
import { registerPodHandlers } from '../handlers/pods.js'
/**
* @param {import('./session.js').PeerSession} session
@@ -38,6 +39,7 @@ export function registerAllHandlers(session) {
registerDockerCliHandlers(session)
registerSwarmHandlers(session)
registerPluginHandlers(session)
registerPodHandlers(session)
registerPeerHandlers(session)
registerVaultHandlers(session)
registerRegistryHandlers(session)
+37 -4
View File
@@ -100,12 +100,27 @@ export function listPodmanSocketCandidates(env = process.env, homedir = os.homed
push('/run/podman/podman.sock')
push('/var/run/podman/podman.sock')
// Podman machine (macOS / remote VM helpers)
// Podman machine / Podman Desktop (macOS, Windows WSL helpers, Linux remote)
if (homedir) {
push(`${homedir}/.local/share/containers/podman/machine/podman.sock`)
push(`${homedir}/.local/share/containers/podman/machine/qemu/podman.sock`)
push(`${homedir}/.local/share/containers/podman/machine/applehv/podman.sock`)
const machineRoots = [
`${homedir}/.local/share/containers/podman/machine`,
`${homedir}/.config/containers/podman/machine`,
]
for (const root of machineRoots) {
push(`${root}/podman.sock`)
push(`${root}/qemu/podman.sock`)
push(`${root}/applehv/podman.sock`)
push(`${root}/libkrun/podman.sock`)
push(`${root}/wsl/podman.sock`)
// Default machine name used by recent Podman Desktop
push(`${root}/podman-machine-default/podman.sock`)
push(`${root}/podman-machine-default/docker.sock`)
}
}
// Windows named pipe (path form used by some tools; dockerode handles npipe separately)
push('//./pipe/podman-machine-default')
push('//./pipe/docker_engine')
return out
}
@@ -256,12 +271,30 @@ export function resolveContainerSocket(opts = {}) {
const resolvedSocket = resolveContainerSocket()
const socketPath = resolvedSocket.path
/**
* API version for docker-modem.
* Podman's compatibility layer is closest to Docker Engine ~1.401.41;
* pin that for Podman so newer client defaults do not reject requests.
* Override with DOCKER_API_VERSION (e.g. v1.44).
*/
function resolveDockerApiVersion(kind) {
const env = String(process.env.DOCKER_API_VERSION || '').trim()
if (env) return env.startsWith('v') ? env : `v${env}`
if (kind === 'podman') return 'v1.41'
return undefined
}
const dockerApiVersion = resolveDockerApiVersion(resolvedSocket.kind)
export const docker = new Docker({
socketPath,
// Force unix-socket mode in docker-modem (do not set host)
protocol: 'http',
...(dockerApiVersion ? { version: dockerApiVersion } : {}),
})
export { dockerApiVersion }
export { socketPath as dockerSocketPath }
export const containerEngineKind = resolvedSocket.kind
export const containerSocketMeta = resolvedSocket
+98 -3
View File
@@ -3,13 +3,15 @@
*
* 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.
* logs/exec/build/compose — plus Libpod pods & secrets.
* No Swarm and no Docker Engine plugins.
*/
import {
containerEngineKind,
dockerSocketPath,
engineDisplayName,
classifySocketPath,
dockerApiVersion,
} from './docker.js'
import { isHolesailEnabled } from './holesail-tunnels.js'
@@ -19,8 +21,13 @@ import { isHolesailEnabled } from './holesail-tunnels.js'
* engine: EngineKind,
* engineLabel: string,
* socketPath: string,
* apiVersionPinned: string|null,
* rootless: boolean|null,
* selinux: boolean|null,
* swarm: boolean,
* plugins: boolean,
* pods: boolean,
* podmanSecrets: boolean,
* compose: boolean,
* stacks: boolean,
* containers: boolean,
@@ -37,6 +44,7 @@ import { isHolesailEnabled } from './holesail-tunnels.js'
* dockerCli: boolean,
* podmanCli: boolean,
* unsupported: string[],
* notes: string[],
* }} EngineFeatures
*/
@@ -93,15 +101,52 @@ export function engineKindFromVersion(version, fallback = containerEngineKind) {
*/
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
}
/**
* Detect rootless from Engine /info (or version) payload.
* @param {object|null|undefined} info
* @returns {boolean|null} null if unknown
*/
export function detectRootless(info) {
if (!info || typeof info !== 'object') return null
if (typeof info.Rootless === 'boolean') return info.Rootless
if (typeof info.rootless === 'boolean') return info.rootless
const sec = info.SecurityOptions || info.securityOptions
if (Array.isArray(sec)) {
const blob = sec.join(',').toLowerCase()
if (blob.includes('rootless')) return true
if (blob.includes('name=rootless')) return true
}
// security-opt string forms
const blob = JSON.stringify(info).toLowerCase()
if (blob.includes('name=rootless') || /"rootless"\s*:\s*true/.test(blob)) return true
return null
}
/**
* Detect SELinux enforcement hints from Engine /info.
* @param {object|null|undefined} info
* @returns {boolean|null}
*/
export function detectSelinux(info) {
if (!info || typeof info !== 'object') return null
const sec = info.SecurityOptions || info.securityOptions
if (Array.isArray(sec)) {
const blob = sec.join(',').toLowerCase()
if (blob.includes('selinux')) return true
}
const driver = String(info.SecurityOptions || info.Driver || '').toLowerCase()
if (driver.includes('selinux')) return true
return null
}
/**
* Full feature flags for clients / banner / metrics.
* @param {{ version?: object|null }} [opts]
* @param {{ version?: object|null, info?: object|null }} [opts]
* @returns {EngineFeatures}
*/
export function getEngineFeatures(opts = {}) {
@@ -109,16 +154,49 @@ export function getEngineFeatures(opts = {}) {
const isPodman = engine === 'podman'
const swarm = !isPodman && envSwarmEnabled()
const plugins = !isPodman && envPluginsEnabled()
const pods = isPodman
const podmanSecrets = isPodman
const rootless = detectRootless(opts.info)
const selinux = detectSelinux(opts.info)
/** @type {string[]} */
const unsupported = []
if (isPodman) {
unsupported.push('swarm', 'plugins')
} else {
unsupported.push('pods', 'podmanSecrets')
}
/** @type {string[]} */
const notes = []
if (isPodman) {
notes.push('Swarm and Docker Engine plugins are not available on Podman.')
notes.push('Pods and file-backed secrets use the Libpod API.')
notes.push(
'Prefer PearDock Registry Vault for private pulls; host CLI uses containers/auth.json when present.'
)
if (rootless === true) {
notes.push(
'Rootless mode: ports below 1024 need sysctl net.ipv4.ip_unprivileged_port_start or map high host ports.'
)
notes.push(
'Rootless networking uses pasta/slirp4netns — bridge/iptables behavior differs from rootful Docker.'
)
}
if (selinux === true) {
notes.push(
'SELinux is active: bind mounts may need :Z or :z volume options for correct labeling.'
)
}
}
return {
engine,
engineLabel: engineDisplayName(engine),
socketPath: dockerSocketPath,
apiVersionPinned: dockerApiVersion || null,
rootless,
selinux,
// Always-on Engine API surface (Docker + Podman)
containers: true,
images: true,
@@ -136,9 +214,12 @@ export function getEngineFeatures(opts = {}) {
// Engine-specific
swarm,
plugins,
pods,
podmanSecrets,
dockerCli: !isPodman,
podmanCli: isPodman,
unsupported,
notes,
}
}
@@ -156,3 +237,17 @@ export function isSwarmFeatureAvailable() {
export function isPluginsFeatureAvailable() {
return getEngineFeatures().plugins
}
/**
* Podman pods (Libpod) — only when engine is Podman.
*/
export function isPodsFeatureAvailable() {
return getEngineFeatures().pods
}
/**
* Podman file-backed secrets (Libpod) — only when engine is Podman.
*/
export function isPodmanSecretsFeatureAvailable() {
return getEngineFeatures().podmanSecrets
}
+30 -8
View File
@@ -66,20 +66,34 @@ async function openEventStream() {
stream.on('data', async (chunk) => {
try {
const lines = chunk
.toString()
.split('\n')
// Podman/Docker may batch multiple JSON objects; tolerate partial lines
const text = chunk.toString()
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
for (const line of lines) {
let event
/** @type {object[]} */
const events = []
try {
event = JSON.parse(line)
events.push(JSON.parse(line))
} catch {
continue
// Podman sometimes emits concatenated JSON without newlines
const multi = line.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g)
if (multi) {
for (const piece of multi) {
try {
events.push(JSON.parse(piece))
} catch {
// skip
}
if (event.status === 'undefined') continue
}
}
}
for (const event of events) {
if (!event || event.status === 'undefined') continue
logger.info('Docker event', {
status: event.status,
@@ -111,7 +125,11 @@ async function openEventStream() {
}
}
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
if (
event.Type === 'volume' &&
(event.Action === 'create' || event.Action === 'destroy')
) {
try {
const volumesResult = await docker.listVolumes()
const volumesList = extractVolumesList(volumesResult)
peers.broadcast(Pushes.volumes, {
@@ -120,6 +138,10 @@ async function openEventStream() {
success: true,
volumes: volumesList,
})
} catch (e) {
logger.debug('volume list on event failed', { error: e.message })
}
}
}
}
} catch (err) {
+481
View File
@@ -0,0 +1,481 @@
/**
* Minimal Libpod (Podman-native) HTTP client over the same unix socket as dockerode.
* Used for pods + secrets not available on the Docker compatibility API.
*
* Paths: /libpod/pods/, /libpod/secrets/
* @see https://docs.podman.io/en/latest/_static/api.html
*/
import net from 'net'
import fs from 'fs'
import os from 'os'
import { dockerSocketPath, containerEngineKind } from './docker.js'
import logger from '../utils/logger.js'
/**
* Resolve containers auth.json paths (Podman credential store).
* @param {NodeJS.ProcessEnv} [env]
* @param {string} [homedir]
* @returns {string[]}
*/
export function listPodmanAuthFileCandidates(env = process.env, homedir = os.homedir()) {
/** @type {string[]} */
const out = []
const push = (p) => {
if (p && !out.includes(p)) out.push(p)
}
if (env.REGISTRY_AUTH_FILE) push(env.REGISTRY_AUTH_FILE)
if (env.XDG_RUNTIME_DIR) {
push(`${String(env.XDG_RUNTIME_DIR).replace(/\/$/, '')}/containers/auth.json`)
}
try {
if (typeof process.getuid === 'function') {
const uid = process.getuid()
if (Number.isFinite(uid)) push(`/run/user/${uid}/containers/auth.json`)
}
} catch {
// ignore
}
if (homedir) {
push(`${homedir}/.config/containers/auth.json`)
// Docker fallback Podman also reads
push(`${homedir}/.docker/config.json`)
}
return out
}
/**
* First existing auth file (for REGISTRY_AUTH_FILE / CLI env).
* @returns {string|null}
*/
export function resolvePodmanAuthFile() {
for (const p of listPodmanAuthFileCandidates()) {
try {
if (fs.existsSync(p)) return p
} catch {
// ignore
}
}
return null
}
/**
* Env extras for podman/docker CLI so registry + socket match the server process.
* @returns {Record<string, string>}
*/
export function containerCliEnvExtras() {
const unix = `unix://${dockerSocketPath}`
/** @type {Record<string, string>} */
const env = {
DOCKER_HOST: unix,
CONTAINER_HOST: unix,
}
const auth = process.env.REGISTRY_AUTH_FILE || resolvePodmanAuthFile()
if (auth) env.REGISTRY_AUTH_FILE = auth
return env
}
/**
* Low-level HTTP over unix socket.
* @param {string} method
* @param {string} apiPath
* @param {{ body?: object|string|null, ok?: number[], timeoutMs?: number, query?: Record<string, string|number|boolean|undefined> }} [opts]
* @returns {Promise<{ statusCode: number, body: string, json: any }>}
*/
export function libpodRequest(method, apiPath, opts = {}) {
const okCodes = opts.ok || [200, 201, 204]
const timeoutMs =
Number(opts.timeoutMs) > 0 && Number.isFinite(Number(opts.timeoutMs))
? Number(opts.timeoutMs)
: 60_000
let path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
if (opts.query && typeof opts.query === 'object') {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(opts.query)) {
if (v === undefined || v === null || v === false) continue
qs.set(k, v === true ? 'true' : String(v))
}
const s = qs.toString()
if (s) path += (path.includes('?') ? '&' : '?') + s
}
const verb = String(method || 'GET').toUpperCase()
let payload = ''
if (opts.body != null && opts.body !== '') {
payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body)
}
return new Promise((resolve, reject) => {
const socket = net.createConnection({ path: dockerSocketPath })
let buf = Buffer.alloc(0)
let settled = false
const finish = (err, result) => {
if (settled) return
settled = true
try {
socket.destroy()
} catch {
// ignore
}
if (err) reject(err)
else resolve(result)
}
const timer = setTimeout(() => {
finish(new Error(`Libpod socket timeout (${verb} ${path})`))
}, timeoutMs)
socket.on('connect', () => {
let req =
`${verb} ${path} HTTP/1.1\r\n` +
`Host: localhost\r\n` +
`Connection: close\r\n`
if (payload) {
req +=
`Content-Type: application/json\r\n` +
`Content-Length: ${Buffer.byteLength(payload)}\r\n` +
`\r\n` +
payload
} else {
req += `Content-Length: 0\r\n\r\n`
}
socket.write(req)
})
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk])
})
socket.on('error', (err) => {
clearTimeout(timer)
finish(err)
})
socket.on('end', () => {
clearTimeout(timer)
const text = buf.toString('utf8')
const statusMatch = /^HTTP\/1\.[01] (\d{3})/.exec(text)
const statusCode = statusMatch ? Number(statusMatch[1]) : 0
const sep = text.indexOf('\r\n\r\n')
let body = sep >= 0 ? text.slice(sep + 4) : ''
// Strip chunked framing if present (simple: only handle non-chunked; Podman usually content-length)
if (/transfer-encoding:\s*chunked/i.test(text.slice(0, sep > 0 ? sep : 200))) {
body = decodeChunked(body)
}
let json = null
if (body && body.trim()) {
try {
json = JSON.parse(body)
} catch {
json = null
}
}
if (okCodes.includes(statusCode)) {
finish(null, { statusCode, body, json })
return
}
let message =
(json && (json.message || json.cause || json.error)) ||
body.trim() ||
`Libpod HTTP ${statusCode || 'error'} for ${path}`
if (typeof message === 'object') message = JSON.stringify(message)
const err = new Error(String(message))
err.statusCode = statusCode
err.path = path
err.json = json
finish(err)
})
})
}
/**
* @param {string} chunked
*/
function decodeChunked(chunked) {
let out = ''
let i = 0
const s = chunked
while (i < s.length) {
const nl = s.indexOf('\r\n', i)
if (nl < 0) break
const size = parseInt(s.slice(i, nl), 16)
if (!Number.isFinite(size) || size === 0) break
const start = nl + 2
out += s.slice(start, start + size)
i = start + size + 2
}
return out || chunked
}
export function isLibpodAvailable() {
return containerEngineKind === 'podman'
}
// —— Pods ——
/**
* @returns {Promise<object[]>}
*/
export async function listPods() {
const { json } = await libpodRequest('GET', '/libpod/pods/json', { ok: [200] })
return Array.isArray(json) ? json : []
}
/**
* @param {string} nameOrId
*/
export async function inspectPod(nameOrId) {
const id = encodeURIComponent(String(nameOrId || '').trim())
if (!id) throw new Error('Pod name or id required')
const { json } = await libpodRequest('GET', `/libpod/pods/${id}/json`, { ok: [200] })
return json
}
/**
* @param {{ name: string, labels?: Record<string,string>, share?: string[], infra?: boolean }} opts
*/
export async function createPod(opts) {
const name = String(opts?.name || '').trim()
if (!name) throw new Error('Pod name required')
const body = {
name,
labels: opts.labels && typeof opts.labels === 'object' ? opts.labels : undefined,
// share PID/net/ipc with infra by default (k8s-like)
share: opts.share || ['net', 'ipc', 'uts'],
no_infra: opts.infra === false,
}
const { json, statusCode } = await libpodRequest('POST', '/libpod/pods/create', {
body,
ok: [200, 201],
})
logger.info('Libpod pod created', { name, statusCode })
return json
}
/**
* @param {string} nameOrId
*/
export async function startPod(nameOrId) {
const id = encodeURIComponent(String(nameOrId || '').trim())
await libpodRequest('POST', `/libpod/pods/${id}/start`, { ok: [200, 204, 304] })
return { success: true }
}
/**
* @param {string} nameOrId
* @param {{ timeout?: number }} [opts]
*/
export async function stopPod(nameOrId, opts = {}) {
const id = encodeURIComponent(String(nameOrId || '').trim())
await libpodRequest('POST', `/libpod/pods/${id}/stop`, {
ok: [200, 204, 304],
query: opts.timeout != null ? { t: opts.timeout } : undefined,
})
return { success: true }
}
/**
* @param {string} nameOrId
*/
export async function restartPod(nameOrId) {
const id = encodeURIComponent(String(nameOrId || '').trim())
await libpodRequest('POST', `/libpod/pods/${id}/restart`, { ok: [200, 204] })
return { success: true }
}
/**
* @param {string} nameOrId
* @param {{ force?: boolean }} [opts]
*/
export async function removePod(nameOrId, opts = {}) {
const id = encodeURIComponent(String(nameOrId || '').trim())
await libpodRequest('DELETE', `/libpod/pods/${id}`, {
ok: [200, 204],
query: { force: opts.force !== false },
})
return { success: true }
}
// —— Secrets (Podman file-backed secrets, not Swarm) ——
/**
* @returns {Promise<object[]>}
*/
export async function listPodmanSecrets() {
const { json } = await libpodRequest('GET', '/libpod/secrets/json', { ok: [200] })
return Array.isArray(json) ? json : []
}
/**
* @param {string} nameOrId
*/
export async function inspectPodmanSecret(nameOrId) {
const id = encodeURIComponent(String(nameOrId || '').trim())
if (!id) throw new Error('Secret name or id required')
const { json } = await libpodRequest('GET', `/libpod/secrets/${id}/json`, { ok: [200] })
return json
}
/**
* Create a Podman secret from string/buffer data (raw body preferred by Libpod).
* @param {{ name: string, data: string|Buffer, labels?: Record<string,string>, driver?: string }} opts
*/
export async function createPodmanSecret(opts) {
const name = String(opts?.name || '').trim()
if (!name) throw new Error('Secret name required')
const raw = opts.data
if (raw == null || raw === '') throw new Error('Secret data required')
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
// Libpod: POST /libpod/secrets/create?name=… with raw secret bytes in body
try {
const { json, statusCode } = await libpodRequestRaw(
'POST',
`/libpod/secrets/create?name=${encodeURIComponent(name)}`,
buf,
{ ok: [200, 201], contentType: 'application/octet-stream' }
)
logger.info('Libpod secret created', { name, statusCode })
return json
} catch (err) {
// Fallback: some versions accept JSON { Name, Data (base64) }
if (err.statusCode === 400 || err.statusCode === 404 || err.statusCode === 415) {
const { json, statusCode } = await libpodRequest('POST', '/libpod/secrets/create', {
body: {
Name: name,
Data: buf.toString('base64'),
Labels: opts.labels,
Driver: opts.driver ? { Name: opts.driver } : undefined,
},
ok: [200, 201],
query: { name },
})
logger.info('Libpod secret created (json)', { name, statusCode })
return json
}
throw err
}
}
/**
* Like libpodRequest but sends a Buffer/string body with custom content-type.
* @param {string} method
* @param {string} apiPath
* @param {Buffer|string} payload
* @param {{ ok?: number[], timeoutMs?: number, contentType?: string }} [opts]
*/
export function libpodRequestRaw(method, apiPath, payload, opts = {}) {
const okCodes = opts.ok || [200, 201, 204]
const timeoutMs =
Number(opts.timeoutMs) > 0 && Number.isFinite(Number(opts.timeoutMs))
? Number(opts.timeoutMs)
: 60_000
const path = apiPath.startsWith('/') ? apiPath : `/${apiPath}`
const verb = String(method || 'POST').toUpperCase()
const bodyBuf = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'utf8')
const contentType = opts.contentType || 'application/octet-stream'
return new Promise((resolve, reject) => {
const socket = net.createConnection({ path: dockerSocketPath })
let buf = Buffer.alloc(0)
let settled = false
const finish = (err, result) => {
if (settled) return
settled = true
try {
socket.destroy()
} catch {
// ignore
}
if (err) reject(err)
else resolve(result)
}
const timer = setTimeout(() => {
finish(new Error(`Libpod socket timeout (${verb} ${path})`))
}, timeoutMs)
socket.on('connect', () => {
const req =
`${verb} ${path} HTTP/1.1\r\n` +
`Host: localhost\r\n` +
`Content-Type: ${contentType}\r\n` +
`Content-Length: ${bodyBuf.length}\r\n` +
`Connection: close\r\n` +
`\r\n`
socket.write(req)
if (bodyBuf.length) socket.write(bodyBuf)
})
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk])
})
socket.on('error', (err) => {
clearTimeout(timer)
finish(err)
})
socket.on('end', () => {
clearTimeout(timer)
const text = buf.toString('utf8')
const statusMatch = /^HTTP\/1\.[01] (\d{3})/.exec(text)
const statusCode = statusMatch ? Number(statusMatch[1]) : 0
const sep = text.indexOf('\r\n\r\n')
const body = sep >= 0 ? text.slice(sep + 4) : ''
let json = null
if (body && body.trim()) {
try {
json = JSON.parse(body)
} catch {
json = null
}
}
if (okCodes.includes(statusCode)) {
finish(null, { statusCode, body, json })
return
}
let message =
(json && (json.message || json.cause || json.error)) ||
body.trim() ||
`Libpod HTTP ${statusCode || 'error'} for ${path}`
const err = new Error(String(message))
err.statusCode = statusCode
err.path = path
err.json = json
finish(err)
})
})
}
/**
* @param {string} nameOrId
*/
export async function removePodmanSecret(nameOrId) {
const id = encodeURIComponent(String(nameOrId || '').trim())
await libpodRequest('DELETE', `/libpod/secrets/${id}`, { ok: [200, 204] })
return { success: true }
}
/**
* Probe whether Libpod responds (pods list).
* @returns {Promise<boolean>}
*/
export async function probeLibpod() {
if (!isLibpodAvailable()) return false
try {
await libpodRequest('GET', '/libpod/info', { ok: [200], timeoutMs: 5000 })
return true
} catch {
try {
await listPods()
return true
} catch {
return false
}
}
}
+6 -5
View File
@@ -12,21 +12,22 @@ import {
containerEngineKind,
dockerSocketPath,
} from '../services/docker.js'
import { containerCliEnvExtras } from '../services/libpod.js'
/**
* Resolve compose CLI binary + argv prefix based on preferred engine.
* Docker: `docker compose …`
* Podman: `podman compose …` (falls back to docker / podman-compose)
* Child inherits DOCKER_HOST/CONTAINER_HOST pointing at our resolved socket.
* Child inherits DOCKER_HOST/CONTAINER_HOST + REGISTRY_AUTH_FILE when known.
* @param {string} [kind]
* @returns {{ cmd: string, prefix: string[], envExtra: Record<string, string> }}
*/
export function resolveComposeCli(kind = containerEngineKind) {
const envExtra = containerCliEnvExtras()
// ensure socket path is current even if libpod imported earlier
const unix = `unix://${dockerSocketPath}`
const envExtra = {
DOCKER_HOST: unix,
CONTAINER_HOST: unix,
}
envExtra.DOCKER_HOST = unix
envExtra.CONTAINER_HOST = unix
if (kind === 'podman') {
return { cmd: 'podman', prefix: ['compose'], envExtra }
}
+40 -9
View File
@@ -150,34 +150,65 @@ export function formatDeployError(err, ctx = {}) {
fix = 'Disconnect the existing endpoint or choose a different network/container name.'
}
// —— Podman rootless / privileged ports ——
else if (
/rootlessport|cannot expose privileged port|permission denied.*port|bind.*permission denied|ip_unprivileged_port/i.test(
lower
) ||
(/port/i.test(lower) && /rootless|unprivileged/i.test(lower))
) {
title = `Port publish failed for ${name} (often rootless Podman)`
detail = raw
fix =
'Map a host port ≥ 1024 (e.g. 8080:80), or as root set sysctl net.ipv4.ip_unprivileged_port_start=80. Rootless Podman cannot bind privileged ports by default.'
}
// —— SELinux bind mounts ——
else if (
/permission denied.*mount|selinux|failed to mount|mkdir.*permission denied|chown.*permission denied/i.test(
lower
) &&
/volume|mount|bind|overlay|z:/i.test(lower + ' ' + stage)
) {
title = `Volume/bind mount failed for ${name} (possible SELinux)`
detail = raw
fix =
'On SELinux hosts (RHEL/Fedora/CentOS), append :Z or :z to the bind mount (relabel), ensure the host path exists and is readable by the container engine user, then retry.'
} else if (/relabel|container_file_t|container_t/i.test(lower)) {
title = `SELinux blocked a mount for ${name}`
detail = raw
fix =
'Use volume option :Z (private) or :z (shared) on the bind mount, or adjust SELinux policy. Prefer named volumes when possible.'
}
// —— Resources / runtime ——
else if (/cannot set memory|memory limit|out of memory|oci runtime/i.test(lower)) {
title = `Runtime/resource error starting ${name}`
detail = raw
fix =
'Lower memory/CPU limits, free host resources, or fix invalid HostConfig options (cgroup, privileged, devices).'
'Lower memory/CPU limits, free host resources, or fix invalid HostConfig options (cgroup, privileged, devices). On rootless Podman, ensure cgroup delegation is enabled for the user.'
} else if (/permission denied|operation not permitted|cap_|apparmor|seccomp/i.test(lower)) {
title = `Permission denied starting ${name}`
detail = raw
fix =
'Remove restricted capabilities/security options, or run with the needed CapAdd/privileged flag only if you trust the image.'
} else if (/driver failed programming external connectivity|iptables|firewall/i.test(lower)) {
title = `Host networking/firewall blocked publish for ${name}`
'Remove restricted capabilities/security options, or run with the needed CapAdd/privileged flag only if you trust the image. On Podman+SELinux, check volume labels (:Z/:z).'
} else if (/driver failed programming external connectivity|iptables|firewall|pasta|slirp4netns/i.test(lower)) {
title = `Host networking blocked publish for ${name}`
detail = raw
fix =
'Check host firewall/iptables rules and that Dockers bridge networking is healthy; retry after freeing the port.'
'Check host firewall/iptables and that the engine network stack is healthy. Rootless Podman uses pasta/slirp4netns — prefer published ports over custom bridges for host access.'
}
// —— Engine down ——
else if (
/docker.*socket|connect econnrefused|enoent.*docker|is the docker daemon running|cannot connect to the docker/i.test(
/docker.*socket|podman\.sock|connect econnrefused|enoent.*docker|enoent.*podman|is the docker daemon running|cannot connect to the docker|no such file or directory.*sock/i.test(
lower
)
) {
title = 'Cannot reach Docker Engine'
title = 'Cannot reach container engine'
detail = raw
fix =
'Start the Docker daemon on the peardock host and ensure the process can access the Docker socket.'
'Start Docker (systemctl start docker) or Podman (systemctl --user enable --now podman.socket; loginctl enable-linger $USER for rootless). Set DOCKER_HOST/CONTAINER_HOST or PEARDOCK_ENGINE if needed. Ensure peardock runs as a user that can access the socket.'
}
// —— Validation already descriptive ——
@@ -190,7 +221,7 @@ export function formatDeployError(err, ctx = {}) {
const parts = [title]
if (detail && detail !== title) parts.push(detail)
if (fix) parts.push(`How to fix: ${fix}`)
if (status) parts.push(`(Docker HTTP ${status})`)
if (status) parts.push(`(Engine HTTP ${status})`)
let message = parts.join(' — ').replace(/\s+/g, ' ').trim()
if (message.length > MAX_MSG) {
+25
View File
@@ -77,6 +77,11 @@ export const MethodRoles = Object.freeze({
inspectConfig: Roles.viewer,
listPlugins: Roles.viewer,
inspectPlugin: Roles.viewer,
// Podman Libpod pods + secrets
listPods: Roles.viewer,
inspectPod: Roles.viewer,
listPodmanSecrets: Roles.viewer,
inspectPodmanSecret: Roles.viewer,
serviceLogs: Roles.viewer,
getHostSnapshot: Roles.viewer,
suggestNetworkIPAM: Roles.viewer,
@@ -138,6 +143,10 @@ export const MethodRoles = Object.freeze({
updateService: Roles.operator,
scaleService: Roles.operator,
swarmJoin: Roles.operator,
createPod: Roles.operator,
startPod: Roles.operator,
stopPod: Roles.operator,
restartPod: Roles.operator,
// admin
removeContainer: Roles.admin,
@@ -177,6 +186,9 @@ export const MethodRoles = Object.freeze({
removeSecret: Roles.admin,
createConfig: Roles.admin,
removeConfig: Roles.admin,
removePod: Roles.admin,
createPodmanSecret: Roles.admin,
removePodmanSecret: Roles.admin,
swarmInit: Roles.admin,
swarmLeave: Roles.admin,
swarmUpdate: Roles.admin,
@@ -397,6 +409,19 @@ export const Methods = Object.freeze({
inspectPlugin: 'inspectPlugin',
configurePlugin: 'configurePlugin',
// Podman Libpod — pods + file-backed secrets (Podman only)
listPods: 'listPods',
inspectPod: 'inspectPod',
createPod: 'createPod',
startPod: 'startPod',
stopPod: 'stopPod',
restartPod: 'restartPod',
removePod: 'removePod',
listPodmanSecrets: 'listPodmanSecrets',
inspectPodmanSecret: 'inspectPodmanSecret',
createPodmanSecret: 'createPodmanSecret',
removePodmanSecret: 'removePodmanSecret',
// Holesail tunnels (on by default; ENABLE_HOLESAIL=0 to disable)
listTunnels: 'listTunnels',
getTunnel: 'getTunnel',
+16
View File
@@ -30,15 +30,31 @@ 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' },
info: { Rootless: true, SecurityOptions: ['name=rootless', 'name=selinux'] },
})
t.is(f.engine, 'podman')
t.is(f.swarm, false)
t.is(f.plugins, false)
t.is(f.pods, true)
t.is(f.podmanSecrets, true)
t.is(f.rootless, true)
t.is(f.selinux, true)
t.ok(f.containers)
t.ok(f.compose)
t.ok(f.stacks)
t.ok(f.unsupported.includes('swarm'))
t.ok(f.unsupported.includes('plugins'))
t.ok(Array.isArray(f.notes) && f.notes.length > 0)
})
test('getEngineFeatures disables pods on docker kind', (t) => {
const f = getEngineFeatures({
version: { Platform: { Name: 'Docker Engine - Community' } },
})
t.is(f.engine, 'docker')
t.is(f.pods, false)
t.is(f.podmanSecrets, false)
t.ok(f.unsupported.includes('pods'))
})
test('envSwarmEnabled respects ENABLE_SWARM=0', (t) => {
+48
View File
@@ -0,0 +1,48 @@
import test from 'brittle'
import { listPodmanAuthFileCandidates } from '../server/services/libpod.js'
import { listPodmanSocketCandidates } from '../server/services/docker.js'
import { formatDeployError } from '../server/utils/dockerErrors.js'
// re-export check — auth candidates
test('listPodmanAuthFileCandidates includes runtime and config paths', (t) => {
const paths = listPodmanAuthFileCandidates(
{ XDG_RUNTIME_DIR: '/run/user/1000', REGISTRY_AUTH_FILE: '/custom/auth.json' },
'/home/user'
)
t.ok(paths.includes('/custom/auth.json'))
t.ok(paths.some((p) => p.includes('/run/user/1000/containers/auth.json')))
t.ok(paths.some((p) => p.includes('/home/user/.config/containers/auth.json')))
t.ok(paths.some((p) => p.includes('.docker/config.json')))
})
test('listPodmanSocketCandidates includes machine variants', (t) => {
const paths = listPodmanSocketCandidates({ XDG_RUNTIME_DIR: '/run/user/42' }, '/home/user')
t.ok(paths.some((p) => p.includes('podman-machine-default')))
t.ok(paths.some((p) => p.includes('libkrun')))
t.ok(paths.some((p) => p.includes('/run/user/42/podman')))
})
test('formatDeployError explains rootless privileged port', (t) => {
const err = formatDeployError(
new Error('rootlessport cannot expose privileged port 80'),
{ containerName: 'web', stage: 'start' }
)
t.ok(/rootless|1024|privileged/i.test(err.message))
t.ok(/8080|port/i.test(err.message))
})
test('formatDeployError explains SELinux mount', (t) => {
const err = formatDeployError(
new Error('permission denied while mounting bind: selinux relabel failed'),
{ containerName: 'db', stage: 'create' }
)
t.ok(/SELinux|:Z|:z/i.test(err.message))
})
test('formatDeployError explains missing podman socket', (t) => {
const err = formatDeployError(
new Error('connect ENOENT /run/user/1000/podman/podman.sock'),
{ stage: 'list' }
)
t.ok(/podman\.socket|linger|DOCKER_HOST/i.test(err.message))
})
+3
View File
@@ -34,6 +34,8 @@ const REQUIRED_IDS = [
'deploy-view',
'tunnels-view',
'swarm-view',
'pods-view',
'pods-list-body',
'host-view',
'settings-view',
'settings-panel-peers',
@@ -50,6 +52,7 @@ const REQUIRED_IDS = [
const REQUIRED_SNIPPETS = [
'data-view="tunnels"',
'data-view="swarm"',
'data-view="pods"',
'data-view="registry"',
'data-settings-tab="peers"',
'ENABLE_HOLESAIL',
+4 -2
View File
@@ -1492,10 +1492,12 @@ body {
}
}
/* Engine feature gating — Swarm/plugins hidden on Podman */
/* Engine feature gating — Swarm/plugins hidden on Podman; pods hidden on Docker */
.engine-feature-hidden,
body.feature-swarm-off [data-view="swarm"],
body.feature-plugins-off [data-feature="plugins"] {
body.feature-plugins-off [data-feature="plugins"],
body.feature-pods-off [data-view="pods"],
body.feature-pods-off [data-feature="pods"] {
display: none !important;
}
+215
View File
@@ -213,6 +213,7 @@ export function startListAutoRefresh(seconds) {
else if (view === 'tunnels') loadTunnelsView({ silent: true })
else if (view === 'events') loadEventsView({ silent: true })
else if (view === 'swarm') loadSwarmView({ silent: true })
else if (view === 'pods') loadPodsView({ silent: true })
try {
window.peardockUx?.stampOnPoll?.()
} catch {
@@ -1322,6 +1323,168 @@ export async function loadSwarmView(opts = {}) {
}
}
/**
* Podman pods + file-backed secrets (Libpod).
*/
export async function loadPodsView(opts = {}) {
const silent = opts.silent === true
const banner = document.getElementById('pods-status-banner')
if (!manager.active?.connected) {
if (banner) {
banner.className = 'alert alert-warning small mb-3'
banner.textContent = 'Connect a peer to view pods.'
}
return
}
const features = manager.active?.features
if (features && features.pods === false) {
if (banner) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML =
'Pods are a <strong>Podman-only</strong> feature. Connect a peer running Podman, or use Containers / Stacks on Docker.'
}
return
}
try {
const [podsRes, secretsRes] = await Promise.all([
manager.request(Methods.listPods, {}).catch((e) => {
throw e
}),
manager.request(Methods.listPodmanSecrets, {}).catch(() => ({ data: [] })),
])
const pods = podsRes?.data || []
const secrets = secretsRes?.data || []
if (banner) {
banner.className = 'alert alert-success small mb-3'
const rootless =
features?.rootless === true
? ' · rootless'
: features?.rootless === false
? ' · rootful'
: ''
banner.innerHTML = `Podman Libpod · <strong>${pods.length}</strong> pod(s) · <strong>${secrets.length}</strong> secret(s)${rootless}`
if (Array.isArray(features?.notes) && features.notes.length) {
banner.innerHTML +=
'<div class="mt-1 small opacity-75">' +
features.notes
.slice(0, 3)
.map((n) => escape(String(n)))
.join(' · ') +
'</div>'
}
}
const body = document.getElementById('pods-list-body')
if (body) {
body.innerHTML = pods.length
? pods
.map((p) => {
const name = p.Name || p.name || '—'
const id = String(p.Id || p.ID || p.id || '')
const status = p.Status || p.status || p.State || '—'
const nCont =
p.Containers != null
? Array.isArray(p.Containers)
? p.Containers.length
: typeof p.Containers === 'object'
? Object.keys(p.Containers).length
: p.NumContainers ?? '—'
: p.NumContainers ?? '—'
return `<tr data-row-key="pod:${escape(id)}">
<td>${escape(String(name))}</td>
<td>${escape(String(status))}</td>
<td>${escape(String(nCont))}</td>
<td class="small font-monospace">${escape(id.slice(0, 12))}</td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-outline-success pod-start-btn" data-id="${escape(id || name)}" data-min-role="operator" title="Start">Start</button>
<button type="button" class="btn btn-sm btn-outline-warning pod-stop-btn" data-id="${escape(id || name)}" data-min-role="operator" title="Stop">Stop</button>
<button type="button" class="btn btn-sm btn-outline-danger pod-rm-btn" data-id="${escape(id || name)}" data-min-role="admin" title="Remove">Rm</button>
</td>
</tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No pods</td></tr>'
}
const secBody = document.getElementById('podman-secrets-body')
if (secBody) {
secBody.innerHTML = secrets.length
? secrets
.map((s) => {
const name = s.Spec?.Name || s.Name || s.name || '—'
const id = String(s.ID || s.Id || s.id || '')
const driver = s.Spec?.Driver?.Name || s.Driver?.Name || s.driver || 'file'
return `<tr data-row-key="psec:${escape(id)}">
<td>${escape(String(name))}</td>
<td class="small">${escape(String(driver))}</td>
<td class="small font-monospace">${escape(id.slice(0, 12))}</td>
<td><button type="button" class="btn btn-sm btn-outline-danger podman-secret-rm" data-id="${escape(id || name)}" data-min-role="admin">Remove</button></td>
</tr>`
})
.join('')
: '<tr><td colspan="4" class="text-muted">No secrets</td></tr>'
}
document.querySelectorAll('.pod-start-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await manager.request(Methods.startPod, { id: btn.getAttribute('data-id') })
showAlert('success', 'Pod started')
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'startPod', { showAlert })
}
})
})
document.querySelectorAll('.pod-stop-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
try {
await manager.request(Methods.stopPod, { id: btn.getAttribute('data-id') })
showAlert('success', 'Pod stopped')
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'stopPod', { showAlert })
}
})
})
document.querySelectorAll('.pod-rm-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this pod and its infra container?')) return
try {
await manager.request(Methods.removePod, { id: btn.getAttribute('data-id'), force: true })
showAlert('success', 'Pod removed')
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'removePod', { showAlert })
}
})
})
document.querySelectorAll('.podman-secret-rm').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm('Remove this Podman secret?')) return
try {
await manager.request(Methods.removePodmanSecret, { id: btn.getAttribute('data-id') })
showAlert('success', 'Secret removed')
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'removePodmanSecret', { showAlert })
}
})
})
} catch (err) {
const code = err?.code || ''
const msg = err?.message || String(err)
if (banner) {
if (code === 'FEATURE_DISABLED' || /Podman|Libpod|FEATURE/i.test(msg)) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML =
'Pods require <strong>Podman</strong> with a live API socket (<code>systemctl --user enable --now podman.socket</code>).'
} else {
banner.className = 'alert alert-danger small mb-3'
banner.textContent = msg
}
}
if (!silent) presentError(err, 'listPods', { showAlert, silent: /FEATURE_DISABLED|Podman/i.test(msg) })
}
}
export async function connectLocalTunnel(url, opts = {}) {
if (!url) {
showAlert('warning', 'No tunnel URL')
@@ -1690,6 +1853,10 @@ export function openPalette(navigateToView) {
...(manager.active?.features?.swarm === false
? []
: [{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' }]),
// Pods only on Podman
...(manager.active?.features?.pods === true
? [{ label: 'Pods', icon: 'fa-cubes', view: 'pods', keywords: 'g k podman secrets' }]
: []),
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
{
@@ -1845,6 +2012,53 @@ export function initOpsApp({ navigateToView, sendCommand }) {
panel.classList.toggle('hidden', panel.id !== `swarm-panel-${tab}`)
})
})
document.getElementById('pods-refresh-btn')?.addEventListener('click', () => loadPodsView())
document.getElementById('pods-tabs')?.addEventListener('click', (e) => {
const btn = e.target?.closest?.('[data-pods-tab]')
if (!btn) return
const tab = btn.getAttribute('data-pods-tab')
document.querySelectorAll('#pods-tabs .nav-link').forEach((el) => {
el.classList.toggle('active', el === btn)
})
document.querySelectorAll('.pods-panel').forEach((panel) => {
panel.classList.toggle('hidden', panel.id !== `pods-panel-${tab}`)
})
})
document.getElementById('pod-create-btn')?.addEventListener('click', async () => {
const name = document.getElementById('pod-create-name')?.value?.trim()
if (!name) {
showAlert('warning', 'Pod name required')
return
}
try {
await manager.request(Methods.createPod, { name })
showAlert('success', `Pod "${name}" created`)
const input = document.getElementById('pod-create-name')
if (input) input.value = ''
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'createPod', { showAlert })
}
})
document.getElementById('podman-secret-create')?.addEventListener('click', async () => {
const name = document.getElementById('podman-secret-name')?.value?.trim()
const data = document.getElementById('podman-secret-data')?.value
if (!name || !data) {
showAlert('warning', 'Secret name and value required')
return
}
try {
await manager.request(Methods.createPodmanSecret, { name, data })
showAlert('success', `Secret "${name}" created`)
const n = document.getElementById('podman-secret-name')
const d = document.getElementById('podman-secret-data')
if (n) n.value = ''
if (d) d.value = ''
loadPodsView({ silent: true })
} catch (err) {
presentError(err, 'createPodmanSecret', { showAlert })
}
})
document.getElementById('tunnels-list')?.addEventListener('click', async (e) => {
const t = e.target
const copyBtn = t?.closest?.('.tunnel-copy-btn')
@@ -2091,6 +2305,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
loadEventsView,
loadTunnelsView,
loadSwarmView,
loadPodsView,
loadSettingsView,
createTunnelFromForm,
closeTunnelById,
+10
View File
@@ -16,6 +16,7 @@ const GO_MAP = {
v: 'volumes',
s: 'stacks',
w: 'swarm',
k: 'pods',
e: 'events',
h: 'host',
t: 'tunnels',
@@ -323,6 +324,15 @@ export function initTrackGUx(ctx = {}) {
typeof window !== 'undefined' ? window.manager?.active?.features?.swarm : undefined
if (swarmOn === false) return
}
// Pods only on Podman
if (dest === 'pods') {
const podsOn =
typeof window !== 'undefined' ? window.manager?.active?.features?.pods : undefined
if (podsOn === false || podsOn == null) {
// null/undefined: allow navigation; applyEngineUI will redirect if unsupported
if (podsOn === false) return
}
}
// Compound targets: "settings:peers" → settings view + peers subtab
if (String(dest).includes(':')) {
const [view, tab] = String(dest).split(':')