Add Podman Libpod pods, secrets, and operator hardening.
Release rolling / release (push) Successful in 7m56s
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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 user’s rootless socket.
|
||||
|
||||
**Privileged ports:** rootless cannot bind host ports < 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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -100,13 +100,28 @@ 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.40–1.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+65
-43
@@ -66,60 +66,82 @@ 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
|
||||
}
|
||||
if (event.status === 'undefined') continue
|
||||
|
||||
logger.info('Docker event', {
|
||||
status: event.status,
|
||||
id: event.id,
|
||||
type: event.Type,
|
||||
action: event.Action,
|
||||
})
|
||||
|
||||
peers.broadcast(Pushes.dockerEvent, {
|
||||
type: 'dockerEvent',
|
||||
data: event,
|
||||
})
|
||||
|
||||
// Do not rebroadcast the full list on every exec_*/health_status event —
|
||||
// that thrashed clients (rows flicker / disappear on each refresh).
|
||||
if (event.Type === 'container') {
|
||||
const action = String(event.Action || event.status || '').split(':')[0]
|
||||
if (CONTAINER_LIST_ACTIONS.has(action)) {
|
||||
scheduleContainerListBroadcast()
|
||||
// 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.Type === 'image') {
|
||||
try {
|
||||
const images = await docker.listImages({ all: true })
|
||||
peers.broadcast(Pushes.images, { type: 'images', data: images })
|
||||
} catch (e) {
|
||||
logger.debug('image list on event failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
for (const event of events) {
|
||||
if (!event || event.status === 'undefined') continue
|
||||
|
||||
if (event.Type === 'volume' && (event.Action === 'create' || event.Action === 'destroy')) {
|
||||
const volumesResult = await docker.listVolumes()
|
||||
const volumesList = extractVolumesList(volumesResult)
|
||||
peers.broadcast(Pushes.volumes, {
|
||||
type: 'volumes',
|
||||
data: volumesList,
|
||||
success: true,
|
||||
volumes: volumesList,
|
||||
logger.info('Docker event', {
|
||||
status: event.status,
|
||||
id: event.id,
|
||||
type: event.Type,
|
||||
action: event.Action,
|
||||
})
|
||||
|
||||
peers.broadcast(Pushes.dockerEvent, {
|
||||
type: 'dockerEvent',
|
||||
data: event,
|
||||
})
|
||||
|
||||
// Do not rebroadcast the full list on every exec_*/health_status event —
|
||||
// that thrashed clients (rows flicker / disappear on each refresh).
|
||||
if (event.Type === 'container') {
|
||||
const action = String(event.Action || event.status || '').split(':')[0]
|
||||
if (CONTAINER_LIST_ACTIONS.has(action)) {
|
||||
scheduleContainerListBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
if (event.Type === 'image') {
|
||||
try {
|
||||
const images = await docker.listImages({ all: true })
|
||||
peers.broadcast(Pushes.images, { type: 'images', data: images })
|
||||
} catch (e) {
|
||||
logger.debug('image list on event failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.Type === 'volume' &&
|
||||
(event.Action === 'create' || event.Action === 'destroy')
|
||||
) {
|
||||
try {
|
||||
const volumesResult = await docker.listVolumes()
|
||||
const volumesList = extractVolumesList(volumesResult)
|
||||
peers.broadcast(Pushes.volumes, {
|
||||
type: 'volumes',
|
||||
data: volumesList,
|
||||
success: true,
|
||||
volumes: volumesList,
|
||||
})
|
||||
} catch (e) {
|
||||
logger.debug('volume list on event failed', { error: e.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 Docker’s 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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(':')
|
||||
|
||||
Reference in New Issue
Block a user