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) |
|
| `ENABLE_PLUGINS` | Off unless `1` (Docker only; never on Podman) |
|
||||||
| `PEARDOCK_ENGINE` | Prefer `docker` or `podman` (default: Docker socket first, then Podman) |
|
| `PEARDOCK_ENGINE` | Prefer `docker` or `podman` (default: Docker socket first, then Podman) |
|
||||||
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit engine socket (`unix://…`); wins over auto-detect |
|
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit engine socket (`unix://…`); wins over auto-detect |
|
||||||
|
| `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` |
|
| `PEARDOCK_MAX_TUNNELS` | `20` |
|
||||||
| `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald |
|
| `LOG_LEVEL` / `LOG_FORMAT` | `info` / `json` recommended under journald |
|
||||||
|
|
||||||
|
|||||||
@@ -1050,6 +1050,8 @@ function navigateToView(viewName, opts = {}) {
|
|||||||
loadStacks();
|
loadStacks();
|
||||||
} else if (viewName === 'swarm') {
|
} else if (viewName === 'swarm') {
|
||||||
window.peardockOps?.loadSwarmView?.();
|
window.peardockOps?.loadSwarmView?.();
|
||||||
|
} else if (viewName === 'pods') {
|
||||||
|
window.peardockOps?.loadPodsView?.();
|
||||||
} else if (viewName === 'deploy') {
|
} else if (viewName === 'deploy') {
|
||||||
loadDeployView();
|
loadDeployView();
|
||||||
} else if (viewName === 'add-container') {
|
} else if (viewName === 'add-container') {
|
||||||
@@ -1715,12 +1717,21 @@ function applyEngineUI() {
|
|||||||
'docker';
|
'docker';
|
||||||
const swarmOn = features ? features.swarm !== false : engine !== 'podman';
|
const swarmOn = features ? features.swarm !== false : engine !== 'podman';
|
||||||
const pluginsOn = features ? features.plugins === true : false;
|
const pluginsOn = features ? features.plugins === true : false;
|
||||||
|
const podsOn = features ? features.pods === true : engine === 'podman';
|
||||||
|
|
||||||
document.body.dataset.engine = engine;
|
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-podman', engine === 'podman');
|
||||||
document.body.classList.toggle('engine-docker', engine === 'docker');
|
document.body.classList.toggle('engine-docker', engine === 'docker');
|
||||||
document.body.classList.toggle('feature-swarm-off', !swarmOn);
|
document.body.classList.toggle('feature-swarm-off', !swarmOn);
|
||||||
document.body.classList.toggle('feature-plugins-off', !pluginsOn);
|
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"
|
// Sidebar + any nav with data-view="swarm"
|
||||||
document.querySelectorAll('[data-view="swarm"]').forEach((el) => {
|
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) => {
|
document.querySelectorAll('[data-feature="swarm"]').forEach((el) => {
|
||||||
el.classList.toggle('engine-feature-hidden', !swarmOn);
|
el.classList.toggle('engine-feature-hidden', !swarmOn);
|
||||||
});
|
});
|
||||||
@@ -1740,10 +1761,26 @@ function applyEngineUI() {
|
|||||||
el.classList.toggle('engine-feature-hidden', !pluginsOn);
|
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 (!swarmOn && typeof currentView !== 'undefined' && currentView === 'swarm') {
|
||||||
if (typeof navigateToView === 'function') navigateToView('containers');
|
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
|
// Host CLI button label
|
||||||
document.querySelectorAll('.docker-terminal-btn, [data-cli-label]').forEach((el) => {
|
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'),
|
engineLabel: res.docker.label || (eng === 'podman' ? 'Podman' : 'Docker'),
|
||||||
swarm: eng !== 'podman',
|
swarm: eng !== 'podman',
|
||||||
plugins: false,
|
plugins: false,
|
||||||
|
pods: eng === 'podman',
|
||||||
|
podmanSecrets: eng === 'podman',
|
||||||
compose: true,
|
compose: true,
|
||||||
stacks: true,
|
stacks: true,
|
||||||
unsupported: eng === 'podman' ? ['swarm', 'plugins'] : [],
|
unsupported: eng === 'podman' ? ['swarm', 'plugins'] : ['pods', 'podmanSecrets'],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (res?.docker && res.docker.ok === false) {
|
if (res?.docker && res.docker.ok === false) {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ Description=peardock HyperDHT Docker control plane
|
|||||||
Documentation=https://git.ssh.surf/snxraven/peardock
|
Documentation=https://git.ssh.surf/snxraven/peardock
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=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
|
Wants=docker.service
|
||||||
|
# Rootful Podman API (optional; ignore if unit missing)
|
||||||
|
# Wants=podman.socket
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
@@ -30,9 +32,12 @@ EnvironmentFile=-/opt/peardock/.env
|
|||||||
# Example production knobs (prefer .env):
|
# Example production knobs (prefer .env):
|
||||||
# Environment=PEARDOCK_DEFAULT_ROLE=operator
|
# Environment=PEARDOCK_DEFAULT_ROLE=operator
|
||||||
# Environment=PEARDOCK_PEER_ALLOWLIST=1
|
# 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_SWARM=0
|
||||||
# Environment=ENABLE_HOLESAIL=0
|
# Environment=ENABLE_HOLESAIL=0
|
||||||
|
# Environment=DOCKER_API_VERSION=v1.41
|
||||||
|
|
||||||
ReadWritePaths=/opt/peardock
|
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` |
|
| Holesail tunnels | shipped | **on** | `handlers/tunnels.js`, `services/holesail-tunnels.js` |
|
||||||
| Docker Swarm | shipped | **on** (Docker only; hidden on Podman) | `handlers/swarm.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` |
|
| 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` |
|
| Suggestions / smart defaults | shipped | on | `handlers/suggestions.js` |
|
||||||
| Binary streams | shipped | on | `rpc/binary-stream.js` |
|
| Binary streams | shipped | on | `rpc/binary-stream.js` |
|
||||||
| Multi-peer fleet UI | shipped | on | `client/manager.js` |
|
| Multi-peer fleet UI | shipped | on | `client/manager.js` |
|
||||||
|
|||||||
+40
-7
@@ -70,37 +70,70 @@ Symptoms of a missing group: empty container lists, start failures, “permissio
|
|||||||
|
|
||||||
### Podman (no Docker)
|
### Podman (no Docker)
|
||||||
|
|
||||||
If no Docker socket is found, the server automatically falls back to a **Podman** socket (Docker-compatible API via dockerode). 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 |
|
| 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=podman` | Prefer Podman sockets |
|
||||||
| `PEARDOCK_ENGINE=docker` | Docker only (no Podman fallback) |
|
| `PEARDOCK_ENGINE=docker` | Docker only (no Podman fallback) |
|
||||||
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit `unix://…` path wins |
|
| `DOCKER_HOST` / `CONTAINER_HOST` | Explicit `unix://…` path wins |
|
||||||
| `PODMAN_SOCK` | Extra Podman path candidate |
|
| `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
|
```bash
|
||||||
# Rootless example
|
|
||||||
systemctl --user enable --now podman.socket
|
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
|
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
|
||||||
# or: PEARDOCK_ENGINE=podman
|
# 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 |
|
| Surface on Podman | Status |
|
||||||
|-------------------|--------|
|
|-------------------|--------|
|
||||||
| Containers, images, volumes, networks, logs, terminals, stats, events | Supported |
|
| Containers, images, volumes, networks, logs, terminals, stats, events | Supported |
|
||||||
| Deploy / add container / always-pull / duplicate | Supported |
|
| Deploy / add container / always-pull / duplicate | Supported |
|
||||||
| Stacks + compose CLI (`podman compose` / `podman-compose`) | 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 |
|
| 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 Swarm** (services/nodes/tasks/secrets/configs) | **Not available** — UI hidden |
|
||||||
| **Docker Engine plugins** | **Not available** — remains off |
|
| **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
|
## Manual install from source
|
||||||
|
|
||||||
|
|||||||
+57
@@ -166,6 +166,12 @@
|
|||||||
<span class="nav-label">Swarm</span>
|
<span class="nav-label">Swarm</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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-group-label">Build & storage</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="#" class="nav-link" data-view="images" title="Images">
|
<a href="#" class="nav-link" data-view="images" title="Images">
|
||||||
@@ -2167,6 +2173,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Swarm view (on by default; ENABLE_SWARM=0 to disable) -->
|
||||||
<div id="swarm-view" class="view hidden">
|
<div id="swarm-view" class="view hidden">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
|
|||||||
@@ -142,11 +142,15 @@ export function addcAddVolume() {
|
|||||||
<label class="volume-field-label">Container path</label>
|
<label class="volume-field-label">Container path</label>
|
||||||
<input type="text" class="form-control bg-dark text-white volume-container-input" placeholder="/data" required>
|
<input type="text" class="form-control bg-dark text-white volume-container-input" placeholder="/data" required>
|
||||||
</div>
|
</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>
|
<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="rw">RW</option>
|
||||||
<option value="ro">RO</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger align-self-end" data-addc-remove title="Remove">
|
<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 * as validation from '../utils/validation.js'
|
||||||
import { Pushes } from '../../shared/protocol.js'
|
import { Pushes } from '../../shared/protocol.js'
|
||||||
import { Roles } from '../../shared/protocol.js'
|
import { Roles } from '../../shared/protocol.js'
|
||||||
import {
|
import { containerEngineKind } from '../services/docker.js'
|
||||||
containerEngineKind,
|
import { containerCliEnvExtras } from '../services/libpod.js'
|
||||||
dockerSocketPath,
|
|
||||||
} from '../services/docker.js'
|
|
||||||
|
|
||||||
const UNRESTRICTED =
|
const UNRESTRICTED =
|
||||||
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
|
process.env.PEARDOCK_UNRESTRICTED_CLI === '1' ||
|
||||||
@@ -79,14 +77,13 @@ export function registerDockerCliHandlers(session) {
|
|||||||
|
|
||||||
const { executable, cmdArgs } = parseContainerCliCommand(commandStr)
|
const { executable, cmdArgs } = parseContainerCliCommand(commandStr)
|
||||||
const connectionId = args.connectionId
|
const connectionId = args.connectionId
|
||||||
const unix = `unix://${dockerSocketPath}`
|
const envExtra = containerCliEnvExtras()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(executable, cmdArgs, {
|
const child = spawn(executable, cmdArgs, {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
DOCKER_HOST: unix,
|
...envExtra,
|
||||||
CONTAINER_HOST: unix,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
let settled = false
|
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 platform = null
|
||||||
let error = null
|
let error = null
|
||||||
let version = null
|
let version = null
|
||||||
|
let info = null
|
||||||
try {
|
try {
|
||||||
version = await docker.version()
|
version = await docker.version()
|
||||||
dockerOk = true
|
dockerOk = true
|
||||||
apiVersion = version.ApiVersion || version.apiVersion || null
|
apiVersion = version.ApiVersion || version.apiVersion || null
|
||||||
osType = version.Os || version.os || null
|
osType = version.Os || version.os || null
|
||||||
platform = version.Platform?.Name || version.Platform || null
|
platform = version.Platform?.Name || version.Platform || null
|
||||||
|
try {
|
||||||
|
info = await docker.info()
|
||||||
|
} catch {
|
||||||
|
info = null
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message
|
error = err.message
|
||||||
}
|
}
|
||||||
const features = getEngineFeatures({ version })
|
const features = getEngineFeatures({ version, info })
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pong: Date.now(),
|
pong: Date.now(),
|
||||||
@@ -56,6 +62,7 @@ export function registerSystemHandlers(session) {
|
|||||||
engine: features.engine,
|
engine: features.engine,
|
||||||
socketPath: features.socketPath,
|
socketPath: features.socketPath,
|
||||||
label: features.engineLabel,
|
label: features.engineLabel,
|
||||||
|
rootless: features.rootless,
|
||||||
},
|
},
|
||||||
features,
|
features,
|
||||||
}
|
}
|
||||||
@@ -63,7 +70,7 @@ export function registerSystemHandlers(session) {
|
|||||||
|
|
||||||
session.respond('getSystemInfo', async () => {
|
session.respond('getSystemInfo', async () => {
|
||||||
const [info, version] = await Promise.all([docker.info(), docker.version()])
|
const [info, version] = await Promise.all([docker.info(), docker.version()])
|
||||||
const features = getEngineFeatures({ version })
|
const features = getEngineFeatures({ version, info })
|
||||||
return {
|
return {
|
||||||
type: 'systemInfo',
|
type: 'systemInfo',
|
||||||
data: { info, version, engine: features },
|
data: { info, version, engine: features },
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { registerRegistryHandlers } from '../handlers/registry.js'
|
|||||||
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
||||||
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
||||||
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
||||||
|
import { registerPodHandlers } from '../handlers/pods.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('./session.js').PeerSession} session
|
* @param {import('./session.js').PeerSession} session
|
||||||
@@ -38,6 +39,7 @@ export function registerAllHandlers(session) {
|
|||||||
registerDockerCliHandlers(session)
|
registerDockerCliHandlers(session)
|
||||||
registerSwarmHandlers(session)
|
registerSwarmHandlers(session)
|
||||||
registerPluginHandlers(session)
|
registerPluginHandlers(session)
|
||||||
|
registerPodHandlers(session)
|
||||||
registerPeerHandlers(session)
|
registerPeerHandlers(session)
|
||||||
registerVaultHandlers(session)
|
registerVaultHandlers(session)
|
||||||
registerRegistryHandlers(session)
|
registerRegistryHandlers(session)
|
||||||
|
|||||||
@@ -100,12 +100,27 @@ export function listPodmanSocketCandidates(env = process.env, homedir = os.homed
|
|||||||
push('/run/podman/podman.sock')
|
push('/run/podman/podman.sock')
|
||||||
push('/var/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) {
|
if (homedir) {
|
||||||
push(`${homedir}/.local/share/containers/podman/machine/podman.sock`)
|
const machineRoots = [
|
||||||
push(`${homedir}/.local/share/containers/podman/machine/qemu/podman.sock`)
|
`${homedir}/.local/share/containers/podman/machine`,
|
||||||
push(`${homedir}/.local/share/containers/podman/machine/applehv/podman.sock`)
|
`${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
|
return out
|
||||||
}
|
}
|
||||||
@@ -256,12 +271,30 @@ export function resolveContainerSocket(opts = {}) {
|
|||||||
const resolvedSocket = resolveContainerSocket()
|
const resolvedSocket = resolveContainerSocket()
|
||||||
const socketPath = resolvedSocket.path
|
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({
|
export const docker = new Docker({
|
||||||
socketPath,
|
socketPath,
|
||||||
// Force unix-socket mode in docker-modem (do not set host)
|
// Force unix-socket mode in docker-modem (do not set host)
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
|
...(dockerApiVersion ? { version: dockerApiVersion } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export { dockerApiVersion }
|
||||||
|
|
||||||
export { socketPath as dockerSocketPath }
|
export { socketPath as dockerSocketPath }
|
||||||
export const containerEngineKind = resolvedSocket.kind
|
export const containerEngineKind = resolvedSocket.kind
|
||||||
export const containerSocketMeta = resolvedSocket
|
export const containerSocketMeta = resolvedSocket
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
*
|
*
|
||||||
* Docker: full surface (Swarm/plugins gated by env).
|
* Docker: full surface (Swarm/plugins gated by env).
|
||||||
* Podman: Docker-compatible Engine API for containers/images/networks/volumes/
|
* 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 {
|
import {
|
||||||
containerEngineKind,
|
containerEngineKind,
|
||||||
dockerSocketPath,
|
dockerSocketPath,
|
||||||
engineDisplayName,
|
engineDisplayName,
|
||||||
classifySocketPath,
|
classifySocketPath,
|
||||||
|
dockerApiVersion,
|
||||||
} from './docker.js'
|
} from './docker.js'
|
||||||
import { isHolesailEnabled } from './holesail-tunnels.js'
|
import { isHolesailEnabled } from './holesail-tunnels.js'
|
||||||
|
|
||||||
@@ -19,8 +21,13 @@ import { isHolesailEnabled } from './holesail-tunnels.js'
|
|||||||
* engine: EngineKind,
|
* engine: EngineKind,
|
||||||
* engineLabel: string,
|
* engineLabel: string,
|
||||||
* socketPath: string,
|
* socketPath: string,
|
||||||
|
* apiVersionPinned: string|null,
|
||||||
|
* rootless: boolean|null,
|
||||||
|
* selinux: boolean|null,
|
||||||
* swarm: boolean,
|
* swarm: boolean,
|
||||||
* plugins: boolean,
|
* plugins: boolean,
|
||||||
|
* pods: boolean,
|
||||||
|
* podmanSecrets: boolean,
|
||||||
* compose: boolean,
|
* compose: boolean,
|
||||||
* stacks: boolean,
|
* stacks: boolean,
|
||||||
* containers: boolean,
|
* containers: boolean,
|
||||||
@@ -37,6 +44,7 @@ import { isHolesailEnabled } from './holesail-tunnels.js'
|
|||||||
* dockerCli: boolean,
|
* dockerCli: boolean,
|
||||||
* podmanCli: boolean,
|
* podmanCli: boolean,
|
||||||
* unsupported: string[],
|
* unsupported: string[],
|
||||||
|
* notes: string[],
|
||||||
* }} EngineFeatures
|
* }} EngineFeatures
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -93,15 +101,52 @@ export function engineKindFromVersion(version, fallback = containerEngineKind) {
|
|||||||
*/
|
*/
|
||||||
export function effectiveEngineKind(version = null) {
|
export function effectiveEngineKind(version = null) {
|
||||||
if (version) return engineKindFromVersion(version, containerEngineKind)
|
if (version) return engineKindFromVersion(version, containerEngineKind)
|
||||||
// Socket path classification is the boot-time truth
|
|
||||||
const fromPath = classifySocketPath(dockerSocketPath)
|
const fromPath = classifySocketPath(dockerSocketPath)
|
||||||
if (fromPath !== 'unknown') return fromPath
|
if (fromPath !== 'unknown') return fromPath
|
||||||
return containerEngineKind
|
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.
|
* Full feature flags for clients / banner / metrics.
|
||||||
* @param {{ version?: object|null }} [opts]
|
* @param {{ version?: object|null, info?: object|null }} [opts]
|
||||||
* @returns {EngineFeatures}
|
* @returns {EngineFeatures}
|
||||||
*/
|
*/
|
||||||
export function getEngineFeatures(opts = {}) {
|
export function getEngineFeatures(opts = {}) {
|
||||||
@@ -109,16 +154,49 @@ export function getEngineFeatures(opts = {}) {
|
|||||||
const isPodman = engine === 'podman'
|
const isPodman = engine === 'podman'
|
||||||
const swarm = !isPodman && envSwarmEnabled()
|
const swarm = !isPodman && envSwarmEnabled()
|
||||||
const plugins = !isPodman && envPluginsEnabled()
|
const plugins = !isPodman && envPluginsEnabled()
|
||||||
|
const pods = isPodman
|
||||||
|
const podmanSecrets = isPodman
|
||||||
|
const rootless = detectRootless(opts.info)
|
||||||
|
const selinux = detectSelinux(opts.info)
|
||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const unsupported = []
|
const unsupported = []
|
||||||
if (isPodman) {
|
if (isPodman) {
|
||||||
unsupported.push('swarm', 'plugins')
|
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 {
|
return {
|
||||||
engine,
|
engine,
|
||||||
engineLabel: engineDisplayName(engine),
|
engineLabel: engineDisplayName(engine),
|
||||||
socketPath: dockerSocketPath,
|
socketPath: dockerSocketPath,
|
||||||
|
apiVersionPinned: dockerApiVersion || null,
|
||||||
|
rootless,
|
||||||
|
selinux,
|
||||||
// Always-on Engine API surface (Docker + Podman)
|
// Always-on Engine API surface (Docker + Podman)
|
||||||
containers: true,
|
containers: true,
|
||||||
images: true,
|
images: true,
|
||||||
@@ -136,9 +214,12 @@ export function getEngineFeatures(opts = {}) {
|
|||||||
// Engine-specific
|
// Engine-specific
|
||||||
swarm,
|
swarm,
|
||||||
plugins,
|
plugins,
|
||||||
|
pods,
|
||||||
|
podmanSecrets,
|
||||||
dockerCli: !isPodman,
|
dockerCli: !isPodman,
|
||||||
podmanCli: isPodman,
|
podmanCli: isPodman,
|
||||||
unsupported,
|
unsupported,
|
||||||
|
notes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,3 +237,17 @@ export function isSwarmFeatureAvailable() {
|
|||||||
export function isPluginsFeatureAvailable() {
|
export function isPluginsFeatureAvailable() {
|
||||||
return getEngineFeatures().plugins
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,20 +66,34 @@ async function openEventStream() {
|
|||||||
|
|
||||||
stream.on('data', async (chunk) => {
|
stream.on('data', async (chunk) => {
|
||||||
try {
|
try {
|
||||||
const lines = chunk
|
// Podman/Docker may batch multiple JSON objects; tolerate partial lines
|
||||||
.toString()
|
const text = chunk.toString()
|
||||||
.split('\n')
|
const lines = text
|
||||||
|
.split(/\r?\n/)
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
let event
|
/** @type {object[]} */
|
||||||
|
const events = []
|
||||||
try {
|
try {
|
||||||
event = JSON.parse(line)
|
events.push(JSON.parse(line))
|
||||||
} catch {
|
} 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', {
|
logger.info('Docker event', {
|
||||||
status: event.status,
|
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 volumesResult = await docker.listVolumes()
|
||||||
const volumesList = extractVolumesList(volumesResult)
|
const volumesList = extractVolumesList(volumesResult)
|
||||||
peers.broadcast(Pushes.volumes, {
|
peers.broadcast(Pushes.volumes, {
|
||||||
@@ -120,6 +138,10 @@ async function openEventStream() {
|
|||||||
success: true,
|
success: true,
|
||||||
volumes: volumesList,
|
volumes: volumesList,
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('volume list on event failed', { error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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,
|
containerEngineKind,
|
||||||
dockerSocketPath,
|
dockerSocketPath,
|
||||||
} from '../services/docker.js'
|
} from '../services/docker.js'
|
||||||
|
import { containerCliEnvExtras } from '../services/libpod.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve compose CLI binary + argv prefix based on preferred engine.
|
* Resolve compose CLI binary + argv prefix based on preferred engine.
|
||||||
* Docker: `docker compose …`
|
* Docker: `docker compose …`
|
||||||
* Podman: `podman compose …` (falls back to docker / podman-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]
|
* @param {string} [kind]
|
||||||
* @returns {{ cmd: string, prefix: string[], envExtra: Record<string, string> }}
|
* @returns {{ cmd: string, prefix: string[], envExtra: Record<string, string> }}
|
||||||
*/
|
*/
|
||||||
export function resolveComposeCli(kind = containerEngineKind) {
|
export function resolveComposeCli(kind = containerEngineKind) {
|
||||||
|
const envExtra = containerCliEnvExtras()
|
||||||
|
// ensure socket path is current even if libpod imported earlier
|
||||||
const unix = `unix://${dockerSocketPath}`
|
const unix = `unix://${dockerSocketPath}`
|
||||||
const envExtra = {
|
envExtra.DOCKER_HOST = unix
|
||||||
DOCKER_HOST: unix,
|
envExtra.CONTAINER_HOST = unix
|
||||||
CONTAINER_HOST: unix,
|
|
||||||
}
|
|
||||||
if (kind === 'podman') {
|
if (kind === 'podman') {
|
||||||
return { cmd: 'podman', prefix: ['compose'], envExtra }
|
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.'
|
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 ——
|
// —— Resources / runtime ——
|
||||||
else if (/cannot set memory|memory limit|out of memory|oci runtime/i.test(lower)) {
|
else if (/cannot set memory|memory limit|out of memory|oci runtime/i.test(lower)) {
|
||||||
title = `Runtime/resource error starting ${name}`
|
title = `Runtime/resource error starting ${name}`
|
||||||
detail = raw
|
detail = raw
|
||||||
fix =
|
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)) {
|
} else if (/permission denied|operation not permitted|cap_|apparmor|seccomp/i.test(lower)) {
|
||||||
title = `Permission denied starting ${name}`
|
title = `Permission denied starting ${name}`
|
||||||
detail = raw
|
detail = raw
|
||||||
fix =
|
fix =
|
||||||
'Remove restricted capabilities/security options, or run with the needed CapAdd/privileged flag only if you trust the image.'
|
'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/i.test(lower)) {
|
} else if (/driver failed programming external connectivity|iptables|firewall|pasta|slirp4netns/i.test(lower)) {
|
||||||
title = `Host networking/firewall blocked publish for ${name}`
|
title = `Host networking blocked publish for ${name}`
|
||||||
detail = raw
|
detail = raw
|
||||||
fix =
|
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 ——
|
// —— Engine down ——
|
||||||
else if (
|
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
|
lower
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
title = 'Cannot reach Docker Engine'
|
title = 'Cannot reach container engine'
|
||||||
detail = raw
|
detail = raw
|
||||||
fix =
|
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 ——
|
// —— Validation already descriptive ——
|
||||||
@@ -190,7 +221,7 @@ export function formatDeployError(err, ctx = {}) {
|
|||||||
const parts = [title]
|
const parts = [title]
|
||||||
if (detail && detail !== title) parts.push(detail)
|
if (detail && detail !== title) parts.push(detail)
|
||||||
if (fix) parts.push(`How to fix: ${fix}`)
|
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()
|
let message = parts.join(' — ').replace(/\s+/g, ' ').trim()
|
||||||
if (message.length > MAX_MSG) {
|
if (message.length > MAX_MSG) {
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export const MethodRoles = Object.freeze({
|
|||||||
inspectConfig: Roles.viewer,
|
inspectConfig: Roles.viewer,
|
||||||
listPlugins: Roles.viewer,
|
listPlugins: Roles.viewer,
|
||||||
inspectPlugin: Roles.viewer,
|
inspectPlugin: Roles.viewer,
|
||||||
|
// Podman Libpod pods + secrets
|
||||||
|
listPods: Roles.viewer,
|
||||||
|
inspectPod: Roles.viewer,
|
||||||
|
listPodmanSecrets: Roles.viewer,
|
||||||
|
inspectPodmanSecret: Roles.viewer,
|
||||||
serviceLogs: Roles.viewer,
|
serviceLogs: Roles.viewer,
|
||||||
getHostSnapshot: Roles.viewer,
|
getHostSnapshot: Roles.viewer,
|
||||||
suggestNetworkIPAM: Roles.viewer,
|
suggestNetworkIPAM: Roles.viewer,
|
||||||
@@ -138,6 +143,10 @@ export const MethodRoles = Object.freeze({
|
|||||||
updateService: Roles.operator,
|
updateService: Roles.operator,
|
||||||
scaleService: Roles.operator,
|
scaleService: Roles.operator,
|
||||||
swarmJoin: Roles.operator,
|
swarmJoin: Roles.operator,
|
||||||
|
createPod: Roles.operator,
|
||||||
|
startPod: Roles.operator,
|
||||||
|
stopPod: Roles.operator,
|
||||||
|
restartPod: Roles.operator,
|
||||||
|
|
||||||
// admin
|
// admin
|
||||||
removeContainer: Roles.admin,
|
removeContainer: Roles.admin,
|
||||||
@@ -177,6 +186,9 @@ export const MethodRoles = Object.freeze({
|
|||||||
removeSecret: Roles.admin,
|
removeSecret: Roles.admin,
|
||||||
createConfig: Roles.admin,
|
createConfig: Roles.admin,
|
||||||
removeConfig: Roles.admin,
|
removeConfig: Roles.admin,
|
||||||
|
removePod: Roles.admin,
|
||||||
|
createPodmanSecret: Roles.admin,
|
||||||
|
removePodmanSecret: Roles.admin,
|
||||||
swarmInit: Roles.admin,
|
swarmInit: Roles.admin,
|
||||||
swarmLeave: Roles.admin,
|
swarmLeave: Roles.admin,
|
||||||
swarmUpdate: Roles.admin,
|
swarmUpdate: Roles.admin,
|
||||||
@@ -397,6 +409,19 @@ export const Methods = Object.freeze({
|
|||||||
inspectPlugin: 'inspectPlugin',
|
inspectPlugin: 'inspectPlugin',
|
||||||
configurePlugin: 'configurePlugin',
|
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)
|
// Holesail tunnels (on by default; ENABLE_HOLESAIL=0 to disable)
|
||||||
listTunnels: 'listTunnels',
|
listTunnels: 'listTunnels',
|
||||||
getTunnel: 'getTunnel',
|
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
|
// Force via version refinement even if socket was classified docker
|
||||||
const f = getEngineFeatures({
|
const f = getEngineFeatures({
|
||||||
version: { Platform: { Name: 'podman' }, ApiVersion: '1.41' },
|
version: { Platform: { Name: 'podman' }, ApiVersion: '1.41' },
|
||||||
|
info: { Rootless: true, SecurityOptions: ['name=rootless', 'name=selinux'] },
|
||||||
})
|
})
|
||||||
t.is(f.engine, 'podman')
|
t.is(f.engine, 'podman')
|
||||||
t.is(f.swarm, false)
|
t.is(f.swarm, false)
|
||||||
t.is(f.plugins, 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.containers)
|
||||||
t.ok(f.compose)
|
t.ok(f.compose)
|
||||||
t.ok(f.stacks)
|
t.ok(f.stacks)
|
||||||
t.ok(f.unsupported.includes('swarm'))
|
t.ok(f.unsupported.includes('swarm'))
|
||||||
t.ok(f.unsupported.includes('plugins'))
|
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) => {
|
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',
|
'deploy-view',
|
||||||
'tunnels-view',
|
'tunnels-view',
|
||||||
'swarm-view',
|
'swarm-view',
|
||||||
|
'pods-view',
|
||||||
|
'pods-list-body',
|
||||||
'host-view',
|
'host-view',
|
||||||
'settings-view',
|
'settings-view',
|
||||||
'settings-panel-peers',
|
'settings-panel-peers',
|
||||||
@@ -50,6 +52,7 @@ const REQUIRED_IDS = [
|
|||||||
const REQUIRED_SNIPPETS = [
|
const REQUIRED_SNIPPETS = [
|
||||||
'data-view="tunnels"',
|
'data-view="tunnels"',
|
||||||
'data-view="swarm"',
|
'data-view="swarm"',
|
||||||
|
'data-view="pods"',
|
||||||
'data-view="registry"',
|
'data-view="registry"',
|
||||||
'data-settings-tab="peers"',
|
'data-settings-tab="peers"',
|
||||||
'ENABLE_HOLESAIL',
|
'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,
|
.engine-feature-hidden,
|
||||||
body.feature-swarm-off [data-view="swarm"],
|
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;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+215
@@ -213,6 +213,7 @@ export function startListAutoRefresh(seconds) {
|
|||||||
else if (view === 'tunnels') loadTunnelsView({ silent: true })
|
else if (view === 'tunnels') loadTunnelsView({ silent: true })
|
||||||
else if (view === 'events') loadEventsView({ silent: true })
|
else if (view === 'events') loadEventsView({ silent: true })
|
||||||
else if (view === 'swarm') loadSwarmView({ silent: true })
|
else if (view === 'swarm') loadSwarmView({ silent: true })
|
||||||
|
else if (view === 'pods') loadPodsView({ silent: true })
|
||||||
try {
|
try {
|
||||||
window.peardockUx?.stampOnPoll?.()
|
window.peardockUx?.stampOnPoll?.()
|
||||||
} catch {
|
} 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 = {}) {
|
export async function connectLocalTunnel(url, opts = {}) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showAlert('warning', 'No tunnel URL')
|
showAlert('warning', 'No tunnel URL')
|
||||||
@@ -1690,6 +1853,10 @@ export function openPalette(navigateToView) {
|
|||||||
...(manager.active?.features?.swarm === false
|
...(manager.active?.features?.swarm === false
|
||||||
? []
|
? []
|
||||||
: [{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm', keywords: 'g w services' }]),
|
: [{ 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: 'Deploy', icon: 'fa-rocket', view: 'deploy', keywords: 'g o create' },
|
||||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
{ label: 'Fleet', icon: 'fa-server', view: 'fleet', keywords: 'g f multi' },
|
||||||
{
|
{
|
||||||
@@ -1845,6 +2012,53 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
|||||||
panel.classList.toggle('hidden', panel.id !== `swarm-panel-${tab}`)
|
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) => {
|
document.getElementById('tunnels-list')?.addEventListener('click', async (e) => {
|
||||||
const t = e.target
|
const t = e.target
|
||||||
const copyBtn = t?.closest?.('.tunnel-copy-btn')
|
const copyBtn = t?.closest?.('.tunnel-copy-btn')
|
||||||
@@ -2091,6 +2305,7 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
|||||||
loadEventsView,
|
loadEventsView,
|
||||||
loadTunnelsView,
|
loadTunnelsView,
|
||||||
loadSwarmView,
|
loadSwarmView,
|
||||||
|
loadPodsView,
|
||||||
loadSettingsView,
|
loadSettingsView,
|
||||||
createTunnelFromForm,
|
createTunnelFromForm,
|
||||||
closeTunnelById,
|
closeTunnelById,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const GO_MAP = {
|
|||||||
v: 'volumes',
|
v: 'volumes',
|
||||||
s: 'stacks',
|
s: 'stacks',
|
||||||
w: 'swarm',
|
w: 'swarm',
|
||||||
|
k: 'pods',
|
||||||
e: 'events',
|
e: 'events',
|
||||||
h: 'host',
|
h: 'host',
|
||||||
t: 'tunnels',
|
t: 'tunnels',
|
||||||
@@ -323,6 +324,15 @@ export function initTrackGUx(ctx = {}) {
|
|||||||
typeof window !== 'undefined' ? window.manager?.active?.features?.swarm : undefined
|
typeof window !== 'undefined' ? window.manager?.active?.features?.swarm : undefined
|
||||||
if (swarmOn === false) return
|
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
|
// Compound targets: "settings:peers" → settings view + peers subtab
|
||||||
if (String(dest).includes(':')) {
|
if (String(dest).includes(':')) {
|
||||||
const [view, tab] = String(dest).split(':')
|
const [view, tab] = String(dest).split(':')
|
||||||
|
|||||||
Reference in New Issue
Block a user