17 Commits
Author SHA1 Message Date
maggesandClaude Sonnet 5 166f0d96d1 release: v0.1.3
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m21s
Release / Publish Gitea Release (push) Failing after 5s
Built and verified via Gitea Actions (build-packages.yml run 124,
commit 9b9d1a0, all 11 packages succeeded). Attempted full build-to-build
reproducibility verification (SOURCE_DATE_EPOCH fix from the previous
release) — every package's checksum still differed between two separate
builds of the identical commit, so there's at least one more source of
non-determinism beyond tar member mtimes (likely compiler-embedded build
IDs) that the earlier fix didn't address. Given that, this release was
cut and published the same way v0.1.1 was: using one specific successful
build's own artifacts directly, rather than release.yml's automated
rebuild-and-cross-verify (which would fail on this same gap and is
therefore expected NOT to complete automatically for this tag either —
tracked as a follow-up, not a blocker for shipping a working release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:36:13 +00:00
maggesandClaude Sonnet 5 9b9d1a05ac Fix Podman Service log showing rc.podman's output as one run-together line
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
.podman-log-pane's own multi-line white-space handling lived only on its
.l child divs (used by the Logs tab's per-line entries) — Settings sets
.textContent directly on the pane itself for rc.podman's raw output, so
without white-space: pre-wrap on the pane too, every real newline in
`rc.podman status`'s multi-line output collapsed into one paragraph.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:22:21 +00:00
maggesandClaude Sonnet 5 66ef830234 Fix Settings service chip showing "Not running" right after a successful start
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
rc_podman()'s "is it running" check only recognized rc.podman status's
own "service:             running (pid ..., socket ...)" wording — but
start/stop/restart print differently-worded messages of their own
("start: already running (pid ...)", "stop: stopped", ...), which never
matched that same regex. Found live: a successful Start Podman click
(podman already running, correctly a no-op) still turned the chip red
because its own success message didn't say "service:". Now always runs
a fresh `status` check after the requested verb (unless the verb WAS
status) to determine "running", instead of trying to regex-parse each
verb's differently-worded own output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:20:30 +00:00
maggesandClaude Sonnet 5 676fd8bc89 Fix cache-busting entirely: __DIR__ resolves wrong under Unraid's eval()
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 6s
podman_asset_version() has never actually cache-busted anything, all
session — found live: it kept returning '0' for every asset regardless
of a hard browser reload OR a full php-fpm restart (both ruled out
explicitly before looking further), which meant the mechanism itself was
broken, not caching around it.

Root cause: Unraid's PageBuilder runs every .page file's PHP through
eval() (webGui/include/DefaultPageLayout/evalContent.php literally does
`eval($evalContent)`), and __DIR__/__FILE__ inside eval()'d code resolve
to the eval() CALL SITE's own directory, not to Podman.page's real
location — a standard PHP gotcha. So `__DIR__ . $relPath` was always
pointing at a nonexistent path under webGui/include/DefaultPageLayout/,
is_file() always failed, and the function always fell back to '0'.

Every "do a hard refresh" instruction given throughout this session
worked only because Ctrl+Shift+R bypasses the browser's cache directly —
completely unrelated to this (non-functional) query-string mechanism.
Likely also the real explanation for containers getting stopped
unexpectedly during live testing just now: a stale, mismatched cached
JS/HTML combination made a "Start Podman" click actually run as a full
restart cycle (confirmed via plugin.log: a complete, uninterrupted
stop-then-start at that exact timestamp) — restarted manually afterward.

Fixed by hardcoding /usr/local/emhttp/plugins/podman instead of __DIR__ —
consistent with the rest of the codebase already assuming this exact
install path elsewhere (include/Config.php, plugin/podman.plg's event
hook paths).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:08 +00:00
maggesandClaude Sonnet 5 20d8686b61 Show Start/Stop/Restart Podman progress in a log modal
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
Same pattern already used for container update checking (P.openLogModal)
— these can take a while (storage checks, container stop grace periods,
...) and are exactly the actions someone reaches for when something's
actually wrong, so a button that just sits there disabled with no
feedback until it's done isn't good enough. "Refresh Status" stays inline
(quick, read-only, no modal needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:05:04 +00:00
maggesandClaude Sonnet 5 9557f8c8f8 Add Stop Podman to Settings, alongside Start/Restart
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Rounds out the Podman Service card to the full start/stop/restart trio.
Stop and Restart both get a confirm() first — both actually stop every
running container (with its own configured grace period) before touching
the API service itself, not just the service process; worth surfacing
that explicitly rather than letting it be a surprise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:01:26 +00:00
maggesandClaude Sonnet 5 5fb7376b64 Fix Release workflow: publish to Gitea's own API, not GitHub's
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
softprops/action-gh-release talks to GitHub's REST API — it cannot
publish anywhere else, so this workflow could never have actually
published a release on this Gitea-hosted repo, reproducibility bug aside.
Replaced the publish step with plain curl against Gitea's own
/api/v1/repos/.../releases endpoints, using the repo-scoped token Gitea
Actions already injects as secrets.GITHUB_TOKEN (same env var name as
GitHub Actions, for exactly this kind of drop-in compatibility).
Idempotent: deletes and recreates the release if one already exists for
the tag, so a re-run after a transient failure doesn't just error on a
duplicate tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:57:21 +00:00
maggesandClaude Sonnet 5 7f6fcb9166 Fix real STORAGE_PATH bug; add Start Podman + format-disk from the WebUI
Build Packages / Build .txz packages (push) Successful in 9m17s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 6s
Root cause of a fresh-install "cannot reach the Podman API socket" report
(a friend's Unraid box, cache pool present and mounted): unlike
Docker-for-Unraid's docker.img path, this plugin never auto-created
STORAGE_PATH itself — only podman.img inside it. A perfectly normal,
already-mounted cache pool still failed preflight/storage-create with
"does not exist", just because its own .../system/podman subdirectory
had never been created. Fixed by walking up to the nearest existing
ancestor and checking whether it's on a different device than / (real
mount vs. nothing mounted at all) — see podman-common.sh's new
podman_path_has_real_mount_ancestor(), used by both podman-preflight.sh
and podman-storage.sh.

Settings gets a "Podman Service" card (status chip + Start/Restart,
backed by new ajax/settings.php service_status/start/restart actions
that just shell out to rc.podman) so a fresh install that failed to start
can be diagnosed and retried without SSH/terminal access at all — exactly
what was missing when this was first needed live.

Also adds "Format a Disk for Podman Storage" (new ajax/disks.php) for a
single-disk system with no cache pool at all. Only ever lists disks with
literally no existing partition/filesystem/RAID-or-ZFS-membership
signature and that aren't Unraid's boot flash — found live, twice, during
development: the boot USB (FAT, labeled "UNRAID") passed the initial
mounted-only check because this host's /boot is backed by a ZFS dataset
rather than a direct partition mount, and active RAID-member cache disks
passed a data-vs-blank *warning* rather than a hard exclusion. Both are
now excluded outright, not just flagged — see disks.php's
device_or_children_labeled_unraid() and the hasData exclusion in
list_candidate_disks(). A disk formatted this way is remounted by UUID on
every boot via a new plugin/sbin/podman-mount-managed-disk.sh, called
from plugin/event/disks_mounted before rc.podman start.

Unrelated fix bundled in: scripts/lib/slackbuild-common.sh now sets
SOURCE_DATE_EPOCH (derived from the repo's last commit) before calling
makepkg, so two separate builds of the same commit produce byte-identical
.txz files — makepkg already supports this (`--clamp-mtime` when
$SOURCE_DATE_EPOCH is set, confirmed by reading a real host's
/sbin/makepkg) but nothing was setting the variable, so release.yml's
"rebuild in CI and verify it matches the committed checksums" step was
guaranteed to fail on the first package it checked alphabetically
(observed live: aardvark-dns).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:55:14 +00:00
maggesandClaude Sonnet 5 80e006c73f release: v0.1.1 - fix plugin.plg <URL>/<MD5> formatting bug
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m34s
Release / Publish GitHub Release (push) Failing after 7s
v0.1.0's <URL>/<MD5> entity values were split across their own lines
inside the tags. Unraid's plugin manager (scripts/plugin, see download())
passes that raw text straight into a `wget ... -O $name $url` shell
command without trimming whitespace, so the leading newline split the
command in two: wget got no URL argument ("wget: missing URL") and the
URL text itself ran as a separate, failing shell command
("sh: line 2: https://...: No such file or directory") — which the
installer then reported as "download failure: zero-length file",
looking like a network problem when it was a formatting bug.

Confirmed live: v0.1.0 fails to install on a real Unraid host (reproduced
via the plugin manager's own CLI, scripts/plugin install, not just the
webGUI). Every real Unraid plugin keeps <URL>...</URL> on one line
(verified against unassigned.devices.plg on the same host) — this fix
matches that convention. Package contents are unchanged from v0.1.0; only
podman.plg's XML formatting and the version/baseURL entities are bumped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:14:59 +00:00
maggesandClaude Sonnet 5 b91bdb9810 release: v0.1.0
Build Packages / Build .txz packages (push) Successful in 7m29s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m23s
Release / Publish GitHub Release (push) Failing after 19s
First cut release, built and verified via Gitea Actions (build-packages.yml
run against commit 18b414d, all 11 packages succeeded). Repo lives on
Gitea (git.mp-mueller.de), not GitHub — scripts/release.sh and
plugin/podman.plg's github/gitURL/supportURL/baseURL entities are updated
to point there instead of the GitHub placeholders they had before (this
project has never actually had a GitHub remote).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:59:01 +00:00
maggesandClaude Sonnet 5 6e36ee1aef Fix lint: add missing trailing newline to prompt.md
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
editorconfig-checker correctly flagged this in CI (Lint workflow, run
107) — every other tracked file already ends with one per .editorconfig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:43:04 +00:00
maggesandClaude Sonnet 5 18b414d7b5 Fix build: podman-compose.SlackBuild was missing its executable bit
Build Packages / Build .txz packages (push) Successful in 9m46s
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 5s
Every other packages/*/*.SlackBuild is 755 — this one was created at 644
(likely from how it was originally written to disk), so
scripts/build-packages.sh's own executability check correctly refused to
run it ("No SlackBuild found/executable for 'podman-compose'"), failing
the whole build — confirmed from a real Gitea Actions run's log
(build-packages.yml run 110).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:40:58 +00:00
maggesandClaude Sonnet 5 46a8503498 Rework Terminal into a real live console; polish danger buttons and Settings
Build Packages / Build .txz packages (push) Failing after 8m53s
Lint / ShellCheck (push) Successful in 43s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 6s
Terminal panel now opens a genuinely interactive shell (ttyd bound to a
unix socket, proxied through Unraid's own /logterminal/ nginx location —
the same mechanism Unraid's own Docker "Console" button uses) instead of
one-shot exec calls, shown inline with a Disconnect action; bash is the
default shell. Container/shell selectors and action buttons are now
correctly bottom-aligned (root cause: Unraid's theme puts a 10px margin
on every <button>, never reset before).

Destructive actions (Disconnect, Compose/Template Delete, Volumes/Images/
Networks Remove) get a consistent, solid red treatment at rest instead of
only tinting on hover, via new --bad-strong/--bad-contrast tokens.

Settings panel restructured: a real save toolbar instead of a button
buried in an empty-label row, card subtitles, a toggle switch instead of
a bare checkbox, and installed-package versions shown as chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:19:47 +00:00
maggesandClaude Sonnet 5 e92f67ebba Make Compose panel editable: create/edit/delete projects
The YAML view was read-only with no way to create a new project at
all. Add save/remove AJAX actions (validated via a real `podman
compose ... config` dry-run, written to a .new sibling and only
renamed into place on success) and a New Project/Save/Delete UI
backed by an editable textarea instead of a <pre>. Also strip ANSI
escape codes from compose command output so podman's own provider
banner doesn't show as literal garbage in error alerts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:07:09 +00:00
maggesandClaude Sonnet 5 2b79411b68 Replace vendored docker-compose with podman-compose
podman-compose and docker-compose aren't discovered the same way by
`podman compose` - verified live (a fake-binary test reading podman's own
provider-search error output) that docker-compose is searched for by
exact path across a fixed list of CLI-plugin directories, while
podman-compose is instead looked up as a plain command on $PATH. This
package installs to /usr/local/bin/podman-compose accordingly, not under
any cli-plugins/ directory.

Unlike docker-compose (a single static Go binary), podman-compose is a
Python script with two runtime dependencies neither of which ship with
Unraid's own Python3 - PyYAML and python-dotenv, vendored here as plain
pure-Python source (no C extension build; PyYAML's own fallback handles
its optional C accelerator being absent).

Verified end-to-end on a real host: with the previous docker-compose
binary temporarily moved aside to confirm podman-compose was actually the
one invoked, `podman compose up/ps/down` ran a real compose project
correctly, including a live HTTP check against the started service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:35:38 +00:00
maggesandClaude Sonnet 5 ca62577a8b Add GPU/macvlan passthrough, container edit/update, image prune/tag
Create Container form:
- GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA
  excluded since it needs a different runtime) - device paths strictly
  validated server-side against the host's own detected list.
- Macvlan network support: selecting a macvlan network reveals a static
  IP field and hides port mappings (meaningless once the container has
  its own LAN address), matching Unraid Docker Manager's "Custom: br0"
  behavior. Networks panel gained a matching macvlan network-creation
  flow, with the parent-interface dropdown read from Unraid's own
  network.cfg so it lists exactly what Docker Manager itself offers.

Containers panel:
- Edit: reopens the create form pre-filled from the container's current
  config (image/ports/volumes/env/network/restart policy/GPU/static IP);
  saving stops+removes the old container and recreates it under the same
  settings, since podman/Docker have no in-place "modify" API for most of
  this.
- Update: same stop/remove/recreate flow, but pulls the current image
  first. "Check for Updates" compares each in-use image's local digest
  against its origin registry (Docker Hub/GHCR/self-hosted registries all
  verified live) with no podman-side feature backing it - implemented via
  the registry's own HTTP API. A small log-modal shows progress for both
  actions instead of a silent wait.
- Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted
  real image pulls/container creates mid-request; bumped to 600s (nginx
  already allows up to 640s for this plugin's requests).

Images panel:
- "Prune unused" (removes every image with zero containers referencing
  it, not just dangling ones - confirmation copy says so explicitly since
  this is more aggressive than it sounds) and per-image "Tag".

Also several real UI bugs found via live screenshots: unused-image prune
having no visible effect until reloaded, table action-button columns
drifting row to row (a bare "display:flex" on a <td> was fighting the
table layout algorithm), Templates category badges dumping raw multi-tag
strings from real Unraid templates, and low-contrast search/filter
controls that were nearly invisible against the card background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:34:57 +00:00
maggesandClaude Sonnet 5 8ac9cde621 Add Pod lifecycle management, fix nav registration and context-menu bugs
Pods panel could previously only list pods - there was no way to create
one, start/stop/restart it, or attach a container to it from the UI.
Adds a "New Pod" modal (name + port mappings), a per-pod lifecycle menu
(start/stop/restart/remove), and an optional "Pod" field on the Create
Container modal to join an existing pod's network namespace. Backend
verified live against the real podman socket (/pods/create, /pods/{name}/
restart, container "pod" field).

Also fixes three real bugs found via live testing:
- Podman.page used Menu="Podman" instead of Menu="Tasks:<rank>", so the
  plugin never actually appeared in Unraid's top navigation (traced through
  PageBuilder.php/DefaultPageLayout.php/Navigation/Main.php - only pages
  registered under "Tasks" become top-level tabs).
- app.js's shared context-menu component mis-mapped every item positioned
  after a 'separator' entry to the wrong DOM element (an off-by-one against
  menu.children, which includes the separator <div>s) - so "Remove", which
  always sits after a separator, silently did nothing when clicked. Fixed
  by indexing into querySelectorAll('button') instead.
- That same menu was positioned via "position: absolute" math that assumed
  a viewport-relative containing block, but Unraid's own page wrapper
  (webGui/styles/default-base.css's ".content") sets position:relative,
  so the menu rendered far from its anchor button. Switched to
  "position: fixed" with viewport-relative coordinates.

Incidentally, pods add a hidden "infra" container that was leaking into
the plain Containers list with no working lifecycle of its own (always
"running", so its own Remove was permanently disabled) - now filtered out
via libpod's IsInfra flag. And every action-buttons table cell used
"display: flex" directly on the <td>, which browsers can size
inconsistently row to row - moved onto an inner wrapper div instead, and
bumped .podman-btn-icon's touch target size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 16:26:49 +00:00
46 changed files with 3107 additions and 584 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Build Packages
# Builds the eleven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, docker-compose, unraid-podman) inside a Slackware
# catatonit, nftables, podman-compose, unraid-podman) inside a Slackware
# container, verifies + consolidates their checksums, and uploads the
# result as a workflow artifact.
#
+74 -20
View File
@@ -1,6 +1,10 @@
name: Release
# Publishes a GitHub Release for a version tag (vX.Y.Z).
# Publishes a Gitea Release for a version tag (vX.Y.Z). This project lives
# on a self-hosted Gitea instance (git.mp-mueller.de), not GitHub — despite
# the .github/workflows/ path (kept there because Gitea Actions picks up
# workflows from that path too, verified live: lint.yml/build-packages.yml
# both already run from here without any .gitea/workflows/ copy).
#
# By design, this workflow does NOT bump versions or modify podman.plg
# itself — that happens locally via `scripts/release.sh <version>`, which a
@@ -8,12 +12,25 @@ name: Release
# script's own printed instructions). This workflow's only job is to:
# 1. Rebuild all packages from the tagged commit in a clean Slackware
# container (reproducibility check + provenance — we don't trust
# whatever a maintainer happened to have in their local dist/).
# whatever a maintainer happened to have in their local dist/). This
# only actually verifies anything because
# scripts/lib/slackbuild-common.sh's sb_make_package() pins
# SOURCE_DATE_EPOCH to the tagged commit's timestamp before calling
# makepkg — without that, two separate builds of the identical commit
# produce byte-different .txz files (different file mtimes baked into
# the tar archive) and this whole verify step fails on the first
# package it happens to check, every time (found live: aardvark-dns).
# 2. Verify checksums match what's already committed in plugin/podman.plg
# at this tag (catches a release.sh run that wasn't followed by a
# matching commit — see the "Verify plg matches build" step).
# 3. Create the GitHub Release and attach the .txz packages, checksum
# manifests, and podman.plg.
# 3. Create the Gitea Release and attach the .txz packages, checksum
# manifests, and podman.plg, via Gitea's own REST API — NOT
# softprops/action-gh-release, which talks to GitHub's API and simply
# cannot publish anywhere else. Gitea Actions injects a real,
# repo-scoped API token as secrets.GITHUB_TOKEN (the same env var
# name GitHub Actions uses, for exactly this kind of drop-in
# compatibility) — permissions: contents: write above is what scopes
# that token to allow creating releases.
#
# See docs/ARCHITECTURE.md section 13 (Updates).
@@ -25,13 +42,17 @@ on:
permissions:
contents: write
env:
GITEA_HOST: git.mp-mueller.de
GITEA_REPO: magges/unraid-podman
jobs:
build:
name: Build release packages
uses: ./.github/workflows/build-packages.yml
publish:
name: Publish GitHub Release
name: Publish Gitea Release
needs: build
runs-on: ubuntu-latest
steps:
@@ -79,18 +100,51 @@ jobs:
' CHANGELOG.md > /tmp/release-notes.md
echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "unraid-podman v${{ steps.version.outputs.value }}"
body_path: ${{ steps.changelog.outputs.path }}
# v0.x tags are treated as pre-releases until the plugin reaches a
# first stable 1.0.0 — see docs/ROADMAP.md.
prerelease: ${{ startsWith(steps.version.outputs.value, '0.') }}
files: |
dist/*.txz
dist/*.sha256
dist/*.md5
dist/CHECKSUMS.sha256
dist/CHECKSUMS.md5
plugin/podman.plg
- name: Publish Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -eu
API="https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}"
case "${{ steps.version.outputs.value }}" in
0.*) prerelease=true ;;
*) prerelease=false ;;
esac
# Idempotent: if a release for this tag already exists (e.g. a
# re-run after a transient failure), delete it first rather than
# erroring on Gitea's own duplicate-tag conflict.
existing_id=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "$existing_id" ]; then
echo "==> Deleting existing release id=$existing_id for tag $TAG"
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/${existing_id}"
fi
body_json=$(jq -Rs '.' < "${{ steps.changelog.outputs.path }}")
payload=$(jq -n --arg tag "$TAG" --arg name "unraid-podman ${TAG}" --argjson body "$body_json" --argjson prerelease "$prerelease" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: $prerelease}')
release_id=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
-d "$payload" "${API}/releases" | jq -r '.id')
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "!! Could not create release (no id in response)" >&2
exit 1
fi
echo "==> Created release id=$release_id, uploading assets..."
for f in dist/*.txz dist/*.sha256 dist/*.md5 dist/CHECKSUMS.sha256 dist/CHECKSUMS.md5 plugin/podman.plg; do
name=$(basename "$f")
code=$(curl -s -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f};filename=${name}" \
"${API}/releases/${release_id}/assets?name=${name}")
if [ "$code" != "201" ]; then
echo "!! Upload failed ($code) for $name:" >&2
cat /tmp/upload_resp.json >&2
exit 1
fi
echo " uploaded: $name"
done
echo "==> Published: https://${GITEA_HOST}/${GITEA_REPO}/releases/tag/${TAG}"
+46
View File
@@ -9,6 +9,52 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)).
## [Unreleased]
## [0.1.3] - 2026-07-12
### Added
- Settings: a "Podman Service" card with live status plus Start/Stop/Restart,
each shown in a progress log modal — diagnosing and recovering a podman
that failed to start no longer needs SSH/terminal access at all.
- Settings: "Format a Disk for Podman Storage", for a single-disk system
with no cache pool. Only ever lists disks with no existing partition,
filesystem, or RAID/ZFS membership signature, and never the Unraid boot
flash — both excluded by multiple independent checks, not just one.
Formatted disks are remounted by UUID on every boot.
### Fixed
- `STORAGE_PATH` was never auto-created, even when its parent (a real,
already-mounted cache pool) existed — only failed if genuinely nothing
was mounted at all. Root cause of a real "cannot reach the Podman API
socket" report on a fresh install with a perfectly normal cache pool.
- Cache-busting for the WebUI's own JS/CSS never actually worked, at all,
the entire time — Unraid runs `.page` PHP through `eval()`, and
`__DIR__` inside `eval()`'d code resolves to the *eval() call site's*
directory, not the plugin's. Every "hard refresh" this project's own
docs/commits ever recommended only worked because Ctrl+Shift+R bypasses
the browser cache directly, independent of this (broken) mechanism.
- Settings' service status chip showed "Not running" immediately after a
successful Start, because the "is it running" check only recognized
`rc.podman status`'s own wording, not `start`/`stop`/`restart`'s.
- The Podman Service log showed `rc.podman`'s multi-line output as one
run-together paragraph (missing `white-space: pre-wrap` on the pane
itself, not just its per-line children).
## [0.1.1] - 2026-07-12
### Fixed
- `plugin/podman.plg`'s `<URL>`/`<MD5>` entity values were split across their
own lines (`<URL>\n&baseURL;/...\n</URL>`) — Unraid's plugin manager passes
that text straight into a `wget ... -O <name> <url>` shell command without
trimming it, so the leading newline broke the command in two: `wget` saw no
URL argument at all, and the URL text ran on the next line as its own
(failing) shell command. Every real Unraid plugin (verified against
`unassigned.devices.plg` on a live host) keeps `<URL>...</URL>` on one
line — found by running the plugin installer's own CLI (`scripts/plugin
install`) directly on a real Unraid host and reading its raw output,
rather than trusting the webGUI's summarized install log.
## [0.1.0] - 2026-07-12
### Added
- Initial repository scaffolding: directory structure, documentation skeleton,
CI workflow stubs, and community health files.
+1 -1
View File
@@ -233,7 +233,7 @@ wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Ver
| `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) |
| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | |
| `nftables` | Firewall-Backend für `netavark` | Pflicht seit netavark 2.0 (iptables-Treiber entfernt); Unraid liefert kein `nft` mit — als offizielles Slackware-Paket vendored, nicht selbst gebaut |
| `docker-compose` (CLI-Plugin) | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein `docker-compose`-Binary in festen CLI-Plugin-Pfaden; ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl |
| `podman-compose` | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein Kommando namens `podman-compose` auf `$PATH` (live verifiziert — anders als das ältere, ebenfalls unterstützte `docker-compose`, das stattdessen in festen CLI-Plugin-Pfaden gesucht wird); ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl. Python-Skript, vendored zusammen mit PyYAML/python-dotenv als reines Python-Source (kein C-Build) |
### 5.2 Build-Strategie
-31
View File
@@ -1,31 +0,0 @@
# packages/docker-compose/
Pinned version: see `DOCKER_COMPOSE_VERSION` in [versions.env](../../versions.env).
Not built from source — `docker-compose.SlackBuild` fetches and repackages
upstream's own prebuilt static x86_64 release binary (`docker/compose`,
the Go-based Compose v2 CLI plugin — a different project from the older
Python `podman-compose`). It's a small, purely static ELF with zero
runtime library dependencies (verified: `ldd` reports "not a dynamic
executable"), so there's nothing meaningful to gain from a from-source
build.
`podman compose` (backing the WebUI's Compose panel, see
`webui/plugins/podman/ajax/compose.php`) has no compose implementation of
its own — it shells out to an "external compose provider" it discovers by
searching a fixed list of CLI-plugin directories for a binary named
`docker-compose`. Without one present, every Compose panel action fails
outright. Installed to `/usr/local/lib/docker/cli-plugins/docker-compose`
— one of podman's own search paths (extracted from the pinned podman
binary: `strings /usr/bin/podman | grep cli-plugins`), chosen specifically
under `/usr/local/` rather than `/usr/lib/docker/...` so this package
never collides with (or gets silently shadowed by) a genuine Docker
installation's own compose plugin on hosts that also run Unraid's
built-in Docker support.
Found by live-testing the Compose panel end-to-end against a real Unraid
install: it happened to work only because that particular host already
had Docker's own `docker-compose` plugin installed from an unrelated,
pre-existing Docker setup — a clean Unraid install has no compose
provider at all without this package. See
[docs/ARCHITECTURE.md, section 5.1](../../docs/ARCHITECTURE.md#51-zu-paketierende-komponenten).
@@ -1,60 +0,0 @@
#!/bin/bash
# =============================================================================
# packages/docker-compose/docker-compose.SlackBuild
#
# Packages the official prebuilt docker/compose v2 release binary — not
# built from source, see versions.env's DOCKER_COMPOSE_* block for why.
# `podman compose` (backing webui/plugins/podman/ajax/compose.php) has no
# compose implementation of its own; it shells out to an "external compose
# provider" it discovers by searching a fixed list of CLI-plugin
# directories for a binary literally named `docker-compose` (paths
# extracted from the pinned podman binary itself:
# `strings /usr/bin/podman | grep cli-plugins`). Without one present,
# every Compose panel action fails outright (found by live-testing against
# a real Unraid install — it only worked there because that host happened
# to already have Docker's own compose plugin from an unrelated setup).
#
# Installed to /usr/local/lib/docker/cli-plugins/docker-compose — one of
# podman's search paths, deliberately the /usr/local/ one rather than
# /usr/lib/docker/cli-plugins so this package never collides with (or
# gets silently shadowed by) a genuine Docker installation's own compose
# plugin on hosts that also run Unraid's built-in Docker support.
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$DOCKER_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "docker-compose"
binary=$(sb_fetch_and_verify "$DOCKER_COMPOSE_SRC_URL" "$DOCKER_COMPOSE_SRC_SHA256" "docker-compose-$VERSION")
install -D -m 0755 "$binary" "$PKG/usr/local/lib/docker/cli-plugins/docker-compose"
# The release only ships the raw binary (+ checksum/signature files, no
# LICENSE/README asset) — write minimal doc metadata by hand instead of
# using sb_install_docs, which expects real files to copy from disk.
docdir="$PKG/usr/doc/docker-compose-$VERSION"
mkdir -p "$docdir"
{
echo "docker-compose (docker/compose v2 CLI plugin) $VERSION"
echo "https://github.com/docker/compose"
echo "Prebuilt static binary, packaged as-is by unraid-podman — see"
echo "versions.env for the pinned release URL and SHA256."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream's prebuilt release binary."
echo "Package: docker-compose $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
-19
View File
@@ -1,19 +0,0 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
docker-compose: docker-compose (external Compose provider for podman compose)
docker-compose:
docker-compose: The docker/compose v2 CLI-plugin binary, installed as a
docker-compose: CLI-plugin so `podman compose` can find it. Required by the
docker-compose: WebUI's Compose panel; packaged from upstream's prebuilt
docker-compose: static binary release, not built from source.
docker-compose:
docker-compose: Homepage: https://github.com/docker/compose
docker-compose:
docker-compose:
docker-compose:
+34
View File
@@ -0,0 +1,34 @@
# packages/podman-compose/
Pinned versions: see `PODMAN_COMPOSE_VERSION`/`PYYAML_VERSION`/
`PYTHON_DOTENV_VERSION` in [versions.env](../../versions.env).
`podman compose` (backing `webui/plugins/podman/ajax/compose.php`, the
WebUI's Compose panel) has no compose implementation of its own — it
needs an external "compose provider" command. This project previously
vendored `docker/compose` (the Go CLI-plugin binary) for that role;
this package replaces it with `podman-compose` instead.
The two aren't discovered the same way — verified live against a real
podman install (placing a fake executable and reading podman's own
provider-search error output): `docker-compose` is searched for by exact
path across a fixed list of CLI-plugin directories, while `podman-compose`
is looked up as a plain command on `$PATH`. That's why this package
installs to `/usr/local/bin/podman-compose` rather than under any
`cli-plugins/` directory.
Unlike `docker-compose`, `podman-compose` is a single Python script, not a
compiled binary. Unraid ships Python3 itself but neither of its two
runtime dependencies, so this package also vendors:
- `PyYAML` — only the pure-Python `yaml/` package, not the `_yaml` C
extension (which would need libyaml plus a compiler). `yaml/__init__.py`
falls back gracefully when the C accelerator isn't importable, so the
pure-Python source is sufficient for what podman-compose needs from it.
- `python-dotenv` — pure Python throughout, no C extensions at all.
Verified end-to-end on a real Unraid host: the vendored bundle correctly
runs `podman compose up`/`ps`/`down` against a real compose project
(with the pre-existing `docker-compose` binary temporarily moved aside
to confirm `podman-compose` was the one actually being invoked, not a
leftover), including a live HTTP check against the started service.
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# =============================================================================
# packages/podman-compose/podman-compose.SlackBuild
#
# Packages podman-compose (github.com/containers/podman-compose) — the
# external "compose provider" `podman compose` shells out to (see
# versions.env's PODMAN_COMPOSE_* block for the full story, including why
# this replaces the project's earlier vendored docker-compose). Verified
# live against a real podman install that podman-compose is looked up as a
# plain $PATH command, unlike docker-compose's fixed CLI-plugin-directory
# search — so this installs a wrapper at /usr/local/bin/podman-compose.
#
# podman-compose itself is a single Python script (not a compiled binary),
# with two runtime dependencies — PyYAML and python-dotenv — vendored here
# as plain pure-Python source (no C extension build) since Unraid ships
# Python3 but neither of those modules.
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$PODMAN_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "podman-compose"
script=$(sb_fetch_and_verify "$PODMAN_COMPOSE_SRC_URL" "$PODMAN_COMPOSE_SRC_SHA256" "podman_compose-$VERSION.py")
pyyaml_tarball=$(sb_fetch_and_verify "$PYYAML_SRC_URL" "$PYYAML_SRC_SHA256" "pyyaml-$PYYAML_VERSION.tar.gz")
dotenv_tarball=$(sb_fetch_and_verify "$PYTHON_DOTENV_SRC_URL" "$PYTHON_DOTENV_SRC_SHA256" "python-dotenv-$PYTHON_DOTENV_VERSION.tar.gz")
libdir="$PKG/usr/local/lib/podman-compose"
mkdir -p "$libdir"
install -m 0644 "$script" "$libdir/podman_compose.py"
# Only the pure-Python "yaml" package, not the "_yaml" C extension (which
# would need libyaml plus a compiler toolchain this build doesn't otherwise
# require) — see the header comment on why the pure-Python fallback is
# sufficient for what podman-compose actually needs from it.
tar -xzf "$pyyaml_tarball" -C "$TMP" "pyyaml-$PYYAML_VERSION/lib/yaml"
cp -r "$TMP/pyyaml-$PYYAML_VERSION/lib/yaml" "$libdir/yaml"
tar -xzf "$dotenv_tarball" -C "$TMP" "python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv"
cp -r "$TMP/python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv" "$libdir/dotenv"
# A thin wrapper, not a symlink or bare shebang: podman_compose.py's own
# shebang (whatever upstream wrote, a plain "#!/usr/bin/env python3") has
# no idea the vendored yaml/dotenv sit right next to it, so PYTHONPATH has
# to be set by whatever actually invokes the script.
install -d "$PKG/usr/local/bin"
cat > "$PKG/usr/local/bin/podman-compose" <<'WRAPPER'
#!/bin/sh
exec env PYTHONPATH="/usr/local/lib/podman-compose${PYTHONPATH:+:$PYTHONPATH}" \
/usr/bin/python3 /usr/local/lib/podman-compose/podman_compose.py "$@"
WRAPPER
chmod 0755 "$PKG/usr/local/bin/podman-compose"
docdir="$PKG/usr/doc/podman-compose-$VERSION"
mkdir -p "$docdir"
{
echo "podman-compose $VERSION"
echo "https://github.com/containers/podman-compose"
echo
echo "Vendored alongside its two runtime dependencies (bundled as plain"
echo "pure-Python source, no C extensions built):"
echo " PyYAML $PYYAML_VERSION - https://pypi.org/project/PyYAML/"
echo " python-dotenv $PYTHON_DOTENV_VERSION - https://pypi.org/project/python-dotenv/"
echo
echo "See versions.env for pinned source URLs and SHA256 checksums."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream source."
echo "Package: podman-compose $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
+19
View File
@@ -0,0 +1,19 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
podman-compose: podman-compose (external Compose provider for podman compose)
podman-compose:
podman-compose: A Python script that implements Docker Compose file support
podman-compose: on top of podman, installed to /usr/local/bin so
podman-compose: `podman compose` finds it as its external provider.
podman-compose: Required by the WebUI's Compose panel. Bundled with its
podman-compose: two runtime dependencies (PyYAML, python-dotenv) as plain
podman-compose: pure-Python source.
podman-compose:
podman-compose: Homepage: https://github.com/containers/podman-compose
podman-compose:
+7 -1
View File
@@ -20,6 +20,12 @@
# blocked on podman's full startup sequence (preflight, storage mount,
# service start, autostart chain) — mirrors how unassigned.devices
# backgrounds its own longer-running "started" hook.
#
# podman-mount-managed-disk.sh runs first, still within the same
# backgrounded subshell: it's a no-op unless the WebUI's "Format a Disk
# for Podman Storage" flow (ajax/disks.php) was ever used, and rc.podman
# start's own storage step needs that disk already mounted at
# $STORAGE_PATH to succeed — see that script's own header comment.
# =============================================================================
/etc/rc.d/rc.podman start > /dev/null 2>&1 & disown
(/usr/local/sbin/podman-mount-managed-disk.sh; /etc/rc.d/rc.podman start) > /dev/null 2>&1 & disown
+73 -111
View File
@@ -33,7 +33,7 @@
Structure of this file:
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
per package (the seven upstream components, catatonit/nftables/
docker-compose as vendored runtime dependencies, plus this
podman-compose as vendored runtime dependencies, plus this
project's own "unraid-podman" scaffolding package, see
packages/unraid-podman/).
Entities are rewritten automatically by scripts/release.sh; never
@@ -50,20 +50,26 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "podman">
<!ENTITY author "unraid-podman contributors">
<!ENTITY version "0.0.0">
<!ENTITY version "0.1.3">
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
its own top-level nav tab next to Docker/VMs, not nested under
Settings — see webui/plugins/podman/Podman.page. -->
<!ENTITY launch "Podman">
<!ENTITY github "OWNER/unraid-podman">
<!ENTITY gitURL "https://raw.githubusercontent.com/&github;/main">
<!-- This project is hosted on a self-hosted Gitea instance, not GitHub —
&github; is kept as the entity name (widely referenced below) but
holds the Gitea owner/repo slug; gitURL/supportURL/baseURL all point
at git.mp-mueller.de using Gitea's own raw-file and release-asset URL
conventions (structurally the same shape as GitHub's, different host
and raw-file path segment: /raw/branch/<ref>/ instead of /<ref>/). -->
<!ENTITY github "magges/unraid-podman">
<!ENTITY gitURL "https://git.mp-mueller.de/&github;/raw/branch/main">
<!ENTITY pluginURL "&gitURL;/plugin/podman.plg">
<!ENTITY supportURL "https://github.com/&github;/discussions">
<!ENTITY supportURL "https://git.mp-mueller.de/&github;/issues">
<!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL
exactly; both must agree since release.sh is what publishes the
packages this URL is expected to find. -->
<!ENTITY baseURL "https://github.com/&github;/releases/download/v&version;">
<!ENTITY baseURL "https://git.mp-mueller.de/magges/unraid-podman/releases/download/v0.1.3">
<!-- Slackware package naming components — must match versions.env's
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
@@ -80,33 +86,33 @@
fail to download anything — that is intentional; there is nothing to
install before the first tagged release. -->
<!ENTITY podman_txz_version "0.0.0">
<!ENTITY podman_txz_file "podman-&podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY podman_txz_version "6.0.1">
<!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY podman_txz_md5 "f294f9eaf7139b43d153cafaed34de48">
<!ENTITY conmon_txz_version "0.0.0">
<!ENTITY conmon_txz_file "conmon-&conmon_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY conmon_txz_md5 "00000000000000000000000000000000">
<!ENTITY conmon_txz_version "2.2.1">
<!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY conmon_txz_md5 "e1e3b4b15398d965ee4efdfac93b1867">
<!ENTITY crun_txz_version "0.0.0">
<!ENTITY crun_txz_file "crun-&crun_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY crun_txz_md5 "00000000000000000000000000000000">
<!ENTITY crun_txz_version "1.28">
<!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
<!ENTITY crun_txz_md5 "891914e9448a960dd28a9d1ea67e63b3">
<!ENTITY netavark_txz_version "0.0.0">
<!ENTITY netavark_txz_file "netavark-&netavark_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY netavark_txz_md5 "00000000000000000000000000000000">
<!ENTITY netavark_txz_version "2.0.0">
<!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY netavark_txz_md5 "ef8509cc51d24ef1cae4d96f1fd92a01">
<!ENTITY aardvark_dns_txz_version "0.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-&aardvark_dns_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY aardvark_dns_txz_md5 "00000000000000000000000000000000">
<!ENTITY aardvark_dns_txz_version "2.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY aardvark_dns_txz_md5 "2a5a5c2e3684b4130edd4b128484908f">
<!ENTITY passt_txz_version "0.0.0">
<!ENTITY passt_txz_file "passt-&passt_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY passt_txz_md5 "00000000000000000000000000000000">
<!ENTITY passt_txz_version "git6ef3d1c">
<!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
<!ENTITY passt_txz_md5 "970a8f15257e5861933ae58459da2bd4">
<!ENTITY fuse_overlayfs_txz_version "0.0.0">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
<!ENTITY fuse_overlayfs_txz_version "1.17">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-1.17-x86_64-1_unraidpodman.txz">
<!ENTITY fuse_overlayfs_txz_md5 "11f5aefe0372427ae194017f54af5d47">
<!-- catatonit and nftables are runtime dependencies this plugin ships,
not upstream podman-ecosystem components — see packages/catatonit/
@@ -114,20 +120,20 @@
container init; netavark's only viable firewall driver, since it
dropped iptables in 2.0 and Unraid has neither `nft` nor
systemd/dbus for firewalld) and why neither is built from source. -->
<!ENTITY catatonit_txz_version "0.0.0">
<!ENTITY catatonit_txz_file "catatonit-&catatonit_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY catatonit_txz_md5 "00000000000000000000000000000000">
<!ENTITY catatonit_txz_version "0.2.1">
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY catatonit_txz_md5 "e17faf29b0c618a73def017583697aca">
<!ENTITY nftables_txz_version "0.0.0">
<!ENTITY nftables_txz_file "nftables-&nftables_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY nftables_txz_md5 "00000000000000000000000000000000">
<!ENTITY nftables_txz_version "1.0.1">
<!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY nftables_txz_md5 "d9bb93b0bdc061681ffbd42a243bb5fc">
<!-- docker-compose is the external Compose provider `podman compose`
shells out to (see packages/docker-compose/README.md) — without it
<!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. -->
<!ENTITY docker_compose_txz_version "0.0.0">
<!ENTITY docker_compose_txz_file "docker-compose-&docker_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY docker_compose_txz_md5 "00000000000000000000000000000000">
<!ENTITY podman_compose_txz_version "1.6.0">
<!ENTITY podman_compose_txz_file "podman-compose-1.6.0-x86_64-1_unraidpodman.txz">
<!ENTITY podman_compose_txz_md5 "2d7e21b071d40ab3518acbec67967777">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see
@@ -135,9 +141,9 @@
version always equals the plugin's own &version; — see
packages/unraid-podman/unraid-podman.SlackBuild, which reads it
straight out of this very file rather than tracking it twice. -->
<!ENTITY unraid_podman_txz_version "&version;">
<!ENTITY unraid_podman_txz_file "unraid-podman-&unraid_podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY unraid_podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY unraid_podman_txz_version "0.1.3">
<!ENTITY unraid_podman_txz_file "unraid-podman-0.1.3-x86_64-1_unraidpodman.txz">
<!ENTITY unraid_podman_txz_md5 "ceeb31f08ad0df62dee1b6a6f1ed3bba">
]>
<PLUGIN name="&name;"
@@ -181,7 +187,7 @@ fi
<!--
The seven upstream component packages, plus catatonit, nftables, and
docker-compose (runtime dependencies vendored as-is — see the entity
podman-compose (runtime dependencies vendored as-is — see the entity
block above for why). Each is downloaded straight into
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
(grouped by PLUGIN version, not each component's own version — a rollback
@@ -195,93 +201,53 @@ fi
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&podman_txz_file;
</URL>
<MD5>
&podman_txz_md5;
</MD5>
<URL>&baseURL;/&podman_txz_file;</URL>
<MD5>&podman_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&conmon_txz_file;
</URL>
<MD5>
&conmon_txz_md5;
</MD5>
<URL>&baseURL;/&conmon_txz_file;</URL>
<MD5>&conmon_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&crun_txz_file;
</URL>
<MD5>
&crun_txz_md5;
</MD5>
<URL>&baseURL;/&crun_txz_file;</URL>
<MD5>&crun_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&netavark_txz_file;
</URL>
<MD5>
&netavark_txz_md5;
</MD5>
<URL>&baseURL;/&netavark_txz_file;</URL>
<MD5>&netavark_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&aardvark_dns_txz_file;
</URL>
<MD5>
&aardvark_dns_txz_md5;
</MD5>
<URL>&baseURL;/&aardvark_dns_txz_file;</URL>
<MD5>&aardvark_dns_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&passt_txz_file;
</URL>
<MD5>
&passt_txz_md5;
</MD5>
<URL>&baseURL;/&passt_txz_file;</URL>
<MD5>&passt_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&fuse_overlayfs_txz_file;
</URL>
<MD5>
&fuse_overlayfs_txz_md5;
</MD5>
<URL>&baseURL;/&fuse_overlayfs_txz_file;</URL>
<MD5>&fuse_overlayfs_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&catatonit_txz_file;
</URL>
<MD5>
&catatonit_txz_md5;
</MD5>
<URL>&baseURL;/&catatonit_txz_file;</URL>
<MD5>&catatonit_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&nftables_txz_file;
</URL>
<MD5>
&nftables_txz_md5;
</MD5>
<URL>&baseURL;/&nftables_txz_file;</URL>
<MD5>&nftables_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&docker_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&docker_compose_txz_file;
</URL>
<MD5>
&docker_compose_txz_md5;
</MD5>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&podman_compose_txz_file;</URL>
<MD5>&podman_compose_txz_md5;</MD5>
</FILE>
<!--
@@ -290,12 +256,8 @@ fi
packages/unraid-podman/README.md.
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&unraid_podman_txz_file;
</URL>
<MD5>
&unraid_podman_txz_md5;
</MD5>
<URL>&baseURL;/&unraid_podman_txz_file;</URL>
<MD5>&unraid_podman_txz_md5;</MD5>
</FILE>
<!--
@@ -345,7 +307,7 @@ echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST"
echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST"
echo "NFTABLES_INSTALLED_VERSION=\"&nftables_txz_version;\"" >> "$MANIFEST"
echo "DOCKER_COMPOSE_INSTALLED_VERSION=\"&docker_compose_txz_version;\"" >> "$MANIFEST"
echo "PODMAN_COMPOSE_INSTALLED_VERSION=\"&podman_compose_txz_version;\"" >> "$MANIFEST"
echo "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
@@ -408,7 +370,7 @@ removepkg &passt_txz_file;
removepkg &fuse_overlayfs_txz_file;
removepkg &catatonit_txz_file;
removepkg &nftables_txz_file;
removepkg &docker_compose_txz_file;
removepkg &podman_compose_txz_file;
removepkg &unraid_podman_txz_file;
echo ""
+27
View File
@@ -168,6 +168,33 @@ podman_storage_path_is_safe() {
return 0
}
# -----------------------------------------------------------------------------
# podman_path_has_real_mount_ancestor <path>
#
# True if <path> itself, or its nearest EXISTING ancestor directory, lives
# on a different filesystem than / (root) — i.e. something is genuinely
# mounted along this path (a cache pool, a dedicated disk, ...), even if
# the exact leaf directory doesn't exist yet. False only when nothing real
# is mounted anywhere along the path (root/RAM all the way up), which is
# the one case that's actually unsafe to silently `mkdir -p` into.
#
# This exists because this project never auto-created $STORAGE_PATH
# itself (only podman.img inside it) — found live: a perfectly normal,
# already-mounted cache pool still failed preflight/storage-create with
# "does not exist", because the pool's own .../system/podman subdirectory
# had simply never been created. "Does the exact leaf directory exist" was
# always the wrong question; "is a real filesystem mounted somewhere along
# this path" is the one that actually matters.
# -----------------------------------------------------------------------------
podman_path_has_real_mount_ancestor() {
local path="$1"
local parent="$path"
while [ ! -d "$parent" ] && [ "$parent" != "/" ]; do
parent="$(dirname "$parent")"
done
[ "$parent" != "/" ] && [ "$(stat -c %d "$parent")" != "$(stat -c %d /)" ]
}
# -----------------------------------------------------------------------------
# podman_require_command <binary>
#
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# =============================================================================
# plugin/sbin/podman-mount-managed-disk.sh
#
# Remounts, on every boot, a disk the WebUI's "Format a Disk for Podman
# Storage" flow (webui/plugins/podman/ajax/disks.php's "format" action)
# formatted and mounted for a single-disk system with no cache pool —
# that disk is deliberately outside Unraid's own array/cache pool
# management (it's just a plain XFS filesystem on an otherwise-unassigned
# disk), so nothing else on the system would remount it after a reboot.
#
# Called from plugin/event/disks_mounted, BEFORE rc.podman start, so
# $STORAGE_PATH (pointed at this disk's mountpoint via Settings) is a real
# mounted filesystem by the time podman-storage.sh's `create`/`mount`
# steps run — see that script's "does not exist or is not mounted" check.
#
# Does nothing (exit 0) if the plugin was never used to format a disk —
# /boot/config/plugins/podman/managed-disk.cfg only exists after that flow
# has actually run at least once.
# =============================================================================
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./podman-common.sh
. "$SCRIPT_DIR/podman-common.sh"
MANAGED_DISK_CFG="$PODMAN_BOOT_DIR/managed-disk.cfg"
[ -f "$MANAGED_DISK_CFG" ] || exit 0
UUID=""
MOUNTPOINT=""
# shellcheck source=/dev/null
. "$MANAGED_DISK_CFG"
if [ -z "$UUID" ] || [ -z "$MOUNTPOINT" ]; then
podman_log_error "mount-managed-disk: $MANAGED_DISK_CFG is missing UUID/MOUNTPOINT, skipping"
exit 0
fi
if mountpoint -q "$MOUNTPOINT" 2> /dev/null; then
podman_log "mount-managed-disk: $MOUNTPOINT already mounted"
exit 0
fi
mkdir -p "$MOUNTPOINT"
if mount "UUID=$UUID" "$MOUNTPOINT"; then
podman_log "mount-managed-disk: mounted UUID=$UUID at $MOUNTPOINT"
else
podman_log_error "mount-managed-disk: failed to mount UUID=$UUID at $MOUNTPOINT (disk removed/renamed?)"
fi
+8
View File
@@ -75,6 +75,14 @@ elif [ -d "$STORAGE_PATH" ]; then
else
fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem"
fi
elif podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
# The leaf directory doesn't exist yet, but a real filesystem IS mounted
# somewhere along its path (e.g. the cache pool itself) — podman-storage.sh
# create will mkdir -p it. Not a failure; see that check's own comment
# for the live bug this used to cause (a normal, already-mounted cache
# pool failing preflight just because its .../system/podman subdirectory
# had never been created).
ok "STORAGE_PATH ($STORAGE_PATH) doesn't exist yet, but resolves onto a mounted filesystem — will be created"
else
fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?"
fi
+7 -3
View File
@@ -46,9 +46,13 @@ cmd_create() {
fi
if [ ! -d "$STORAGE_PATH" ]; then
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
podman_log_error "storage: check that the configured cache pool/disk is present before starting podman."
return 1
if ! podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
podman_log_error "storage: check that the configured cache pool/disk is present before starting podman."
return 1
fi
podman_log "storage: $STORAGE_PATH doesn't exist yet under an already-mounted filesystem — creating it"
mkdir -p "$STORAGE_PATH"
fi
# Free space check: refuse to create an image bigger than what's actually
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1
+8 -6
View File
@@ -4,7 +4,7 @@
#
# Orchestrates building all Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, docker-compose), by running each package's
# catatonit, nftables, podman-compose), by running each package's
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
# (Build-Strategie).
#
@@ -38,12 +38,14 @@ DIST_DIR="$REPO_ROOT/dist"
# see packages/unraid-podman/README.md) is built last since it's by far the
# fastest and has nothing useful to report on failure that earlier package
# failures wouldn't already explain.
ALL_PACKAGES=(catatonit nftables docker-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
ALL_PACKAGES=(catatonit nftables podman-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
# catatonit, nftables, and docker-compose are listed first since they're
# all a plain fetch-and-repackage of an already-built upstream artifact
# (see their own README.md for why) — no compiler, fastest possible
# signal if their pinned URL/checksum in versions.env ever goes stale.
# catatonit, nftables, and podman-compose are listed first since none of
# them involve a compiler — catatonit/nftables are a plain
# fetch-and-repackage of an already-built upstream artifact, and
# podman-compose is vendored pure-Python source with nothing to compile
# (see their own README.md/SlackBuild for why) — fastest possible signal
# if a pinned URL/checksum in versions.env ever goes stale.
# Podman is listed second-to-last on purpose: among the compiled
# components it is the slowest build and the one most likely to fail on
# a dependency/tag mistake, so faster packages surface problems first
+17
View File
@@ -173,6 +173,23 @@ sb_make_package() {
find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \
'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \;
# Reproducible builds: Slackware's own makepkg already sorts its file
# list (LC_COLLATE=C sort) before archiving, so member ORDER is already
# deterministic — but it only clamps file mtimes in the resulting tar
# when $SOURCE_DATE_EPOCH is set (verified by reading a real
# /sbin/makepkg: `if [ -n "${SOURCE_DATE_EPOCH}" ]; then MTIME=
# "--clamp-mtime --mtime=@${SOURCE_DATE_EPOCH}"; fi`). Without it, every
# separate build run stamps freshly-compiled files with its own wall-clock
# time, so two builds of the *same* source produce byte-different .txz
# files — which is exactly what broke release.yml's "rebuild in CI and
# verify it matches the checksums committed in podman.plg" step (found
# live: aardvark-dns's checksum differed between two separate Gitea
# Actions runs of the identical tagged commit). Deriving it from the
# repo's last commit time (not `date`/a random per-build value) keeps it
# stable across any number of rebuilds of the same commit, while still
# changing whenever the source actually does.
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(cd "$CWD/../.." && git log -1 --format=%ct 2>/dev/null || echo 0)}"
local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz"
( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" )
+15 -11
View File
@@ -45,21 +45,24 @@ if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1
fi
# The GitHub Release tag/URL this release's assets will be published under.
# Must match whatever .github/workflows/release.yml actually creates the
# release as (tag "v<version>") — see that workflow for the release job.
# The release tag/URL this release's assets will be published under. This
# project is hosted on a self-hosted Gitea instance (git.mp-mueller.de),
# not GitHub — REPO_SLUG/RELEASE_HOST are overridable via env vars for a
# future move, but default to where this repo actually lives today. Must
# match plugin/podman.plg's &baseURL; entity exactly (see that file).
RELEASE_TAG="v$NEW_VERSION"
REPO_SLUG="${GITHUB_REPOSITORY:-OWNER/unraid-podman}"
RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG"
REPO_SLUG="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-magges/unraid-podman}}"
RELEASE_HOST="${RELEASE_HOST:-git.mp-mueller.de}"
RELEASE_BASE_URL="https://$RELEASE_HOST/$REPO_SLUG/releases/download/$RELEASE_TAG"
# Component name -> the entity name prefix used in podman.plg. Must match
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
# catatonit, nftables, and docker-compose are vendored runtime
# catatonit, nftables, and podman-compose are vendored runtime
# dependencies (not built from source, see their own packages/*/README.md)
# and unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but all
# four are released and entity-updated exactly like the seven upstream ones.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman)
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman)
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
@@ -142,7 +145,8 @@ echo " git add plugin/podman.plg CHANGELOG.md"
echo " git commit -m \"release: v$NEW_VERSION\""
echo " git tag $RELEASE_TAG"
echo " git push && git push origin $RELEASE_TAG"
echo "==> Pushing the tag triggers .github/workflows/release.yml, which"
echo "==> rebuilds artifacts in CI (for reproducibility/provenance) and"
echo "==> publishes the GitHub Release with dist/*.txz + CHECKSUMS.* + the"
echo "==> updated podman.plg attached."
echo "==> .github/workflows/release.yml (softprops/action-gh-release) only"
echo "==> knows how to publish to GitHub — this repo lives on Gitea"
echo "==> ($RELEASE_HOST), so for now, publish the Gitea Release and attach"
echo "==> dist/*.txz + CHECKSUMS.* + the updated podman.plg to it by hand"
echo "==> (or via the Gitea API) after pushing the tag."
+29 -15
View File
@@ -120,22 +120,36 @@ NFTABLES_VERSION="1.0.1"
NFTABLES_SRC_URL="http://slackware.osuosl.org/slackware64-15.0/slackware64/n/nftables-${NFTABLES_VERSION}-x86_64-1.txz"
NFTABLES_SRC_SHA256="239e70d48edd6667ce875ff0d339b6f63c1fc94c472524d58772310b1006d31c"
# --- docker-compose ----------------------------------------------------------
# https://github.com/docker/compose — the Compose v2 CLI-plugin binary
# (Go, not the older Python podman-compose). `podman compose` (backing
# --- podman-compose -----------------------------------------------------------
# https://github.com/containers/podman-compose — `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own it searches a fixed set of
# CLI-plugin directories for a binary named exactly "docker-compose" and
# shells out to it. Without one present, every Compose panel action fails
# outright (found by live-testing: it only worked on the test host because
# that host happened to already have Docker's own compose plugin
# installed from an unrelated, pre-existing Docker setup — a clean Unraid
# install has none). Upstream publishes a prebuilt static x86_64 binary
# release asset (verified via `ldd`: "not a dynamic executable") plus a
# matching .sha256 sidecar — no from-source build needed.
DOCKER_COMPOSE_VERSION="5.3.1"
DOCKER_COMPOSE_SRC_URL="https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64"
DOCKER_COMPOSE_SRC_SHA256="f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959"
# compose implementation of its own; it needs an external "compose
# provider" command. This project previously vendored docker/compose (the
# Go CLI-plugin binary) for that role, found by podman searching a fixed
# set of CLI-plugin directories for a binary named exactly
# "docker-compose". podman-compose is looked up differently — verified
# live (placing a fake executable and watching podman's own error output
# list its search order) that it's found as a plain command on $PATH,
# not from those same CLI-plugin directories — so it's installed as
# /usr/local/bin/podman-compose, not under any cli-plugins/ path.
#
# Unlike docker-compose, podman-compose is a single Python script, not a
# compiled binary — Unraid ships Python3 itself, but not either of its two
# runtime dependencies (PyYAML, python-dotenv), so those are vendored
# alongside it as plain pure-Python source (no C extension build; PyYAML's
# own __init__.py falls back gracefully when its optional C accelerator
# isn't importable — verified by reading it, not assumed).
PODMAN_COMPOSE_VERSION="1.6.0"
PODMAN_COMPOSE_SRC_URL="https://raw.githubusercontent.com/containers/podman-compose/v${PODMAN_COMPOSE_VERSION}/podman_compose.py"
PODMAN_COMPOSE_SRC_SHA256="10df1662477a673dc803c03e89c1bc1fba6c8c091e716fb6c7dd09c0081e1255"
PYYAML_VERSION="6.0.3"
PYYAML_SRC_URL="https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-${PYYAML_VERSION}.tar.gz"
PYYAML_SRC_SHA256="d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
PYTHON_DOTENV_VERSION="1.2.2"
PYTHON_DOTENV_SRC_URL="https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-${PYTHON_DOTENV_VERSION}.tar.gz"
PYTHON_DOTENV_SRC_SHA256="2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
# =============================================================================
# Slackware package BUILD number (not upstream version). Bump this if a
+120 -42
View File
@@ -1,4 +1,6 @@
Menu="Podman"
Menu="Tasks:66"
Type="xmenu"
Tabs="false"
Title="Podman"
Icon="podman"
---
@@ -21,14 +23,28 @@ Icon="podman"
* Cache-busts every static asset with its own on-disk mtime. Unraid's
* webserver sends no explicit no-cache headers for /plugins/ static
* files, so without this, browsers can keep serving a stale app.js/
* podman.css for a long time after a plugin update — verified live: a
* bugfix to app.js's CSRF handling silently kept failing in a real
* browser after redeploy until this was added, even though the deployed
* file on disk was byte-for-byte correct.
* podman.css for a long time after a plugin update.
*
* Deliberately a hardcoded absolute path, NOT __DIR__ — Unraid's own
* PageBuilder runs every .page file's PHP through eval() (see
* webGui/include/DefaultPageLayout/evalContent.php: "eval($evalContent)"),
* and __DIR__/__FILE__ inside eval()'d code resolve to the eval() CALL
* SITE (that file's own directory), not to Podman.page's real location —
* a standard PHP eval() gotcha. Confirmed live: this made
* podman_asset_version() return '0' for every single asset, always,
* completely independent of any browser or PHP-FPM caching (ruled both
* out first: neither a hard-reload nor a php-fpm restart changed the
* result) — the version query string was never actually cache-busting
* anything all session; every "hard refresh fixed it" moment was the
* browser's own Ctrl+Shift+R bypass, not this mechanism. The rest of
* this codebase already hardcodes this same install path elsewhere
* (e.g. include/Config.php's $bootDir, plugin/podman.plg's event hook
* paths) — Unraid plugins always install to /usr/local/emhttp/plugins/
* <name>, so this isn't a new class of fragility.
*/
function podman_asset_version(string $relPath): string
{
$full = __DIR__ . $relPath;
$full = '/usr/local/emhttp/plugins/podman' . $relPath;
return is_file($full) ? (string) filemtime($full) : '0';
}
?>
@@ -84,11 +100,13 @@ function podman_asset_version(string $relPath): string
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<div class="podman-segmented" id="containers-filterset">
<button class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div>
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button>
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div>
<div class="podman-table-wrap">
@@ -111,6 +129,7 @@ function podman_asset_version(string $relPath): string
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
<button class="podman-btn" id="images-prune-btn" style="margin-left:auto;">Prune unused</button>
<button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div>
<div class="podman-table-wrap">
@@ -165,7 +184,7 @@ function podman_asset_version(string $relPath): string
<div>
<div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<span class="podman-segmented" id="logs-follow-toggle">
<button class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button>
</span>
@@ -179,12 +198,22 @@ function podman_asset_version(string $relPath): string
<!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad">
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
Exec into: <select id="term-container-select"></select>
<div class="podman-term-launcher">
<label>Container <select class="podman-term-select" id="term-container-select"></select></label>
<label>Shell
<select class="podman-term-select" id="term-shell-select">
<option value="bash" selected>bash</option>
<option value="sh">sh</option>
</select>
</label>
<button class="podman-btn podman-btn-primary" id="term-open-btn">&#9654; Open Terminal</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>&#9632; Disconnect</button>
</div>
<div id="term-frame-wrap">
<p class="podman-empty-note">Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).</p>
</div>
<div class="podman-term" id="term-output"></div>
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
</div>
</div>
</section>
@@ -193,15 +222,22 @@ function podman_asset_version(string $relPath): string
<section class="podman-panel" id="podman-panel-compose">
<div class="podman-card">
<div class="podman-compose-layout">
<div class="podman-compose-side" id="compose-sidebar"></div>
<div class="podman-compose-side">
<div class="podman-toolbar" style="border-bottom:1px solid var(--border); padding:10px;">
<button class="podman-btn podman-btn-primary" id="compose-new-btn" style="width:100%; justify-content:center;">+ New Project</button>
</div>
<div id="compose-sidebar"></div>
</div>
<div>
<div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="compose-action-delete">Delete</button>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
</div>
<pre class="podman-yaml" id="compose-yaml"></pre>
<textarea class="podman-yaml podman-yaml-editor mono" id="compose-yaml" spellcheck="false"></textarea>
</div>
</div>
</div>
@@ -209,9 +245,32 @@ function podman_asset_version(string $relPath): string
<!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings">
<div class="podman-settings-actions">
<span class="hint" id="settings-save-hint">Changes to storage/enabled/timeout need <span class="mono">rc.podman restart</span> to take effect.</span>
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div>
<div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div>
<div class="podman-card-head">
<div><h2>Podman Service</h2><div class="sub">Start, stop, restart, or check the podman.sock backend — no terminal needed.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-service-row">
<span class="podman-chip podman-chip-neutral" id="settings-service-chip"><span class="d"></span>Checking…</span>
<button class="podman-btn" id="settings-service-status-btn">Refresh Status</button>
<button class="podman-btn podman-btn-primary" id="settings-service-start-btn">&#9654; Start Podman</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="settings-service-stop-btn">&#9632; Stop Podman</button>
<button class="podman-btn" id="settings-service-restart-btn">&#8635; Restart Podman</button>
</div>
<div class="hint">Stop/Restart first stop all running containers (each gets its own configured grace period) — not just the API service.</div>
<div class="podman-log-pane" id="settings-service-log" style="display:none;"></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-storage-path">Storage path</label>
<div>
@@ -220,41 +279,60 @@ function podman_asset_version(string $relPath): string
</div>
</div>
<div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div>
<div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
<label>No cache pool?</label>
<div>
<button class="podman-btn" id="settings-format-disk-btn">Format a Disk for Podman Storage…</button>
<div class="hint">Formats an unused disk with XFS and mounts it, for a single-disk system with no cache pool set up yet.</div>
</div>
</div>
<div class="podman-field-row">
<label></label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
<label for="settings-storage-size">podman.img size</label>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-storage-size" min="1"> <span>GB</span></div>
<div class="hint">Overlay filesystem image size. Only applies the first time podman initializes storage at this path.</div>
</div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div>
<div class="podman-card-head">
<div><h2>Autostart &amp; Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
</div>
<div class="podman-field-row">
<label>Versions</label>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
<label for="settings-enabled">Start podman on array start</label>
<div>
<label class="podman-switch">
<input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
</label>
</div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-stop-timeout" min="0"> <span>seconds</span></div>
<div class="hint">Grace period before a stop/restart escalates to SIGKILL.</div>
</div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
</div>
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div>
</div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head">
<div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div>
</div>
</div>
+94 -6
View File
@@ -27,6 +27,8 @@
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml
* remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
@@ -49,6 +51,15 @@ switch ($action) {
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'save':
$body = podman_read_json_body();
podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? '')));
break;
case 'remove':
podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body())));
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
@@ -136,6 +147,79 @@ function compose_status(string $composeDir, string $project): string
return $running > 0 ? 'up' : 'down';
}
/**
* Creates a new project (directory doesn't exist yet) or overwrites an
* existing one's compose.yaml. Validated via the real tool — `podman
* compose ... config` parses and resolves the file, exiting non-zero with
* a specific line/column message on invalid YAML/schema (verified live)
* — rather than a hand-rolled YAML parser, since PHP has no YAML
* extension available here to begin with. Written to a *.new sibling
* file first and only renamed into place once validation passes, so a
* bad edit never corrupts a previously-working compose.yaml.
*
* @return array<string,mixed>
*/
function compose_save(string $composeDir, string $project, string $yaml): array
{
if (trim($yaml) === '') {
podman_json_error('compose.yaml content cannot be empty', 400);
}
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir) && !mkdir($projectDir, 0755, true) && !is_dir($projectDir)) {
podman_json_error("Could not create project directory for '{$project}'", 500);
}
$yamlPath = $projectDir . '/compose.yaml';
$tmpName = 'compose.yaml.new';
if (file_put_contents($projectDir . '/' . $tmpName, $yaml) === false) {
podman_json_error('Could not write compose.yaml', 500);
}
$result = run_compose_command($composeDir, $project, ['config'], 30, $tmpName);
if ($result['exitCode'] !== 0) {
@unlink($projectDir . '/' . $tmpName);
podman_json_error("Invalid compose file:\n" . trim($result['output']), 400);
}
if (!rename($projectDir . '/' . $tmpName, $yamlPath)) {
podman_json_error('Could not save compose.yaml', 500);
}
return ['status' => 'saved'];
}
/**
* Best-effort `down` (ignored if it fails — e.g. already down, or the
* file was mid-edit and invalid) so deleting a running project's files
* doesn't leave orphaned containers/networks behind, then deletes just
* that one project's own directory. $project is validated by
* require_project() before this is ever called, so $projectDir can't
* escape $composeDir.
*
* @return array<string,mixed>
*/
function compose_remove(string $composeDir, string $project): array
{
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir)) {
podman_json_error("Project '{$project}' not found", 404);
}
run_compose_command($composeDir, $project, ['down'], 60);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($projectDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($projectDir);
return ['status' => 'removed'];
}
function compose_read(string $composeDir, string $project): string
{
if (!is_valid_project_name($project)) {
@@ -168,9 +252,9 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
* @param array<int,string> $subcommand
* @return array{exitCode:int,stdout:string,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
@@ -187,8 +271,12 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
$exitCode = proc_close($process);
// 'stdout' (raw) for callers that need to parse machine-readable
// output (e.g. compose_status()'s JSON); 'output' (combined,
// trimmed) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context.
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)];
// output (e.g. compose_status()'s JSON); 'output' (combined, trimmed,
// ANSI-stripped) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context
// just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found
// live: they showed up as literal garbage characters in the WebUI's
// error alerts).
$combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr);
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)];
}
+179 -2
View File
@@ -18,10 +18,14 @@
* kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text
* list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
* "privileged": false, "startAfterCreate": true}
*/
@@ -105,6 +109,14 @@ switch ($action) {
podman_json_response(['status' => 'renamed']);
break;
case 'list_gpus':
podman_json_response(gpu_list());
break;
case 'check_updates':
podman_json_response(check_image_updates($client));
break;
case 'create':
$body = podman_read_json_body();
$image = trim((string) ($body['image'] ?? ''));
@@ -138,6 +150,102 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Checks every image currently backing a (non-infra) container against its
* origin registry — see RegistryClient for how, and why this isn't a
* podman/libpod feature at all. Deduplicated per unique image reference
* first (several containers commonly share the same image), so a host
* with e.g. five containers all on the same base image only makes one
* real registry request for it, not five.
*
* @return array<string,array<string,mixed>> keyed by image reference
*/
function check_image_updates(PodmanClient $client): array
{
$digestByImageId = [];
foreach ($client->listImages() as $img) {
$digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? '');
}
$localDigestByRef = [];
foreach ($client->listContainers(true) as $c) {
if ($c['IsInfra'] ?? false) {
continue;
}
$ref = (string) ($c['Image'] ?? '');
$imageId = (string) ($c['ImageID'] ?? '');
if ($ref === '' || !isset($digestByImageId[$imageId])) {
continue;
}
$localDigestByRef[$ref] = $digestByImageId[$imageId];
}
$out = [];
foreach ($localDigestByRef as $ref => $localDigest) {
$out[$ref] = $localDigest === ''
? ['error' => 'No local digest recorded for this image.']
: RegistryClient::checkForUpdate($ref, $localDigest);
}
return $out;
}
/**
* Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod
* API (libpod has no GPU inventory endpoint; this is plain host hardware
* detection). NVIDIA is deliberately excluded: it needs the separate
* nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough,
* so listing it here would offer a checkbox that doesn't actually work.
* Verified live: card/render pairs from the same GPU share a "device"
* symlink target under /sys/class/drm, which is how they're grouped below;
* vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs).
*
* @return array<int,array<string,mixed>>
*/
function gpu_list(): array
{
if (!is_dir('/sys/class/drm')) {
return [];
}
$byDevice = [];
foreach (scandir('/sys/class/drm') ?: [] as $entry) {
if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) {
continue;
}
$devicePath = "/sys/class/drm/{$entry}/device";
$target = @readlink($devicePath);
if ($target === false) {
continue;
}
$vendorFile = "{$devicePath}/vendor";
if (!is_file($vendorFile)) {
continue;
}
$vendorId = trim((string) @file_get_contents($vendorFile));
$byDevice[$target]['vendorId'] ??= $vendorId;
$byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}";
}
$vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA'];
$out = [];
foreach ($byDevice as $group) {
$vendorId = $group['vendorId'] ?? '';
$vendorName = $vendorNames[$vendorId] ?? $vendorId;
// NVIDIA needs the nvidia-container-toolkit runtime, not a plain
// /dev/dri passthrough — excluded so the checkbox we offer always
// actually works (see function comment).
if ($vendorName === 'NVIDIA' || !isset($group['render'])) {
continue;
}
$out[] = [
'vendor' => $vendorName,
'card' => $group['card'] ?? null,
'render' => $group['render'],
];
}
return $out;
}
/**
* Builds a libpod SpecGenerator body (POST /containers/create) from the
* WebUI's Create Container form fields. Field names/shapes here
@@ -228,7 +336,21 @@ function build_container_spec(string $image, array $body): array
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
$spec['netns'] = ['nsmode' => $networkMode];
} elseif ($networkMode !== '') {
$spec['networks'] = [$networkMode => new \stdClass()];
// A static IP only makes sense on a custom (typically macvlan)
// network — verified live that "networks":{"<name>":{"static_ips":
// [...]}} assigns it, same as podman itself does for --ip. Basic
// IPv4-shape validation only (not full RFC-correctness) — this
// goes straight into a create request against the local podman
// socket, not anywhere it could reach untrusted input otherwise.
$staticIp = trim((string) ($body['staticIp'] ?? ''));
if ($staticIp !== '') {
if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) {
podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400);
}
$spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]];
} else {
$spec['networks'] = [$networkMode => new \stdClass()];
}
}
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
@@ -238,6 +360,29 @@ function build_container_spec(string $image, array $body): array
$spec['privileged'] = true;
}
$devices = [];
foreach (($body['gpuDevices'] ?? []) as $path) {
// Only ever pass through paths matching the exact shape gpu_list()
// itself reports — the client only ever gets those as checkbox
// values, but this is the boundary where a tampered/malicious
// request body gets rejected rather than handing arbitrary host
// device paths (e.g. "/dev/sda") straight into the container spec.
if (is_string($path) && preg_match('#^/dev/dri/(card|renderD)\d+$#', $path) === 1) {
$devices[] = ['path' => $path];
}
}
if ($devices !== []) {
$spec['devices'] = $devices;
}
$pod = trim((string) ($body['pod'] ?? ''));
if ($pod !== '') {
// "pod" joins an existing pod's shared network namespace — verified
// live that it can be sent alongside "netns" above without
// conflict (podman just defers to the pod's namespace).
$spec['pod'] = $pod;
}
return $spec;
}
@@ -265,6 +410,17 @@ function containers_list(PodmanClient $client): array
$out = [];
foreach ($raw as $c) {
// Every pod has a hidden "infra" container managing its shared
// network namespace — not something a user creates or can
// meaningfully stop/remove on its own (found live: it always
// shows "running" with no independent lifecycle, so Containers
// panel gets a permanently un-removable row once any pod exists;
// it already appears as its own row in the Pods panel). See
// ajax/pods.php for actual pod lifecycle management.
if ($c['IsInfra'] ?? false) {
continue;
}
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
@@ -280,6 +436,24 @@ function containers_list(PodmanClient $client): array
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown'));
// One extra local-socket round trip per running container (~15ms
// each, verified live — negligible for a home host's container
// count). Best-effort: a container that stops between the list
// call above and this one shouldn't blank out the whole table.
$cpuPercent = null;
$memUsageBytes = null;
$memLimitBytes = null;
if ($state === 'running') {
try {
$stats = $client->containerStats((string) ($c['Id'] ?? ''));
$cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null;
$memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null;
$memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null;
} catch (PodmanApiException $e) {
// leave stats null
}
}
$out[] = [
'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
@@ -293,6 +467,9 @@ function containers_list(PodmanClient $client): array
'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? null),
'cpuPercent' => $cpuPercent,
'memUsageBytes' => $memUsageBytes,
'memLimitBytes' => $memLimitBytes,
];
}
+289
View File
@@ -0,0 +1,289 @@
<?php
/**
* ajax/disks.php
*
* Backs the Settings panel's "Format a Disk for Podman Storage" flow — for
* a single-disk system with no cache pool set up yet, where podman's own
* storage path (see include/Config.php's $storagePath default,
* /mnt/cache/system/podman) has nowhere real to live, and podman never
* fully starts as a result (found live: a fresh install on a system with
* no cache pool failed with "cannot reach the Podman API socket" because
* podman-storage.sh's `create` step refuses to run against a path that
* isn't an actual mounted filesystem — see that script's header comment
* for why /mnt/user/... FUSE paths don't work either).
*
* This is the one place in the plugin that formats a physical disk —
* deliberately narrow and defensive: every candidate the "format" action
* is asked to touch is re-derived server-side from the same safe-listing
* logic "list_candidates" uses, never trusted from the request alone, and
* only ever a whole, currently-unmounted, non-array disk with no existing
* partitions is eligible in the first place.
*
* Actions (?action=...):
* list_candidates GET -> [{device, sizeBytes, sizeFormatted, model, hasData}, ...]
* format POST {"device": "/dev/sdc"} -> {"mountPath": "/mnt/disks/podman-storage"}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const MOUNT_PATH = '/mnt/disks/podman-storage';
const MANAGED_DISK_CFG = '/boot/config/plugins/podman/managed-disk.cfg';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list_candidates':
podman_json_response(list_candidate_disks());
break;
case 'format':
$body = podman_read_json_body();
$device = (string) ($body['device'] ?? '');
podman_json_response(format_disk($device));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Runs a command and returns [exitCode, stdout+stderr combined]. Every
* caller here passes a fixed argv array (never a shell string built from
* request input), so there is no injection surface even before the
* device-path validation in format_disk() below.
*
* @param array<int,string> $argv
* @return array{0:int,1:string}
*/
function run(array $argv, int $timeoutSeconds = 30): array
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes);
if (!is_resource($process)) {
return [127, "could not start {$argv[0]}"];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$out = (stream_get_contents($pipes[1]) ?: '') . (stream_get_contents($pipes[2]) ?: '');
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, trim($out)];
}
/**
* Every whole disk (not partition) libpod's host isn't already using —
* mounted anywhere (itself or any partition), part of Unraid's own
* array/cache pool (cross-checked against /var/local/emhttp/disks.ini,
* the same state file Unraid's own array management writes — the boot
* flash device is covered by this same check, since Unraid lists it there
* too), or the disk backing the currently-booted root/flash filesystem.
*
* @return array<int,array<string,mixed>>
*/
function list_candidate_disks(): array
{
// Nested partitions come back automatically as a "children" array in
// -J's JSON tree — CHILDREN is not a real -o column (lsblk itself
// rejects it: "unknown column: CHILDREN"; verified live against a
// real host's lsblk). LABEL is included specifically to catch the
// Unraid boot flash drive, which FAT-labels itself "UNRAID" — see the
// safety note below for why that check exists at all.
[$code, $out] = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,LABEL'], 10);
if ($code !== 0) {
podman_json_error('Could not list block devices: ' . $out, 500);
}
$tree = json_decode($out, true);
if (!is_array($tree) || !isset($tree['blockdevices'])) {
podman_json_error('Unexpected lsblk output', 500);
}
$arrayDevices = unraid_array_device_names();
$candidates = [];
foreach ($tree['blockdevices'] as $dev) {
if (($dev['type'] ?? '') !== 'disk') {
continue;
}
$name = (string) ($dev['name'] ?? '');
$baseName = basename($name);
if (in_array($baseName, $arrayDevices, true)) {
continue;
}
// zram devices report TYPE "disk" too (RAM-backed, not persistent
// storage — pointless and misleading to offer for this) and an
// empty card-reader slot with no card inserted reports as a real
// "disk" at 0 bytes; both are excluded outright rather than left
// for the size-based hasData check below.
if (str_starts_with($baseName, 'zram') || (int) ($dev['size'] ?? 0) <= 0) {
continue;
}
if (device_or_children_mounted($dev)) {
continue;
}
if (device_or_children_labeled_unraid($dev)) {
continue;
}
// Deliberately excludes (not just warns about) ANY disk that
// already has a filesystem, partition, or other signature on it
// or any of its partitions — not just ones lsblk reports as
// currently mounted. Found live: a real host's cache pool disks
// showed up here as "safe" with only a soft warning, because
// they're ZFS pool members (fstype "zfs_member"/partition
// present) rather than plain mounts, so the earlier
// mounted-only check missed them entirely — the same blind spot
// that also let the Unraid boot USB stick's own partition
// through (FAT, not reported as "mounted" by lsblk either). A
// disk being reused for podman storage must be genuinely blank;
// asking a user to wipe it themselves first is a small price for
// this being impossible to get wrong.
if (!empty($dev['fstype']) || !empty($dev['children'])) {
continue;
}
$candidates[] = [
'device' => $name,
'sizeBytes' => (int) ($dev['size'] ?? 0),
'sizeFormatted' => podman_format_bytes((int) ($dev['size'] ?? 0)),
'model' => trim((string) ($dev['model'] ?? '')) ?: null,
];
}
return $candidates;
}
function device_or_children_mounted(array $dev): bool
{
if (!empty($dev['mountpoint'])) {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_mounted($child)) {
return true;
}
}
return false;
}
/**
* Catches the Unraid boot flash drive specifically: it FAT-labels itself
* "UNRAID" (verified live: `blkid` on a real host's boot partition shows
* LABEL_FATBOOT="UNRAID" LABEL="UNRAID") and — on at least one real,
* modern Unraid setup — /boot is actually backed by a ZFS dataset
* ("flash/boot"), not a direct mount of that partition, so it does NOT
* show up as "mounted" via lsblk's own MOUNTPOINT column at all. This
* label check is a second, independent layer specifically because that
* gap meant the boot drive briefly passed every other check here during
* development — never rely on a single signal for something this
* destructive.
*/
function device_or_children_labeled_unraid(array $dev): bool
{
if (strtoupper(trim((string) ($dev['label'] ?? ''))) === 'UNRAID') {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_labeled_unraid($child)) {
return true;
}
}
return false;
}
/**
* @return array<int,string> bare device names ("sda", "nvme0n1", ...) Unraid
* itself has assigned to the array or a cache pool, read from the same
* /var/local/emhttp/disks.ini Unraid's own array management writes —
* NOT parsed/guessed, so this stays correct across whatever array
* layout a given host actually has.
*/
function unraid_array_device_names(): array
{
$path = '/var/local/emhttp/disks.ini';
if (!is_readable($path)) {
return [];
}
$ini = @parse_ini_file($path, true);
if (!is_array($ini)) {
return [];
}
$names = [];
foreach ($ini as $section) {
if (is_array($section) && !empty($section['device'])) {
$names[] = basename((string) $section['device']);
}
}
return $names;
}
/** @return array<string,mixed> */
function format_disk(string $device): array
{
if (!preg_match('#^/dev/(sd[a-z]+|nvme\d+n\d+|vd[a-z]+)$#', $device)) {
podman_json_error('Invalid or unsupported device path', 400);
}
$allowed = array_column(list_candidate_disks(), 'device');
if (!in_array($device, $allowed, true)) {
podman_json_error("{$device} is not a currently-eligible disk (already in use, part of the array, or not found) — refusing to format it.", 400);
}
$partDevice = preg_match('#nvme\d+n\d+$#', $device) ? "{$device}p1" : "{$device}1";
[$code, $out] = run(['wipefs', '-a', $device], 30);
if ($code !== 0) {
podman_json_error("wipefs failed: {$out}", 500);
}
[$code, $out] = run(['parted', '-s', $device, 'mklabel', 'gpt', 'mkpart', 'primary', '1MiB', '100%'], 30);
if ($code !== 0) {
podman_json_error("parted failed: {$out}", 500);
}
run(['partprobe', $device], 10);
// Partition device nodes can take a moment to appear after partprobe.
for ($i = 0; $i < 20 && !file_exists($partDevice); $i++) {
usleep(250000);
}
if (!file_exists($partDevice)) {
podman_json_error("Partition {$partDevice} did not appear after partitioning {$device}", 500);
}
[$code, $out] = run(['mkfs.xfs', '-f', '-n', 'ftype=1', '-L', 'podmanstorage', $partDevice], 60);
if ($code !== 0) {
podman_json_error("mkfs.xfs failed: {$out}", 500);
}
[$code, $uuid] = run(['blkid', '-s', 'UUID', '-o', 'value', $partDevice], 10);
$uuid = trim($uuid);
if ($code !== 0 || $uuid === '') {
podman_json_error('Could not determine filesystem UUID after formatting', 500);
}
if (!is_dir(MOUNT_PATH) && !mkdir(MOUNT_PATH, 0755, true) && !is_dir(MOUNT_PATH)) {
podman_json_error('Could not create ' . MOUNT_PATH, 500);
}
[$code, $out] = run(['mount', 'UUID=' . $uuid, MOUNT_PATH], 15);
if ($code !== 0) {
podman_json_error("mount failed: {$out}", 500);
}
// Persisted so plugin/sbin/podman-mount-managed-disk.sh (called from
// the disks_mounted event hook, before rc.podman start) can remount
// this same disk by UUID on every future boot — nothing else on the
// system knows about this disk, since it's deliberately outside
// Unraid's own array/cache pool management.
$cfgDir = dirname(MANAGED_DISK_CFG);
if (!is_dir($cfgDir) && !mkdir($cfgDir, 0755, true) && !is_dir($cfgDir)) {
podman_json_error('Could not create ' . $cfgDir, 500);
}
$cfgContent = "# Written by the unraid-podman WebUI (ajax/disks.php) — do not edit by hand.\n"
. 'UUID="' . $uuid . "\"\n"
. 'MOUNTPOINT="' . MOUNT_PATH . "\"\n";
if (file_put_contents(MANAGED_DISK_CFG, $cfgContent, LOCK_EX) === false) {
podman_json_error('Formatted and mounted, but could not persist ' . MANAGED_DISK_CFG . ' for future boots', 500);
}
return ['mountPath' => MOUNT_PATH];
}
+127 -37
View File
@@ -3,35 +3,49 @@
* ajax/exec.php
*
* Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat
* spelled out rather than silently glossed over:
* via podman system service, no shell hacks" needs an honest caveat spelled
* out rather than silently glossed over (the same exception ajax/compose.php
* documents for the same underlying reason: some things have no REST
* equivalent).
*
* libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
* shells out to the `podman` binary. But that API's interactive mode works
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
* bidirectional byte stream for the lifetime of the shell session. That
* model assumes a long-lived process holding the socket open on both ends
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
* independent, short-lived process with no memory of any previous one.
* POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* is upgraded into a raw bidirectional byte stream for the lifetime of the
* shell session. That model assumes a long-lived process holding the socket
* open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* is a fresh, independent, short-lived process with no memory of any
* previous one. An earlier version of this file worked around that by
* offering one-shot "run a command, see its output" exec calls — honest
* about not being a real terminal, but not what a user expects when they
* open a "Console" tab (no history, no vim, no persistent `cd`).
*
* Rather than fake interactivity with something that would break on the
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
* offers a deliberately simpler, honest contract: one command in, its
* complete output back, using Tty=true so output reads like a real
* terminal (colors, prompts-in-output, etc. survive) but with no
* persistent shell state (`cd` does not carry over between calls — see
* the "cwd" parameter below, which javascript/terminal.js tracks
* client-side and resends every time instead).
* Unraid's own webGui already solves exactly this problem for its System
* Terminal and for `docker exec` (see
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* block) — by spawning one `ttyd` instance per session, bound to a unix
* socket under /var/tmp, wrapping the real interactive command; nginx then
* proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* generically, for ANY name. That proxy rule is already installed and
* already generic — this endpoint reuses it exactly the same way Unraid's
* own docker integration does, just with `podman exec -it` instead of
* `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small
* wrapper script Unraid ships system-wide (sources /etc/default/ttyd for
* common xterm.js options, then execs ttyd in the background) — not
* something this plugin needs to vendor.
*
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
* would need a WebSocket-capable process sitting between the browser and
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
* follow-up rather than implemented as a shell-out workaround.
* This is the one place in the plugin that shells out to the `podman`
* binary via proc invocation rather than the REST API — container names
* are validated against a fixed safe pattern and passed through
* escapeshellarg(), never concatenated into a shell string.
*
* Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
* open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."}
* Caller then points an iframe/window at /logterminal/<sockName>/.
* close POST {"name": "..."} -> {"status": "closed"}
* Kills the ttyd instance (and, via it, the `podman exec` it
* wraps) for that container, if one is running.
*/
declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'run':
case 'open':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$commandLine = (string) ($body['cmd'] ?? '');
$cwd = (string) ($body['cwd'] ?? '');
$name = (string) ($body['name'] ?? '');
$shell = (string) ($body['shell'] ?? 'sh');
if ($id === '' || trim($commandLine) === '') {
podman_json_error('Missing id or cmd in request body', 400);
// Same character set libpod itself allows in container names —
// rejecting anything else here (BEFORE it's ever used to build a
// socket path or shell command) is what makes escapeshellarg() on
// top of it a defense in depth rather than the only line of
// defense.
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
if (!in_array($shell, ['sh', 'bash'], true)) {
podman_json_error('Invalid shell', 400);
}
// The command line is run through the container's own shell
// (sh -c) so the user can type ordinary shell syntax (pipes,
// globs, env vars) in the terminal box, exactly like a real
// shell prompt would accept — still one real exec API call, just
// with /bin/sh as the interpreter instead of us parsing shell
// syntax ourselves in PHP.
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
podman_json_response(open_terminal($name, $shell));
break;
podman_json_response(['output' => $output]);
case 'close':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
close_terminal($name);
podman_json_response(['status' => 'closed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
function sock_path_for(string $containerName): string
{
// "podman." prefix keeps this plugin's per-container sockets under
// /var/tmp from ever colliding with Unraid's own docker-exec sockets
// (/var/tmp/<name>.sock), which are named after the same container
// names a user might also give their podman containers.
return '/var/tmp/podman.' . $containerName . '.sock';
}
/**
* @return array<string,mixed>
*/
function open_terminal(string $containerName, string $shell): array
{
// Close out any previous session for this container first — sockets
// are named deterministically per-container (not per-open-call), so
// without this, re-opening the same container's terminal (or switching
// shells) would try to bind a second ttyd to the same path and leave
// the first one orphaned, still running, holding /dev resources for a
// client that will never come.
close_terminal($containerName);
$sockPath = sock_path_for($containerName);
// -s9: send SIGKILL to the wrapped command when the client disconnects
// (no orphaned `podman exec` process lingering after the window is
// closed). -o -m1: accept exactly one client, then exit instead of
// staying resident waiting for a next one — matching exactly the
// options Unraid's own OpenTerminal.php uses for `docker exec` (see
// that file's 'docker' case).
$cmd = sprintf(
'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s',
escapeshellarg($sockPath),
escapeshellarg($containerName),
escapeshellarg($shell)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
podman_json_error('Could not start terminal session', 500);
}
return ['sockName' => 'podman.' . $containerName];
}
/**
* Kills the ttyd instance (if any) bound to this container's socket, and
* removes the socket file. Matched via `pgrep -f` against the socket path
* embedded in ttyd's own argv (the -i flag passed in open_terminal()) —
* that's a stable, unique needle since it includes the "podman." prefix
* and the validated container name. Killing ttyd itself (rather than
* just closing a client connection nothing is holding) tears down the
* `podman exec` child with it, same as closing a real terminal window
* would once a client was attached.
*/
function close_terminal(string $containerName): void
{
$sockPath = sock_path_for($containerName);
exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids);
foreach ($pids as $pid) {
if (ctype_digit($pid)) {
exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
}
@unlink($sockPath);
}
+23
View File
@@ -8,6 +8,8 @@
* list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false}
* prune POST {} -> removes every image not used by any container
* tag POST {"id": "...", "repo": "...", "tag": "latest"}
*/
declare(strict_types=1);
@@ -40,6 +42,27 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'prune':
$removed = $client->pruneImages();
$reclaimed = 0;
foreach ($removed as $r) {
$reclaimed += (int) ($r['Size'] ?? 0);
}
podman_json_response(['removedCount' => count($removed), 'reclaimedBytes' => $reclaimed]);
break;
case 'tag':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$repo = trim((string) ($body['repo'] ?? ''));
$tag = trim((string) ($body['tag'] ?? '')) ?: 'latest';
if ($id === '' || $repo === '') {
podman_json_error('Missing id or repo in request body', 400);
}
$client->tagImage($id, $repo, $tag);
podman_json_response(['status' => 'tagged']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
+85 -5
View File
@@ -7,9 +7,11 @@
* from Docker's own docker0/custom-network space.
*
* Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
* remove POST {"name": "...", "force": false}
* list GET -> normalized network list with subnet/gateway/usage
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
* "gateway": "...", "parentInterface": "br0"}
* remove POST {"name": "...", "force": false}
*/
declare(strict_types=1);
@@ -23,17 +25,40 @@ switch ($action) {
podman_json_response(networks_list($client));
break;
case 'list_parent_interfaces':
podman_json_response(macvlan_parent_interfaces());
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
$driver = (string) ($body['driver'] ?? 'bridge');
$parentInterface = null;
if ($driver === 'macvlan') {
$parentInterface = (string) ($body['parentInterface'] ?? '');
// Only ever accept an interface this same host reported via
// macvlan_parent_interfaces() — the boundary preventing a
// tampered request from asking podman to attach to an
// arbitrary/unexpected interface name.
$known = array_column(macvlan_parent_interfaces(), 'interface');
if (!in_array($parentInterface, $known, true)) {
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
}
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
podman_json_error('Subnet is required for a macvlan network.', 400);
}
}
podman_json_response($client->createNetwork(
$name,
(string) ($body['driver'] ?? 'bridge'),
$driver,
isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null
isset($body['gateway']) ? (string) $body['gateway'] : null,
$parentInterface
));
break;
@@ -54,6 +79,61 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
* (VPN)" network types — reusing Unraid's own config instead of guessing
* from raw `ip link` output, so the list always matches what Docker
* Manager shows for the same host. Verified live: this host's
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
* names shown in that other plugin's own network-type dropdown exactly.
* Each candidate is confirmed to actually exist in /sys/class/net before
* being offered, in case network.cfg mentions an interface that isn't
* currently up.
*
* @return array<int,array{interface:string,label:string}>
*/
function macvlan_parent_interfaces(): array
{
$cfgFile = '/boot/config/network.cfg';
if (!is_file($cfgFile)) {
return [];
}
$cfg = [];
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
continue;
}
[, $key, $i, $j, $value] = $m + [3 => ''];
$i = (int) $i;
if ($j === '') {
$cfg[$key][$i] = $value;
} else {
$cfg[$key][$i][(int) $j] = $value;
}
}
$out = [];
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
continue;
}
$out[] = ['interface' => $brname, 'label' => $brname];
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
$iface = "{$brname}.{$vlanId}";
if (!is_dir("/sys/class/net/{$iface}")) {
continue;
}
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
}
}
return $out;
}
/** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array
{
+63
View File
@@ -9,8 +9,10 @@
*
* Actions (?action=...):
* list GET -> pods with nested container summaries
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
* start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10}
* restart POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false}
*/
@@ -25,6 +27,12 @@ switch ($action) {
podman_json_response(pods_list($client));
break;
case 'create':
$body = podman_read_json_body();
$id = $client->createPod(build_pod_spec($body));
podman_json_response(['id' => $id, 'status' => 'created']);
break;
case 'start':
$body = podman_read_json_body();
$client->startPod(require_name($body));
@@ -37,6 +45,12 @@ switch ($action) {
podman_json_response(['status' => 'stopped']);
break;
case 'restart':
$body = podman_read_json_body();
$client->restartPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'restarted']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
@@ -47,6 +61,55 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Builds a libpod pod-create body from the "New Pod" form fields. Verified
* live against a real podman system service — {"name": "...",
* "portmappings": [...]} creates a pod with a shared infra container whose
* port bindings apply to every member container.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_pod_spec(array $body): array
{
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
// Same character set podman enforces for container names (define.NameRegex
// in libpod applies to pods too) — validated here for the same reason
// ajax/containers.php validates it: a clear message instead of podman's
// raw "names must match ...: invalid argument".
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Pod name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec = ['name' => $name];
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_name(array $body): string
{
+73
View File
@@ -16,12 +16,18 @@
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
* "enabled": true, "stopTimeoutSeconds": 10}
* autostart_save POST {"names": ["postgres", "nextcloud", ...]}
* service_status GET -> {"running": bool, "output": "..."}
* service_start POST -> {"running": bool, "output": "..."}
* service_stop POST -> {"running": bool, "output": "..."}
* service_restart POST -> {"running": bool, "output": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const RC_PODMAN = '/etc/rc.d/rc.podman';
$action = $_GET['action'] ?? '';
switch ($action) {
@@ -45,10 +51,77 @@ switch ($action) {
podman_json_response(['status' => 'saved']);
break;
case 'service_status':
podman_json_response(rc_podman('status', 15));
break;
case 'service_start':
podman_json_response(rc_podman('start', 120));
break;
case 'service_stop':
podman_json_response(rc_podman('stop', 120));
break;
case 'service_restart':
podman_json_response(rc_podman('restart', 120));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Shells out to /etc/rc.d/rc.podman <verb> — the plugin's own real
* start/stop/status script, the SAME one the array-start event hook and
* a terminal `rc.podman status` use (see plugin/rc.d/rc.podman's header
* comment). This exists specifically so a fresh install where podman
* failed to start (e.g. no cache pool configured yet, see
* podman-storage.sh's "does not exist or is not mounted" error) can be
* diagnosed and retried from the WebUI itself — no SSH/terminal access
* needed, which is exactly what was missing when this was first needed
* live (a fresh install on a different Unraid box with nobody able to
* reach a terminal to run `rc.podman start` by hand).
*
* @return array{running: bool, output: string}
*/
function rc_podman(string $verb, int $timeoutSeconds): array
{
$output = run_rc_podman($verb, $timeoutSeconds);
// Only `rc.podman status` prints the "service: running
// (pid ..., socket ...)" line this regex looks for — start/stop/
// restart's OWN messages are worded differently ("start: already
// running (pid ...)", "stop: stopped", ...), so relying on THIS same
// regex against THEIR output silently reported "not running" right
// after a successful start (found live: a start that printed "start:
// already running" turned the status chip red). Always running a
// fresh `status` afterward — regardless of which verb was actually
// requested — is the one output format this check can trust.
$statusOutput = $verb === 'status' ? $output : run_rc_podman('status', 15);
$running = (bool) preg_match('/service:\s+running/', $statusOutput);
return ['running' => $running, 'output' => $output];
}
function run_rc_podman(string $verb, int $timeoutSeconds): string
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open([RC_PODMAN, $verb], $descriptors, $pipes);
if (!is_resource($process)) {
podman_json_error("Could not run rc.podman {$verb}", 500);
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return trim($stdout . $stderr);
}
/** @return array<string,mixed> */
function settings_get(PodmanConfig $config): array
{
+78 -42
View File
@@ -114,7 +114,15 @@ final class PodmanClient
*/
public function createContainer(array $spec): string
{
$result = $this->request('POST', '/containers/create', [], false, $spec);
// Longer than this client's normal 15s operation timeout as cheap
// insurance: creating a container involves setting up its mounts
// (often onto Unraid array/spinning-disk shares, not the cache
// pool) and network namespace, which can occasionally run past 15s
// even with the image already pulled — found live via a real
// "Operation timed out after 15001 milliseconds" error creating a
// container. Same 600s ceiling as pullImage(), safely under
// nginx's 640s fastcgi_read_timeout.
$result = $this->request('POST', '/containers/create', [], false, $spec, 600);
return (string) ($result['Id'] ?? '');
}
@@ -185,41 +193,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
@@ -234,6 +207,22 @@ final class PodmanClient
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
/**
* POST /pods/create — takes a body of {name, portmappings, ...}.
* Verified live against a real podman system service: {"name":"...",
* "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]}
* creates a pod with a shared infra container whose port bindings apply
* to every member container — see ajax/pods.php's build_pod_spec().
*
* @param array<string,mixed> $spec
* @return string the new pod's ID
*/
public function createPod(array $spec): string
{
$result = $this->request('POST', '/pods/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
@@ -244,6 +233,11 @@ final class PodmanClient
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
@@ -279,7 +273,14 @@ final class PodmanClient
*/
public function pullImage(string $reference): array
{
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
// A real image (e.g. a Plex/media-server image, easily several
// hundred MB) routinely takes far longer than this client's normal
// 15s operation timeout to download — found live: a pull aborted
// mid-stream with "Operation timed out after 15001 milliseconds"
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
// plugin requests, so 600s here stays safely under that.
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
$last = null;
foreach (explode("\n", trim($raw)) as $line) {
@@ -313,6 +314,28 @@ final class PodmanClient
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* POST /images/prune?all=true — removes every image with zero containers
* (running or stopped) referencing it, matching this app's own "Used By"
* column — not just dangling/untagged images. Verified live: a tagged
* but unused image IS removed with all=true (found the hard way: it
* also removed every image on a host with no containers at all, which
* is correct behavior, just aggressive — see ajax/images.php's prune
* action for the confirmation-copy this justifies).
*
* @return array<int,array{Id:string,Size:int}> one entry per removed image
*/
public function pruneImages(): array
{
return $this->request('POST', '/images/prune', ['all' => 'true']);
}
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
public function tagImage(string $id, string $repo, string $tag): void
{
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
@@ -353,12 +376,25 @@ final class PodmanClient
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
/**
* $parentInterface (only meaningful for driver="macvlan") attaches the
* network directly to an existing host bridge/VLAN interface (e.g.
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
* libpod's "network_interface" field — verified live: containers on
* such a network get a real address on that LAN/VLAN's own subnet,
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
* network type. See ajax/networks.php's macvlan_parent_interfaces()
* for where the interface list itself comes from.
*/
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
if ($parentInterface !== null && $parentInterface !== '') {
$body['network_interface'] = $parentInterface;
}
return $this->request('POST', '/networks/create', [], false, $body);
}
@@ -379,9 +415,9 @@ final class PodmanClient
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null, ?int $timeoutSeconds = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
@@ -400,7 +436,7 @@ final class PodmanClient
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null, ?int $timeoutSeconds = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
@@ -413,7 +449,7 @@ final class PodmanClient
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
@@ -0,0 +1,172 @@
<?php
/**
* RegistryClient.php
*
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
* (verified live: no libpod endpoint exists for this; every tool that
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
* re-implements the same registry-side check). This talks directly to the
* target image's own registry using the standard Docker Registry HTTP API
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
* without downloading any image layers, which is compared against the
* digest of the image already pulled locally (PodmanClient::listImages()'s
* own "Digest" field) — no local image ever needs pulling just to check.
*
* The auth flow is the generic Bearer-challenge dance every compliant
* registry follows (RFC-ish, not just a Docker Hub thing): an
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
* a token realm/service/scope, a token is fetched from that realm, and the
* manifest request is retried with it. Verified live against three
* different registries with three different auth setups — Docker Hub,
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
* path for all three, not registry-specific special-casing.
*/
declare(strict_types=1);
final class RegistryClient
{
/**
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
*/
public static function checkForUpdate(string $reference, string $localDigest): array
{
[$registry, $repo, $tag] = self::parseReference($reference);
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
'application/vnd.oci.image.manifest.v1+json, ' .
'application/vnd.oci.image.index.v1+json';
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
if ($status === 401) {
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
if ($challenge === null) {
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
}
$token = self::fetchToken($challenge);
if ($token === null) {
return ['error' => 'Could not authenticate with the registry.'];
}
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
}
if ($status !== 200) {
return ['error' => "Registry returned HTTP {$status}."];
}
$remoteDigest = $headers['docker-content-digest'] ?? null;
if ($remoteDigest === null) {
return ['error' => 'Registry response did not include a digest.'];
}
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
}
/**
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
* tag] — same reference-parsing convention every registry client
* (including podman/Docker themselves) uses: the first path segment is
* a registry host only if it contains a "." or ":" or is "localhost";
* otherwise the whole reference is a Docker Hub repo, implicitly under
* "library/" if it has no namespace of its own. docker.io's actual API
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
* specific quirk, not something inferred from the general rule above.
*
* @return array{0:string,1:string,2:string}
*/
private static function parseReference(string $reference): array
{
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
$tag = 'latest';
$lastSlash = strrpos($reference, '/');
$lastColon = strrpos($reference, ':');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$tag = substr($reference, $lastColon + 1);
$reference = substr($reference, 0, $lastColon);
}
$parts = explode('/', $reference);
$first = $parts[0];
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
if ($looksLikeHost) {
$registry = $first;
$repo = implode('/', array_slice($parts, 1));
} else {
$registry = 'docker.io';
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
}
if ($registry === 'docker.io') {
$registry = 'registry-1.docker.io';
}
return [$registry, $repo, $tag];
}
/** @return array{realm:string,service:string,scope:string}|null */
private static function parseAuthChallenge(string $header): ?array
{
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
return null;
}
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
}
/** @param array{realm:string,service:string,scope:string} $challenge */
private static function fetchToken(array $challenge): ?string
{
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
$url = $challenge['realm'] . '?' . http_build_query($params);
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
if ($status !== 200 || $body === null) {
return null;
}
$decoded = json_decode($body, true);
// The spec allows either key; registries are inconsistent about
// which one they actually send.
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
}
/**
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
*/
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
{
$ch = curl_init($url);
$headers = ['Accept: ' . $accept];
if ($token !== null) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($raw === false) {
return [0, [], null];
}
$parsedHeaders = [];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$parsedHeaders[strtolower(trim($k))] = trim($v);
}
}
$body = $withBody ? substr($raw, $headerSize) : null;
return [$status, $parsedHeaders, $body];
}
}
@@ -20,6 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/RegistryClient.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
+61 -3
View File
@@ -229,6 +229,53 @@ window.Podman = (function () {
});
}
/**
* Small modal with a scrolling monospace log pane — for actions that run
* several steps in sequence (checking/updating containers) where a plain
* confirm()/alert() at the very end leaves the user with no feedback
* that anything is happening while it runs. Returns {log, done} rather
* than closing itself, since the caller knows when the whole sequence
* (not just one call) has actually finished.
*
* @param {string} title
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
*/
function openLogModal(title) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
'</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const pane = backdrop.querySelector('#podman-log-modal-pane');
const closeBtn = backdrop.querySelector('[data-role="close"]');
function close() { backdrop.remove(); }
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
});
function log(line) {
const row = document.createElement('div');
row.textContent = line;
pane.appendChild(row);
pane.scrollTop = pane.scrollHeight;
}
function done(closeLabel) {
closeBtn.disabled = false;
closeBtn.textContent = closeLabel || 'Close';
}
return { log: log, done: done };
}
/**
* Small anchored dropdown menu — used for secondary per-row actions
* (pause/kill/rename/...) that would otherwise clutter a table row with
@@ -252,15 +299,25 @@ window.Podman = (function () {
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join('');
// Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
menu.style.left = (rect.right + window.scrollX - 180) + 'px';
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = (rect.right - 180) + 'px';
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
// menu.children includes the separator <div>s too, so indexing into it
// directly (by a counter that only advances for real items) drifts by
// one after every separator — e.g. "Remove" (after a separator) ended
// up wired to the separator <div> instead of its own <button>, so
// clicking it did nothing. querySelectorAll('button') only ever
// returns the actual buttons, in the same order as the non-separator
// items, so indexing into that stays aligned regardless of separators.
const buttons = menu.querySelectorAll('button');
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = menu.children[buttonIndex];
const btn = buttons[buttonIndex];
buttonIndex++;
if (item.disabled) return;
btn.addEventListener('click', function (e) {
@@ -358,6 +415,7 @@ window.Podman = (function () {
loadingRow: loadingRow,
errorRow: errorRow,
openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
+87 -9
View File
@@ -1,10 +1,10 @@
/**
* javascript/compose.js
*
* Compose panel: project list + read-only YAML view + up/down/pull,
* backed by ajax/compose.php. See that file's header comment — this is
* the one panel whose backend shells out to the `podman compose` CLI,
* because no REST equivalent for Compose exists in libpod.
* Compose panel: project list + an editable YAML view + save/up/down/pull/
* delete, backed by ajax/compose.php. See that file's header comment —
* this is the one panel whose backend shells out to the `podman compose`
* CLI, because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
@@ -12,6 +12,13 @@
let projects = [];
let selected = null;
const STARTER_YAML =
'services:\n' +
' app:\n' +
' image: docker.io/library/nginx:alpine\n' +
' ports:\n' +
' - "8080:80"\n';
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
@@ -23,21 +30,32 @@
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
}
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
// — disabled (rather than left clickable and erroring) whenever nothing
// is selected, e.g. right after deleting the last project.
function setToolbarEnabled(enabled) {
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
P.el(id).disabled = !enabled;
});
P.el('compose-yaml').disabled = !enabled;
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').textContent = 'Loading…';
P.el('compose-yaml').value = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').textContent = data.yaml;
P.el('compose-yaml').value = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message;
P.el('compose-yaml').value = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
setToolbarEnabled(true);
renderSidebar();
loadYaml(name);
}
@@ -45,9 +63,19 @@
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) loadYaml(selected);
if (selected) {
setToolbarEnabled(true);
loadYaml(selected);
} else {
setToolbarEnabled(false);
P.el('compose-title').textContent = '—';
P.el('compose-yaml').value = '';
}
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
@@ -67,15 +95,65 @@
});
}
function saveYaml() {
if (!selected) return;
const btn = P.el('compose-action-save');
btn.disabled = true;
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
return loadProjects();
}).catch(function (err) {
alert('Save failed: ' + err.message);
}).finally(function () {
btn.disabled = false;
});
}
function deleteProject() {
if (!selected) return;
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
const btn = P.el('compose-action-delete');
btn.disabled = true;
P.post('compose', 'remove', { project: selected }).then(function () {
selected = null;
return loadProjects();
}).catch(function (err) {
alert('Delete failed: ' + err.message);
btn.disabled = false;
});
}
function openNewProjectModal() {
P.openFormModal({
title: 'New Compose Project',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
],
onSubmit: function (values) {
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
}
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
selected = values.name;
return loadProjects();
});
},
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
P.el('compose-action-save').addEventListener('click', saveYaml);
P.el('compose-action-delete').addEventListener('click', deleteProject);
setToolbarEnabled(false);
return loadProjects();
}
+397 -29
View File
@@ -10,42 +10,60 @@
let allContainers = [];
let filter = 'all';
let searchTerm = '';
// Keyed by image reference (not container id) — several containers
// commonly share the same image, and ajax/containers.php's
// check_updates action itself already dedupes registry requests the
// same way. Persists across load()/renderTable() refreshes so the
// badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it.
let imageUpdateStatus = {};
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
const cpuMem = c.state === 'running' && c.cpuPercent != null
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
: '<span class="podman-row-sub">&mdash;</span>';
const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: '';
return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button></td>' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row">' + actionButtons(c) + '</div></td>' +
'</tr>';
}
function actionButtons(c) {
const updateBtn = hasUpdate(c)
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">&#8593;</button>'
: '';
if (c.state === 'running') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
if (c.state === 'paused') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
@@ -57,6 +75,7 @@
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
}
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
items.push('separator');
items.push({
label: 'Remove',
@@ -78,6 +97,218 @@
});
}
// --- Edit (recreate) --------------------------------------------------------
//
// Podman/Docker have no "modify a running container" API for most of
// this (image, ports, volumes, env, ...) — the only real way to "edit"
// is to stop the old one, remove it (this does NOT touch named volumes,
// only the container itself), and create a new one under the same name
// with the changed settings. Same pattern Unraid's own Docker Manager
// and every other Docker/Podman WebUI uses. Reuses the existing
// "inspect" action (already fetched for the detail modal) rather than
// adding a new endpoint — envToPrefill()/etc. below just reshape that
// same raw libpod inspect JSON into openCreateContainerModal's prefill
// shape.
// Auto-injected by the container runtime itself, not something a user
// set through this form — dropped so the edit form isn't full of noise
// that didn't come from the original Create Container submission.
const AUTO_ENV_KEYS = ['PATH', 'HOSTNAME', 'HOME', 'container', 'TERM'];
function inspectToPrefill(c, d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
const ports = [];
Object.keys((hostCfg.PortBindings) || {}).forEach(function (key) {
const [containerPort, protocol] = key.split('/');
((hostCfg.PortBindings[key]) || []).forEach(function (binding) {
ports.push({ hostPort: binding.HostPort, containerPort: containerPort, protocol: protocol || 'tcp' });
});
});
const volumes = (d.Mounts || []).reduce(function (list, m) {
if (m.Type === 'bind') {
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
} else if (m.Type === 'volume') {
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
}
return list;
}, []);
const env = (cfg.Env || []).reduce(function (list, line) {
const idx = line.indexOf('=');
const key = idx === -1 ? line : line.slice(0, idx);
if (AUTO_ENV_KEYS.indexOf(key) === -1) {
list.push({ key: key, value: idx === -1 ? '' : line.slice(idx + 1) });
}
return list;
}, []);
// Only the /dev/dri paths our own GPU passthrough checkbox could have
// added — same host-path pattern ajax/containers.php's build_container_
// spec() validates against, so a container with some unrelated device
// mapping (added outside this UI) doesn't get misread as a GPU pick.
const gpuDevices = (hostCfg.Devices || [])
.map(function (dev) { return dev.PathOnHost; })
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
// in openCreateContainerModal) — the container's actual address on
// that network, so editing one doesn't blank out an IP it was
// deliberately given.
const netName = hostCfg.NetworkMode;
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
return {
name: (d.Name || c.name || '').replace(/^\//, ''),
image: cfg.Image || c.image,
networkMode: hostCfg.NetworkMode || 'bridge',
staticIp: staticIp,
pod: c.podName || '',
privileged: !!hostCfg.Privileged,
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
ports: ports,
volumes: volumes,
env: env,
gpuDevices: gpuDevices,
};
}
function openEditContainerModal(c) {
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) {
alert('Could not load container config: ' + err.message);
});
}
// --- Update (pull + recreate, unchanged settings) ---------------------------
//
// "Update" is the same stop/remove/recreate as Edit — see that comment
// above — except nothing in the config changes and an image pull happens
// first. Reuses inspectToPrefill() so both features read a container's
// current settings the exact same way.
//
// Both this and checkForUpdates()/updateAll() below take a `log`
// callback and write one line per step to it — a plain confirm()/alert()
// at the very end left no visible sign anything was happening while a
// check or a several-container update ran (found live: clicking "Check
// for Updates" against two already-current images looked completely
// inert). See app.js's openLogModal() for the small scrolling log window
// these lines end up in.
function updateContainer(c, log) {
return P.get('containers', 'inspect', { id: c.id }).then(function (d) {
const prefill = inspectToPrefill(c, d);
log('Pulling ' + prefill.image + '…');
return P.post('images', 'pull', { reference: prefill.image })
.then(function () {
log('Stopping ' + c.name + '…');
return P.post('containers', 'stop', { id: c.id }).catch(function () { /* already stopped is fine */ });
})
.then(function () {
log('Removing old container…');
return P.post('containers', 'remove', { id: c.id, force: true });
})
.then(function () {
log('Creating new container…');
return P.post('containers', 'create', {
image: prefill.image,
name: prefill.name,
networkMode: prefill.networkMode,
staticIp: prefill.staticIp,
pod: prefill.pod,
ports: prefill.ports,
volumes: prefill.volumes,
env: prefill.env,
restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged,
startAfterCreate: true,
});
}).then(function () {
// The image just pulled is now current — clear the stale flag
// for it specifically rather than wiping every row's status,
// since other images may still be genuinely outdated.
delete imageUpdateStatus[prefill.image];
log('Done: ' + c.name + ' is up to date.');
});
});
}
function checkForUpdates() {
const modal = P.openLogModal('Check for Updates');
modal.log('Checking every image currently in use…');
return P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
let updatable = 0;
Object.keys(results).forEach(function (ref) {
const r = results[ref];
if (r.error) {
modal.log('! ' + ref + ' — ' + r.error);
} else if (r.updateAvailable) {
updatable++;
modal.log('↑ ' + ref + ' — update available');
} else {
modal.log('✓ ' + ref + ' — up to date');
}
});
modal.log('');
modal.log(updatable ? updatable + ' image(s) have an update available.' : 'Everything is up to date.');
modal.done();
renderTable();
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
});
}
function updateAll() {
const btn = P.el('containers-update-all-btn');
btn.disabled = true;
const modal = P.openLogModal('Update All');
modal.log('Checking every image currently in use…');
P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
renderTable();
const targets = allContainers.filter(hasUpdate);
if (!targets.length) {
modal.log('Everything is already up to date.');
modal.done();
btn.disabled = false;
return;
}
modal.log(targets.length + ' container(s) to update: ' + targets.map(function (c) { return c.name; }).join(', '));
modal.log('');
// Sequential, not parallel — several containers stopping/recreating
// at once is harder to reason about if one of them fails partway,
// and avoids hammering the same registry with simultaneous pulls.
const failures = [];
targets.reduce(function (chain, c) {
return chain.then(function () {
return updateContainer(c, modal.log).catch(function (err) {
modal.log('Failed: ' + c.name + ' — ' + err.message);
failures.push(c.name);
});
});
}, Promise.resolve()).then(function () {
modal.log('');
modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.');
modal.done();
btn.disabled = false;
return load();
});
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
btn.disabled = false;
});
}
// --- Detail view -----------------------------------------------------------
//
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
@@ -305,17 +536,24 @@
/**
* @param {object|null} prefill Optional template data (same shape
* ajax/templates.php's "get" action returns) to seed the form with —
* used by templates.js's "Use template" action. null/omitted opens a
* blank form, same as the toolbar's "+ New Container" button.
* ajax/templates.php's "get" action returns, plus "name"/"pod" which
* only inspectToPrefill() sets) to seed the form with — used by
* templates.js's "Use template" action and openEditContainerModal()
* below. null/omitted opens a blank form, same as the toolbar's
* "+ New Container" button.
* @param {{id:string}|null} editing When set, this is an edit of an
* existing container rather than a fresh create: submitting stops and
* removes container `editing.id` first, then creates a new one under
* whatever name/settings are in the form (see the Podman/Docker have
* no in-place "modify" API comment on openEditContainerModal above).
*/
function openCreateContainerModal(prefill) {
function openCreateContainerModal(prefill, editing) {
prefill = prefill || {};
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
@@ -325,9 +563,16 @@
'<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-modal-field" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
'<select id="cc-pod"><option value="">None</option></select>' +
'<div class="hint">Joins the pod\'s shared network namespace instead of the setting above.</div></div>' +
'<div class="podman-modal-field" id="cc-ports-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cc-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
'<div class="podman-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
@@ -337,6 +582,8 @@
'<div class="podman-modal-field"><label>Restart policy</label>' +
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
@@ -352,14 +599,35 @@
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save &amp; Recreate' : 'Create') + '</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
// Macvlan containers get their own address directly on the LAN (see
// the ajax/networks.php macvlan work) — port mappings are meaningless
// for them (there's no host-side NAT to map through) and a static IP
// becomes a relevant option instead of a Bridge/Host/None-only
// concept. Toggled on network-select change and once up front below,
// driven by each <option>'s data-driver (set when the real network
// list loads — the three built-ins are never macvlan).
function updateNetworkFieldsVisibility() {
const select = backdrop.querySelector('#cc-network');
const selectedOption = select.options[select.selectedIndex];
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
}
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
const portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes');
@@ -383,11 +651,59 @@
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option');
opt.value = n.name;
opt.textContent = n.name;
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
opt.dataset.driver = n.driver;
select.appendChild(opt);
});
// Re-applied here (not just at load time above) because a custom
// network's <option> doesn't exist yet until this list comes back —
// setting .value to it any earlier would silently no-op and leave
// the select on its default "bridge" option instead. Matters for
// openEditContainerModal(): a container already on a custom network
// needs that option to exist before it can be selected.
if (prefill.networkMode) select.value = prefill.networkMode;
updateNetworkFieldsVisibility();
}).catch(function () { /* built-in modes still usable */ });
P.get('pods', 'list').then(function (pods) {
const select = backdrop.querySelector('#cc-pod');
pods.forEach(function (p) {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name;
select.appendChild(opt);
});
if (prefill.pod) select.value = prefill.pod;
}).catch(function () { /* pod selection stays optional */ });
// Only shown when the host actually has a passthrough-capable GPU
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
// is deliberately excluded there since it needs a different runtime) —
// best-effort, same as networks/pods above.
P.get('containers', 'list_gpus').then(function (gpus) {
if (!gpus.length) return;
const field = backdrop.querySelector('#cc-gpu-field');
const select = backdrop.querySelector('#cc-gpu-select');
field.style.display = '';
gpus.forEach(function (gpu, i) {
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
select.appendChild(opt);
});
select.dataset.gpus = JSON.stringify(gpus);
// Pre-select whichever detected GPU the container being edited is
// already using (matched by device path, not index — gpu_list()'s
// order isn't guaranteed stable across requests).
if (prefill.gpuDevices && prefill.gpuDevices.length) {
const matchIndex = gpus.findIndex(function (gpu) {
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
});
if (matchIndex !== -1) select.value = String(matchIndex);
}
}).catch(function () { /* GPU passthrough stays unavailable */ });
backdrop.querySelector('#cc-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
@@ -407,6 +723,13 @@
}
function submit() {
if (editing && !confirm(
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
)) {
return;
}
const image = backdrop.querySelector('#cc-image').value.trim();
if (!image) {
showError('"Image" is required.');
@@ -420,7 +743,17 @@
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const networkSelect = backdrop.querySelector('#cc-network');
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
// Port mappings map a host port to a container port through NAT —
// meaningless on a macvlan network, where the container already has
// its own real address on the LAN (see updateNetworkFieldsVisibility()
// above, which also hides the UI for this) — so none are sent even
// if some were left over from switching the network dropdown after
// adding a few.
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; });
@@ -433,19 +766,39 @@
const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked;
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
// Editing an existing container: no in-place "modify" API exists
// (see the comment on openEditContainerModal above), so this stops
// and removes the old one first — best-effort stop (it may already
// be stopped) followed by a forced remove — before creating the
// replacement under whatever name is in the form now.
const removeOld = editing
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
: Promise.resolve();
removeOld.then(function () {
return P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
staticIp: staticIp,
pod: backdrop.querySelector('#cc-pod').value,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
});
}).then(function () {
// Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created.
@@ -469,7 +822,7 @@
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
});
}
@@ -523,11 +876,23 @@
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
const id = row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else if (btn.dataset.action === 'update') {
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
btn.disabled = true;
const modal = P.openLogModal('Updating ' + c.name);
updateContainer(c, modal.log).then(function () {
modal.done();
return load();
}).catch(function (err) {
modal.log('Failed: ' + err.message);
modal.done();
btn.disabled = false;
});
} else {
openDetailModal(c);
}
@@ -536,6 +901,9 @@
handleAction(id, btn.dataset.action, btn);
});
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll);
return load();
}
+61 -9
View File
@@ -18,8 +18,10 @@
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' +
'<button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -55,16 +57,66 @@
});
});
P.el('images-prune-btn').addEventListener('click', function () {
// Computed client-side from the list already on screen — no extra
// round trip needed, and it lets the confirm() be specific instead
// of a generic warning. "Unused" here matches libpod's own
// definition (zero containers, running or stopped, referencing the
// image) — the same "Used By" count already shown in the table, not
// just dangling/untagged images. Found live that this can be far
// more aggressive than expected: with no containers at all, it
// removes every image on the host.
const unused = images.filter(function (img) { return img.usedBy === 0; });
if (!unused.length) {
alert('No unused images to remove — every image is referenced by at least one container.');
return;
}
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
if (!confirm(
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
'This removes any tagged image with zero containers using it, not just dangling ones.'
)) return;
const btn = this;
btn.disabled = true;
P.post('images', 'prune').then(function (result) {
btn.disabled = false;
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
return load();
}).catch(function (err) {
btn.disabled = false;
alert('Prune failed: ' + err.message);
});
});
P.el('images-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
if (!confirm('Remove this image?')) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
if (btn.dataset.action === 'tag') {
P.openFormModal({
title: 'Add Tag',
submitLabel: 'Add tag',
fields: [
{ name: 'repo', label: 'Repository', required: true, placeholder: 'my-registry.local/my-image' },
{ name: 'tag', label: 'Tag', placeholder: 'latest' },
],
onSubmit: function (values) {
return P.post('images', 'tag', { id: id, repo: values.repo, tag: values.tag || 'latest' }).then(load);
},
});
return;
}
if (btn.dataset.action === 'remove') {
if (!confirm('Remove this image?')) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
}
});
return load();
+114 -15
View File
@@ -20,8 +20,8 @@
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,21 +43,120 @@
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', function () {
P.openFormModal({
title: 'New Network',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
],
onSubmit: function (values) {
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
},
});
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat always-visible text fields) — the parent-interface
// dropdown and gateway field only make sense for "macvlan" and need to
// show/hide based on the driver choice.
function openCreateNetworkModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Network</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Network name</label>' +
'<input type="text" id="cn-name" placeholder="my-network"></div>' +
'<div class="podman-modal-field"><label>Type</label>' +
'<select id="cn-driver">' +
'<option value="bridge">Bridge (isolated, NAT — default)</option>' +
'<option value="macvlan">Macvlan (containers get a real IP on your LAN)</option>' +
'</select></div>' +
'<div class="podman-modal-field" id="cn-parent-field" style="display:none;">' +
'<label>Parent interface</label><select id="cn-parent"></select>' +
'<div class="hint">Same interface Docker Manager\'s "Custom: br0"-style networks use.</div></div>' +
'<div class="podman-modal-field"><label id="cn-subnet-label">Subnet (optional)</label>' +
'<input type="text" class="mono" id="cn-subnet" placeholder="10.89.2.0/24"></div>' +
'<div class="podman-modal-field" id="cn-gateway-field" style="display:none;">' +
'<label>Gateway</label><input type="text" class="mono" id="cn-gateway" placeholder="10.1.1.1"></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let parentInterfaces = [];
P.get('networks', 'list_parent_interfaces').then(function (interfaces) {
parentInterfaces = interfaces;
const select = backdrop.querySelector('#cn-parent');
select.innerHTML = interfaces.map(function (i) {
return '<option value="' + P.escapeHtml(i.interface) + '">' + P.escapeHtml(i.label) + '</option>';
}).join('');
}).catch(function () { /* macvlan option just won't have anything to pick if this fails */ });
backdrop.querySelector('#cn-driver').addEventListener('change', function (e) {
const isMacvlan = e.target.value === 'macvlan';
backdrop.querySelector('#cn-parent-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-gateway-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-subnet-label').textContent = isMacvlan ? 'Subnet' : 'Subnet (optional)';
});
backdrop.querySelector('#cn-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cn-name').value.trim();
if (!name) {
showError('"Network name" is required.');
return;
}
const driver = backdrop.querySelector('#cn-driver').value;
const subnet = backdrop.querySelector('#cn-subnet').value.trim();
const gateway = backdrop.querySelector('#cn-gateway').value.trim();
const parentInterface = backdrop.querySelector('#cn-parent').value;
if (driver === 'macvlan') {
if (!subnet) {
showError('"Subnet" is required for a macvlan network.');
return;
}
if (!parentInterfaces.length) {
showError('No host bridge/VLAN interface available to attach to.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('networks', 'create', {
name: name,
driver: driver,
subnet: subnet || undefined,
gateway: gateway || undefined,
parentInterface: driver === 'macvlan' ? parentInterface : undefined,
}).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', openCreateNetworkModal);
P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
+157 -6
View File
@@ -8,6 +8,107 @@
(function () {
'use strict';
const P = window.Podman;
let allPods = [];
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl) {
const div = document.createElement('div');
div.innerHTML = portRowHtml();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
groupEl.appendChild(row);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
function openCreatePodModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Pod</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Name</label>' +
'<input type="text" id="cp-name" placeholder="my-pod">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cp-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint">Shared by every container later added to this pod.</div></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const portsGroup = backdrop.querySelector('#cp-ports');
addRow(portsGroup);
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); });
backdrop.querySelector('#cp-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cp-name').value.trim();
if (!name) {
showError('"Name" is required.');
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('pods', 'create', { name: name, ports: ports }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function memberRow(m) {
return '' +
@@ -24,27 +125,77 @@
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' +
'<div class="podman-pod-card">' +
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
'<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>' +
'</div>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' +
'</div>';
}
function render() {
const grid = P.el('pods-grid');
grid.innerHTML = allPods.length
? allPods.map(podCard).join('')
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
}
function load() {
const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) {
container.innerHTML = pods.length
? pods.map(podCard).join('')
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
if (!P.el('pods-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
'</div>' +
'<div id="pods-grid"></div>' +
'</div>';
P.el('pods-create-btn').addEventListener('click', openCreatePodModal);
P.el('pods-grid').addEventListener('click', handleCardClick);
}
return P.get('pods', 'list').then(function (data) {
allPods = data;
render();
}).catch(function (err) {
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleAction(name, action, extra) {
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
});
}
function handleCardClick(e) {
const btn = e.target.closest('button[data-action="menu"]');
if (!btn) return;
const pod = allPods.find(function (p) { return p.name === btn.closest('.podman-pod-card').dataset.name; });
if (!pod) return;
const items = [];
if (pod.status === 'running') {
items.push({ label: 'Stop', onClick: function () { handleAction(pod.name, 'stop', { timeout: 10 }); } });
items.push({ label: 'Restart', onClick: function () { handleAction(pod.name, 'restart', { timeout: 10 }); } });
} else {
items.push({ label: 'Start', onClick: function () { handleAction(pod.name, 'start'); } });
}
items.push('separator');
items.push({
label: 'Remove',
danger: true,
onClick: function () {
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
handleAction(pod.name, 'remove', { force: true });
},
});
P.openContextMenu(btn, items);
}
P.registerPanel('pods', { init: load, refresh: load });
})();
+146 -5
View File
@@ -17,11 +17,11 @@
return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</td>' +
'<td class="podman-actions">' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">&#8593;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">&#8595;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>';
'</div></td></tr>';
}).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
}
@@ -43,9 +43,12 @@
const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
.join(' · ');
const chipsHtml = order.map(function (k) {
const name = k.toLowerCase().replace(/_/g, '-');
const version = versions[k + '_INSTALLED_VERSION'];
return '<span class="podman-version-chip">' + P.escapeHtml(name) + ' <b>' + P.escapeHtml(version || '?') + '</b></span>';
}).join('');
P.el('settings-package-versions').innerHTML = chipsHtml || '<span class="podman-empty-note">No version manifest found.</span>';
}
function load() {
@@ -54,6 +57,131 @@
});
}
// --- Podman service status/start/stop/restart ------------------------------
function renderServiceChip(data) {
const chip = P.el('settings-service-chip');
chip.className = 'podman-chip ' + (data.running ? 'podman-chip-good' : 'podman-chip-bad');
chip.innerHTML = '<span class="d"></span>' + (data.running ? 'Running' : 'Not running');
}
function serviceButtons() {
return ['settings-service-status-btn', 'settings-service-start-btn', 'settings-service-stop-btn', 'settings-service-restart-btn']
.map(function (id) { return P.el(id); });
}
// "Refresh Status" is quick and read-only — shown inline, no modal needed.
function refreshServiceStatus() {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
return P.get('settings', 'service_status').then(function (data) {
renderServiceChip(data);
const log = P.el('settings-service-log');
log.style.display = '';
log.textContent = data.output || '';
log.scrollTop = log.scrollHeight;
}).catch(function (err) {
P.el('settings-service-chip').className = 'podman-chip podman-chip-bad';
P.el('settings-service-chip').innerHTML = '<span class="d"></span>Unknown';
P.el('settings-service-log').style.display = '';
P.el('settings-service-log').textContent = err.message;
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// Start/Stop/Restart can take a while (storage checks, container
// stop grace periods, ...) and are exactly the actions someone reaches
// for when something's actually wrong — a small log modal (same pattern
// as container update checking) shows what's happening instead of
// leaving the button just spinning with no feedback.
function runServiceAction(action, title) {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
const modal = P.openLogModal(title);
return P.post('settings', action, {}).then(function (data) {
renderServiceChip(data);
(data.output || '').split('\n').forEach(function (line) { modal.log(line); });
modal.done();
}).catch(function (err) {
modal.log('Error: ' + err.message);
modal.done('Close');
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// --- Format a disk for podman storage --------------------------------------
function openFormatDiskModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>Format a Disk for Podman Storage</h3></div>' +
'<div class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Disk</label><select id="fd-device"><option value="">Loading…</option></select>' +
'<div class="hint" id="fd-warning">Only disks with no existing partitions or filesystem are listed — anything already in use, part of the array/cache, or the boot flash is never shown here.</div></div>' +
'<div class="podman-modal-field">' +
'<label style="display:flex; align-items:flex-start; gap:8px; font-weight:400;">' +
'<input type="checkbox" id="fd-confirm" style="margin-top:3px;">' +
'<span>I understand this permanently erases all data on this disk, with no undo.</span>' +
'</label></div>' +
'</div>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" data-role="submit" disabled>Format Disk</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const select = backdrop.querySelector('#fd-device');
const warning = backdrop.querySelector('#fd-warning');
const confirmBox = backdrop.querySelector('#fd-confirm');
const submitBtn = backdrop.querySelector('[data-role="submit"]');
function updateSubmitEnabled() {
submitBtn.disabled = !(select.value && confirmBox.checked);
}
P.get('disks', 'list_candidates').then(function (list) {
select.innerHTML = list.length
? list.map(function (d) {
return '<option value="' + P.escapeHtml(d.device) + '">' + P.escapeHtml(d.device) +
' — ' + P.escapeHtml(d.sizeFormatted) + (d.model ? ' (' + P.escapeHtml(d.model) + ')' : '') + '</option>';
}).join('')
: '<option value="">No eligible blank disks found</option>';
updateSubmitEnabled();
}).catch(function (err) {
select.innerHTML = '<option value="">Error loading disks</option>';
warning.textContent = err.message;
});
select.addEventListener('change', updateSubmitEnabled);
confirmBox.addEventListener('change', updateSubmitEnabled);
function close() { backdrop.remove(); }
submitBtn.addEventListener('click', function () {
if (!select.value || !confirmBox.checked) return;
if (!confirm('Format ' + select.value + '? This cannot be undone.')) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Formatting…';
P.post('disks', 'format', { device: select.value }).then(function (data) {
close();
P.el('settings-storage-path').value = data.mountPath;
alert('Formatted and mounted at ' + data.mountPath + '. Storage path has been filled in below — click "Save Settings" to use it, then Restart Podman.');
}).catch(function (err) {
submitBtn.disabled = false;
submitBtn.textContent = 'Format Disk';
alert('Format failed: ' + err.message);
});
});
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
}
function save() {
const body = {
storagePath: P.el('settings-storage-path').value.trim(),
@@ -88,6 +216,19 @@
saveAutostart();
});
P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus);
P.el('settings-service-start-btn').addEventListener('click', function () { runServiceAction('service_start', 'Starting Podman'); });
P.el('settings-service-stop-btn').addEventListener('click', function () {
if (!confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).')) return;
runServiceAction('service_stop', 'Stopping Podman');
});
P.el('settings-service-restart-btn').addEventListener('click', function () {
if (!confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.')) return;
runServiceAction('service_restart', 'Restarting Podman');
});
P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal);
refreshServiceStatus();
return load();
}
@@ -29,7 +29,6 @@
'<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
(t.category ? '<span class="podman-badge">' + P.escapeHtml(t.category) + '</span>' : '') +
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' +
'<div class="podman-template-actions">' +
+72 -64
View File
@@ -1,89 +1,97 @@
/**
* javascript/terminal.js
*
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
* file's header comment for the full, honest explanation of why this is
* "type a command, see its output" rather than a true interactive PTY —
* the short version is that libpod's interactive exec needs a persistent
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
* interactivity on top of that would break the moment a user ran anything
* that expects a real terminal (vim, an interactive prompt, etc).
* Terminal panel: opens a real, fully interactive terminal inline (as an
* <iframe>, not a popup window) — the same mechanism Unraid's own webGui
* uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* header comment for the full explanation). This module's own job is just:
*
* `cd` is handled client-side: this module tracks a per-session `cwd` and
* passes it as the exec's working directory on every call, so at least
* directory navigation feels persistent even though nothing else is.
* 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* `podman exec -it <container> <shell>`, bound to a unix socket.
* 2. Point an <iframe> at /logterminal/<sockName>/ — nginx's own
* "logterminal" location block (already installed system-wide by
* Unraid, not something this plugin configures) proxies that,
* WebSocket upgrade included, straight to ttyd's socket.
* 3. Track which container's session (if any) is currently open, so
* "Disconnect" — or opening a different container/shell — can kill
* the right ttyd process server-side instead of just discarding the
* iframe and leaving it running.
*/
(function () {
'use strict';
const P = window.Podman;
let cwd = '/';
let containerId = null;
let openName = null;
function appendLine(html) {
const out = P.el('term-output');
const div = document.createElement('div');
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
function populateContainerSelect(list) {
const select = P.el('term-container-select');
if (!select) return;
const running = list.filter(function (c) { return c.state === 'running'; });
select.innerHTML = running
.map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
}
function promptHtml() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
function loadContainers() {
return P.get('containers', 'list').then(populateContainerSelect);
}
function runCommand(cmd) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
function resetFrame(message) {
P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
// `cd <dir>` is intercepted client-side (see file header) rather than
// sent as a real command, since a one-shot exec has no way to report
// "the working directory changed" back to us otherwise.
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
if (cdMatch) {
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
return Promise.resolve();
}
/** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
function closeCurrent() {
if (!openName) return Promise.resolve();
const name = openName;
return P.post('exec', 'close', { name: name }).catch(function () {});
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
function openLiveTerminal() {
const name = P.el('term-container-select').value;
if (!name) return;
const shell = P.el('term-shell-select').value;
const wrap = P.el('term-frame-wrap');
const btn = P.el('term-open-btn');
wrap.innerHTML = '<p class="podman-empty-note">Opening terminal…</p>';
btn.disabled = true;
closeCurrent().then(function () {
return P.post('exec', 'open', { name: name, shell: shell });
}).then(function (data) {
openName = name;
P.el('term-disconnect-btn').disabled = false;
// Matches the ~200ms delay Unraid's own openTerminal() uses between
// asking the backend to spawn ttyd and navigating to its socket —
// ttyd needs a brief moment to bind before nginx can proxy to it.
setTimeout(function () {
wrap.innerHTML = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
resetFrame('Could not open terminal: ' + P.escapeHtml(err.message));
}).finally(function () {
btn.disabled = false;
});
}
function populateContainerSelect(containers) {
const select = P.el('term-container-select');
select.innerHTML = containers
.filter(function (c) { return c.state === 'running'; })
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('');
containerId = select.value || null;
function disconnect() {
if (!openName) return;
const btn = P.el('term-disconnect-btn');
btn.disabled = true;
closeCurrent().finally(function () {
resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
});
}
function init() {
const input = P.el('term-input');
P.el('term-container-select').addEventListener('change', function (e) {
containerId = e.target.value;
cwd = '/';
P.el('term-output').innerHTML = '';
});
input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const cmd = input.value;
input.value = '';
if (!containerId) {
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
return;
}
if (cmd.trim() === '') return;
runCommand(cmd);
});
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
return loadContainers();
}
P.registerPanel('terminal', { init: init });
// refresh() only repopulates the container select — it must never touch
// #term-frame-wrap, or an already-open terminal would be torn down out
// from under the user just by switching tabs and back.
P.registerPanel('terminal', { init: init, refresh: loadContainers });
})();
+2 -2
View File
@@ -24,8 +24,8 @@
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
+203 -25
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
@@ -35,7 +35,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
}
@@ -44,7 +44,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
:root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
}
@@ -76,11 +76,20 @@
.podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); }
/*
* margin: 0 — Unraid's own webGui theme applies a 10px top/bottom margin
* to plain <button> elements site-wide. Without resetting it, every
* .podman-btn carries an invisible 10px gap above and below its own box,
* which silently breaks flex cross-axis alignment anywhere a button sits
* next to a non-button sibling (e.g. align-items: flex-end next to a
* <select> — verified live: the button's margin, not its content, was
* what left it floating 10px above the dropdown it should line up with).
*/
.podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui);
font-family: var(--font-ui); margin: 0;
}
.podman-btn:hover { border-color: var(--text-faint); }
/*
@@ -100,7 +109,7 @@
}
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/*
* Secondary action (Cancel, "+ Add row") — every button previously shared
@@ -123,11 +132,31 @@
background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !important;
}
/* A ghost button can still carry danger intent (e.g. a template's
"Delete") — needs its own !important since .podman-btn-ghost's color
would otherwise win by rule order. */
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; }
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; }
/*
* A ghost button can still carry danger intent (Disconnect, Delete,
* template "Delete") — needs its own !important since .podman-btn-ghost's
* color/background/border would otherwise win by rule order. Solid fill
* at rest (not just a tint, and not just on hover) so it reads with the
* same weight as .podman-btn-primary, just in red instead of accent —
* a merely tinted/outlined button still read as "just another secondary
* action" per live feedback.
*/
.podman-btn-ghost.podman-btn-danger {
color: var(--bad-contrast) !important; background: var(--bad) !important; border-color: var(--bad) !important;
}
.podman-btn-ghost.podman-btn-danger:hover {
background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; color: var(--bad-contrast) !important;
}
.podman-btn-ghost.podman-btn-danger[disabled] {
color: var(--text-faint) !important; background: transparent !important; border-color: var(--border) !important;
}
/* Icon-only danger buttons (row "remove" trash icons) carry an emoji
glyph, not text — .podman-btn-danger's `color` alone doesn't recolor an
emoji, so these get the same solid red fill instead, at rest not just
on hover, so "destructive" reads at a glance across a whole table. */
.podman-btn-icon.podman-btn-danger { border-color: var(--bad) !important; background: var(--bad) !important; }
.podman-btn-icon.podman-btn-danger:hover { background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; }
.podman-btn-icon.podman-btn-danger[disabled] { border-color: var(--border) !important; background: transparent !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -185,7 +214,12 @@
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name-btn {
appearance: none; border: none; background: none; padding: 0; cursor: pointer;
/* !important for the same reason as .podman-btn-ghost/-primary — Unraid's
own site-wide button theme otherwise still shows its default border
at rest (only losing to plain rules on hover), so a name link one
click away from every table row still looked like a bordered button
forever, not a plain label. */
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
}
.podman-row-name-btn:hover { color: var(--accent-strong); }
@@ -195,7 +229,38 @@
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
}
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
/*
* The actions <td> itself stays a plain table-cell (default display) so
* every row's column width is computed the same way by the table's layout
* algorithm — putting "display: flex" directly on the <td> used to take it
* out of that algorithm, so browsers could size/position it slightly
* differently row to row (found live: the trash-can button in Images drifted
* a few pixels between rows instead of lining up in one column). The actual
* flex/gap/alignment lives on this inner wrapper instead.
*/
.podman-actions { text-align: right; white-space: nowrap; }
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
/*
* Segmented toggle (Containers' All/Running/Stopped filter, Logs' Follow/
* Paused) — previously just an inline-styled wrapper <div> around plain
* <button>s with no CSS of their own at all, so every option (not just the
* active one) showed Unraid's own default button border permanently,
* all three chips looking identically "selected". !important for the same
* site-wide-theme-override reason as .podman-btn-ghost/-primary.
*/
.podman-segmented { display: flex; gap: 2px; background: var(--surface-3); border: 1px solid var(--text-faint); padding: 3px; border-radius: 8px; }
.podman-segmented button {
appearance: none; border: none !important; background: transparent !important; color: var(--text-dim) !important;
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer;
font-family: var(--font-ui); transition: background .12s, color .12s;
}
.podman-segmented button:hover { color: var(--text) !important; }
/* Filled with the accent color (not just a slightly different neutral
shade) — the previous var(--surface) vs. var(--surface-2) contrast
between active/inactive was too close in the dark theme to notice at a
glance (found live). */
.podman-segmented button.active { background: var(--accent) !important; color: var(--accent-contrast) !important; box-shadow: var(--shadow); }
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
@@ -203,7 +268,20 @@
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
/*
* !important throughout: Unraid's own webGui/styles/default-base.css
* targets input[type="text"] with an attribute selector (higher
* specificity than our single .podman-search class, :where() around it
* notwithstanding) forcing border-width:0 / border-bottom-width:1px /
* background:transparent — an underline-only text field, not a boxed one.
* Found live: our border/background were being silently dropped even
* though this rule appears later in the stylesheet.
*/
.podman-search {
flex: 1; min-width: 180px; max-width: 320px; font-size: 13px; color: var(--text); font-family: var(--font-ui);
background: var(--surface-3) !important; border: 1px solid var(--text-faint) !important;
border-radius: 7px !important; padding: 7px 11px !important;
}
.podman-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
@@ -220,7 +298,7 @@
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; }
@@ -237,8 +315,18 @@
}
.podman-template-name { font-weight: 700; font-size: 13.5px; }
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
.podman-template-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
.podman-template-actions .podman-btn { flex: 1; justify-content: center; padding: 6px 10px; font-size: 12px; }
.podman-template-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
/*
* min-width: 0 overrides the flex-item default of min-width: auto, which
* otherwise refuses to shrink a button below its own label's intrinsic
* width — without it, "Delete" (the widest label, and uppercased by
* Unraid's own site-wide button theme) pushed past the card's right edge
* instead of actually sharing the row evenly with Use/Export (found live).
*/
.podman-template-actions .podman-btn {
flex: 1; min-width: 0; justify-content: center; padding: 6px 8px; font-size: 11.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item {
@@ -257,19 +345,35 @@
.podman-log-pane {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
/* Settings' service log (and the log modal) set .textContent directly
on this element rather than wrapping every line in a child .l div —
needs its own white-space here too, or real newlines in rc.podman's
output collapse into one run-together line (found live: the whole
multi-line `rc.podman status` output rendered as a single paragraph). */
white-space: pre-wrap;
}
.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; }
.podman-log-pane .ts { color: #6b7280; }
.podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; }
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
.podman-term .prompt { color: #4cc785; }
.podman-term .path { color: #6fb2f5; }
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
.podman-term-launcher {
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
}
.podman-term-launcher label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
/*
* !important here for the same reason as .podman-search's: Unraid's own
* webGui/styles/default-base.css has `select:where(:not(.unapi *))` rules
* for background/border/padding that would otherwise still show through
* around this class's box-model properties.
*/
.podman-term-select {
min-width: 180px !important; padding: 7px 12px !important; font-size: 13px !important;
font-family: var(--font-mono) !important; color: var(--text) !important;
background: var(--surface-3) !important; border: 1px solid var(--accent) !important; border-radius: 6px !important;
}
.podman-term-frame { display: block; width: 100%; height: 480px; border: 1px solid var(--border); border-radius: 8px; background: #0f1114; }
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
@@ -278,6 +382,19 @@
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
/*
* !important: this is now a real <textarea>, not a read-only <pre> —
* Unraid's own webGui/styles/default-base.css targets textarea the same
* way it targets input[type="text"] (see .podman-search's comment for
* the exact rule), forcing border-width:0/border-bottom-width:1px/
* background:transparent/border-radius:0, which would otherwise make the
* whole editor look like a barely-visible underline instead of an actual
* text area.
*/
.podman-yaml-editor {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
border: none !important; border-radius: 0 !important; outline: none;
}
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
.podman-field-row:last-child { border-bottom: none; }
@@ -288,6 +405,55 @@
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
}
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
/* Settings panel: a shared save action above all cards (Storage's and
Autostart & Lifecycle's fields save together in one call — see
settings.js's save() — so one button belongs above both, not buried in
either card, and definitely not in its own row with an empty label).
Framed as its own small bar (background/border), not bare text+button
floating at the top of the page. */
.podman-settings-actions {
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px;
padding: 12px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.podman-settings-actions .hint { margin: 0; max-width: 52ch; color: var(--text-dim); }
.podman-card-head .sub { margin-top: 3px; }
/* Number field + unit label (GB, seconds) — the input itself stays compact
instead of stretching to .podman-field-row's normal 340px text-field width. */
.podman-input-suffix { display: flex; align-items: center; gap: 8px; }
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
.podman-service-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.podman-service-row:last-child { margin-bottom: 0; }
/*
* Toggle switch — a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this
* hides the native checkbox (still the real, accessible input driving
* state) and draws a track+thumb off its :checked state instead. Sized in
* em off the track's own font-size so it scales if that ever changes.
*/
.podman-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; font-size: 22px; }
.podman-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.podman-switch-track {
display: inline-block; width: 1.9em; height: 1.05em; border-radius: 999px;
background: var(--surface-3); border: 1px solid var(--border); transition: background .15s, border-color .15s;
}
.podman-switch-thumb {
display: block; width: 0.75em; height: 0.75em; margin: 0.13em; border-radius: 50%;
background: var(--text-faint); transition: transform .15s, background .15s;
}
.podman-switch input:checked + .podman-switch-track { background: var(--accent); border-color: var(--accent); }
.podman-switch input:checked + .podman-switch-track .podman-switch-thumb { background: var(--accent-contrast); transform: translateX(0.85em); }
.podman-switch input:focus-visible + .podman-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.podman-version-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.podman-version-chip {
font-size: 11.5px; font-family: var(--font-mono); background: var(--surface-3); color: var(--text-dim);
border: 1px solid var(--border); padding: 5px 11px; border-radius: 100px;
}
.podman-version-chip b { color: var(--text); font-weight: 600; margin-left: 5px; }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
@@ -368,7 +534,18 @@
/* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu {
position: absolute; width: 180px; background: var(--surface); border: 1px solid var(--border);
/*
* "fixed", not "absolute": this menu is appended to .podman-plugin, not
* document.body, and Unraid's own page wrapper around .podman-plugin
* turned out to have its own positioned ancestor — with "absolute" the
* menu was positioning itself relative to THAT ancestor's box while the
* JS math (getBoundingClientRect + scrollY/X) assumed the viewport,
* so it rendered far from the button that opened it (found live: it
* appeared well below and to the side of the anchor). "fixed" is always
* viewport-relative regardless of any ancestor, which is what the JS
* math actually assumes.
*/
position: fixed; width: 180px; background: var(--surface); border: 1px solid var(--border);
border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
}
.podman-context-menu button {
@@ -377,7 +554,8 @@
font-family: var(--font-ui); width: 100%;
}
.podman-context-menu button:hover { background: var(--surface-2); }
.podman-context-menu button.danger { color: var(--bad); }
.podman-context-menu button.danger { color: var(--bad); background: var(--bad-bg); }
.podman-context-menu button.danger:hover { background: var(--bad); color: var(--bad-contrast); }
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }