Compare commits
30
Commits
acb9fb704b
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
166f0d96d1 | ||
|
|
9b9d1a05ac | ||
|
|
66ef830234 | ||
|
|
676fd8bc89 | ||
|
|
20d8686b61 | ||
|
|
9557f8c8f8 | ||
|
|
5fb7376b64 | ||
|
|
7f6fcb9166 | ||
|
|
80e006c73f | ||
|
|
b91bdb9810 | ||
|
|
6e36ee1aef | ||
|
|
18b414d7b5 | ||
|
|
46a8503498 | ||
|
|
e92f67ebba | ||
|
|
2b79411b68 | ||
|
|
ca62577a8b | ||
|
|
8ac9cde621 | ||
|
|
45e27f8575 | ||
|
|
151d93c12d | ||
|
|
993e187f59 | ||
|
|
5b47b4cc0a | ||
|
|
5944ddf722 | ||
|
|
51b7262b72 | ||
|
|
6cd541de23 | ||
|
|
c3b8ae8ed9 | ||
|
|
f1962acc8c | ||
|
|
23a1ab22a6 | ||
|
|
e912180e8d | ||
|
|
3133d45d74 | ||
|
|
c6947007aa |
@@ -1,9 +1,10 @@
|
||||
name: Build Packages
|
||||
|
||||
# Builds the seven Slackware .txz packages defined under packages/
|
||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs)
|
||||
# inside a Slackware container, verifies + consolidates their checksums, and
|
||||
# uploads the result as a workflow artifact.
|
||||
# Builds the eleven Slackware .txz packages defined under packages/
|
||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
|
||||
# catatonit, nftables, podman-compose, unraid-podman) inside a Slackware
|
||||
# container, verifies + consolidates their checksums, and uploads the
|
||||
# result as a workflow artifact.
|
||||
#
|
||||
# Intentionally does NOT commit any built binary back to the repository —
|
||||
# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts
|
||||
@@ -31,7 +32,7 @@ on:
|
||||
inputs:
|
||||
packages:
|
||||
description: >
|
||||
Space-separated package names to build (default: all seven).
|
||||
Space-separated package names to build (default: all eleven).
|
||||
Example: "podman conmon"
|
||||
required: false
|
||||
default: ""
|
||||
@@ -60,8 +61,27 @@ on:
|
||||
# resolve everything from the same prioritized repo set (patches over
|
||||
# main, see /etc/slackpkg/slackpkg.conf's PRIORITY) keeps every package on
|
||||
# this image on a mutually consistent version set.
|
||||
#
|
||||
# actions/checkout and actions/upload-artifact are both Node-based actions
|
||||
# — Gitea Actions execs their JS bundle with the "node" binary found
|
||||
# inside the job's own container, it does not inject a runtime of its
|
||||
# own. Slackware has no nodejs package at all (checked: not in any of the
|
||||
# main/extra/pasture/testing repos), so the "Install git" step also
|
||||
# installs a pinned, checksum-verified Node.js release directly from
|
||||
# nodejs.org — the same style already used for Go/Rust in
|
||||
# scripts/ci/setup-slackware-buildenv.sh.
|
||||
#
|
||||
# upload-artifact is pinned to v3, not v4 — Gitea Actions' built-in
|
||||
# artifact storage does not implement the newer v2 upload/download API
|
||||
# that actions/upload-artifact@v4 and actions/download-artifact@v4
|
||||
# require; uploading fails outright with "GHESNotSupportedError". v3 uses
|
||||
# the older, still-supported protocol. release.yml's download-artifact
|
||||
# must stay on the matching v3 — the two protocols aren't compatible with
|
||||
# each other.
|
||||
env:
|
||||
SLACKWARE_IMAGE: "vbatts/slackware:15.0"
|
||||
NODE_VERSION: "20.20.2"
|
||||
NODE_SHA256: "df770b2a6f130ed8627c9782c988fda9669fa23898329a61a871e32f965e007d"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -72,7 +92,7 @@ jobs:
|
||||
outputs:
|
||||
artifact-name: ${{ steps.artifact-name.outputs.value }}
|
||||
steps:
|
||||
- name: Install git (needed before actions/checkout can run)
|
||||
- name: Install git and Node.js (needed before actions/checkout can run)
|
||||
run: |
|
||||
set -eu
|
||||
command -v tar > /dev/null || (echo "!! base image is missing tar — see SLACKWARE_IMAGE in this workflow" && exit 1)
|
||||
@@ -102,6 +122,20 @@ jobs:
|
||||
|
||||
git --version
|
||||
|
||||
# curl isn't installed until scripts/ci/setup-slackware-buildenv.sh
|
||||
# runs, later — use wget (present in the base image) here instead.
|
||||
# wget does not consult the system CA bundle on its own (unlike
|
||||
# curl/git, which respect CURL_CA_BUNDLE/GIT_SSL_CAINFO), so pass
|
||||
# it explicitly via --ca-certificate.
|
||||
node_tarball="node-v${NODE_VERSION}-linux-x64.tar.xz"
|
||||
wget -q --tries=3 --ca-certificate=/etc/ssl/certs/ca-certificates.crt \
|
||||
-O "/tmp/$node_tarball" "https://nodejs.org/dist/v${NODE_VERSION}/$node_tarball"
|
||||
echo "${NODE_SHA256} /tmp/$node_tarball" | sha256sum -c -
|
||||
mkdir -p /usr/local/lib/nodejs
|
||||
tar -xf "/tmp/$node_tarball" -C /usr/local/lib/nodejs
|
||||
echo "/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin" >> "$GITHUB_PATH"
|
||||
"/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin/node" --version
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Slackware build environment
|
||||
@@ -118,7 +152,7 @@ jobs:
|
||||
run: echo "value=podman-packages-${{ github.sha }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.artifact-name.outputs.value }}
|
||||
path: |
|
||||
|
||||
@@ -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,20 +42,24 @@ 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:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download built packages
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ${{ needs.build.outputs.artifact-name }}
|
||||
path: dist
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+26
-2
@@ -18,6 +18,30 @@
|
||||
# TODO: static_dir / volume_path / runroot overrides pointing at the cache-pool
|
||||
# backed storage location instead of RAM-root defaults.
|
||||
|
||||
[engine.runtimes]
|
||||
# Unraid's / is the kernel's initial 'rootfs' pseudo-filesystem — Unraid
|
||||
# never pivots to a real one during boot, the whole OS runs from RAM — and
|
||||
# pivot_root(2) unconditionally rejects that as the "old root" (EINVAL).
|
||||
# runc (what Docker uses) silently falls back to an MS_MOVE-based chroot
|
||||
# in that situation; crun has no such fallback, only a --no-pivot flag on
|
||||
# `create`/`run` with no config-file equivalent — so podman is pointed at
|
||||
# a thin wrapper (installed by the unraid-podman package, see
|
||||
# plugin/sbin/crun-no-pivot.sh) that injects it, instead of crun directly.
|
||||
# Verified live: without this, every container fails with
|
||||
# "crun: pivot_root: Invalid argument: OCI runtime error".
|
||||
crun = ["/usr/local/sbin/crun-no-pivot.sh"]
|
||||
|
||||
[network]
|
||||
# TODO: default network backend (netavark), default subnet range distinct from
|
||||
# Docker's docker0 range — see docs/ARCHITECTURE.md section 8.
|
||||
# TODO: default subnet range distinct from Docker's docker0 range — see
|
||||
# docs/ARCHITECTURE.md section 8.
|
||||
#
|
||||
# netavark >= 2.0 dropped its iptables firewall driver entirely — only
|
||||
# nftables and firewalld remain (verified against the actual netavark
|
||||
# binary; "iptables" is rejected with "Must provide a valid firewall
|
||||
# backend"). firewalld needs systemd/dbus, which Unraid has neither of, so
|
||||
# nftables (netavark's own default — explicit here so that stays true even
|
||||
# if netavark's default ever changes) is the only viable driver. Unraid OS
|
||||
# does not ship the `nft` binary this needs — see docs/ARCHITECTURE.md
|
||||
# section 8 for how it's provisioned.
|
||||
network_backend = "netavark"
|
||||
firewall_driver = "nftables"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"_comment": "Default container image signature verification policy. Placeholder: accepts all images without signature verification, matching Docker's default trust model on Unraid today. See docs/ARCHITECTURE.md section 10 (Images). Revisit before a 1.0 release if signed-image verification becomes a goal.",
|
||||
"default": [
|
||||
{ "type": "insecureAcceptAnything" }
|
||||
],
|
||||
|
||||
+7
-8
@@ -7,14 +7,13 @@
|
||||
#
|
||||
# Full reference: https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md
|
||||
|
||||
[storage]
|
||||
driver = "overlay"
|
||||
# runroot and graphroot are set at runtime by rc.podman based on
|
||||
# /boot/config/plugins/podman/podman.cfg (configurable storage location),
|
||||
# not hardcoded here. TODO: document the exact substitution mechanism once
|
||||
# rc.podman is implemented.
|
||||
# graphroot = "/var/lib/containers/storage"
|
||||
# runroot = "/var/run/containers/storage"
|
||||
# The [storage] table itself (driver, graphroot, runroot) is intentionally
|
||||
# NOT defined here — podman-config.sh's `sync` command appends it in full
|
||||
# at sync time, since graphroot depends on STORAGE_PATH from
|
||||
# /boot/config/plugins/podman/podman.cfg (configurable storage location) and
|
||||
# can't be known statically. A second [storage] table here would be a TOML
|
||||
# duplicate-key error once sync appends its own — see podman-config.sh's
|
||||
# cmd_sync for the generated content.
|
||||
|
||||
[storage.options]
|
||||
# TODO: overlay-specific mount options once the loopback filesystem
|
||||
|
||||
@@ -232,6 +232,8 @@ wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Ver
|
||||
| `fuse-overlayfs` | Fallback-Storage-Driver | Für Rootless-Phase 2 vorbereitet, in Phase 1 optional |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -339,6 +341,12 @@ Unraid-Plugins.
|
||||
## 8. Netzwerke
|
||||
|
||||
- **Backend**: `netavark` + `aardvark-dns` (Podman-Default seit 4.x), kein CNI in Phase 1.
|
||||
- **Firewall-Treiber**: `netavark` >= 2.0 hat seinen `iptables`-Treiber ersatzlos entfernt —
|
||||
nur noch `nftables` oder `firewalld` (Letzteres braucht systemd/dbus, hat Unraid nicht).
|
||||
`nftables` ist also zwingend, aber Unraid OS bringt das `nft`-Binary selbst nicht mit
|
||||
(nur das ältere `iptables`/`iptables-nft`) — muss vom Plugin bereitgestellt werden
|
||||
(verifiziert per Live-Test: `podman run` mit Port-Publishing scheitert ohne `nft`
|
||||
mit „Must provide a valid firewall backend“).
|
||||
- **Default-Bridge**: eigene Bridge `podman0` (nicht `docker0`), eigener privater
|
||||
Adressraum (konfigurierbar, Default-Vorschlag außerhalb von Dockers Default-Range,
|
||||
um Kollisionen bei Parallelbetrieb zu vermeiden).
|
||||
@@ -397,6 +405,12 @@ Unraid-Plugins.
|
||||
- **Community-Applications-Kompatibilität**: kein automatischer Import von
|
||||
Docker-Templates in Phase 1 (eigenes, separates Vorhaben); Podman-Images werden
|
||||
zunächst über CLI/eigene, minimale Template-Definition verwaltet.
|
||||
- **Signatur-Policy** (`config/policy.json`): `insecureAcceptAnything` als Default —
|
||||
akzeptiert Images ohne Signaturprüfung, entspricht Dockers heutigem
|
||||
Standard-Vertrauensmodell auf Unraid. Vor einem 1.0-Release erneut bewerten, falls
|
||||
signierte Images ein Ziel werden. Die Datei selbst darf keine Kommentarfelder
|
||||
enthalten (`containers/image`s Policy-Parser lehnt unbekannte Top-Level-Keys wie
|
||||
`_comment` strikt ab) — Begründung lebt deshalb hier, nicht in der Datei.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# packages/catatonit/
|
||||
|
||||
Pinned version: see `CATATONIT_VERSION` in [versions.env](../../versions.env).
|
||||
|
||||
Not built from source — `catatonit.SlackBuild` fetches and repackages
|
||||
upstream's own prebuilt static x86_64 release binary. catatonit ships no
|
||||
GitHub release asset for source-tarball builds that's meaningfully
|
||||
different from just using the binary directly, and it's a small, purely
|
||||
static ELF with zero runtime library dependencies (verified: `ldd` reports
|
||||
"not a dynamic executable").
|
||||
|
||||
Required by `podman pod create` — without it, pod creation fails with
|
||||
`finding catatonit binary: exec: catatonit: executable file not found in
|
||||
$PATH`. Installed to `/usr/libexec/podman/catatonit`, alongside
|
||||
netavark/aardvark-dns, which podman's `helper_binaries_dir` search already
|
||||
covers.
|
||||
|
||||
Found by live-testing this plugin end-to-end against a real Unraid
|
||||
install, not from reading podman's docs — see
|
||||
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# packages/catatonit/catatonit.SlackBuild
|
||||
#
|
||||
# Packages the official prebuilt catatonit release binary — not built from
|
||||
# source, see versions.env's CATATONIT_* block for why. catatonit is the
|
||||
# init process podman runs inside every pod's infra container to reap
|
||||
# zombies; without it, `podman pod create` fails outright with "finding
|
||||
# catatonit binary: exec: catatonit: executable file not found in $PATH"
|
||||
# (found by live-testing pod creation against a real Unraid install).
|
||||
#
|
||||
# Installed alongside netavark/aardvark-dns under /usr/libexec/podman/ —
|
||||
# podman's helper_binaries_dir search path already covers that directory,
|
||||
# matching where the other two helper binaries this project ships already
|
||||
# live (see packages/netavark, packages/aardvark-dns).
|
||||
# =============================================================================
|
||||
|
||||
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="$CATATONIT_VERSION"
|
||||
ARCH="$PKG_ARCH"
|
||||
BUILD="$PKG_BUILD"
|
||||
TAG="$PKG_TAG"
|
||||
|
||||
sb_init "catatonit"
|
||||
|
||||
binary=$(sb_fetch_and_verify "$CATATONIT_SRC_URL" "$CATATONIT_SRC_SHA256" "catatonit-$VERSION")
|
||||
|
||||
install -D -m 0755 "$binary" "$PKG/usr/libexec/podman/catatonit"
|
||||
|
||||
# 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/catatonit-$VERSION"
|
||||
mkdir -p "$docdir"
|
||||
{
|
||||
echo "catatonit $VERSION"
|
||||
echo "https://github.com/openSUSE/catatonit"
|
||||
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: catatonit $VERSION"
|
||||
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "$docdir/unraid-podman.build-info"
|
||||
|
||||
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
|
||||
@@ -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------------------------------------------------|
|
||||
catatonit: catatonit (init process for OCI pod infra containers)
|
||||
catatonit:
|
||||
catatonit: A minimal init that reaps zombie processes inside a pod's infra
|
||||
catatonit: container. Required by `podman pod create` — packaged here from
|
||||
catatonit: upstream's prebuilt static binary release for the unraid-podman
|
||||
catatonit: plugin, not built from source (see versions.env).
|
||||
catatonit:
|
||||
catatonit: Homepage: https://github.com/openSUSE/catatonit
|
||||
catatonit:
|
||||
catatonit:
|
||||
catatonit:
|
||||
@@ -37,13 +37,41 @@ done
|
||||
|
||||
cd "$srcdir"
|
||||
|
||||
# GitHub's source archive does not include vendored git submodules
|
||||
# (libocispec) that crun's release tarballs normally bundle; init/update
|
||||
# them explicitly.
|
||||
if [ -f .gitmodules ]; then
|
||||
echo "==> [crun] fetching submodules"
|
||||
git -c protocol.file.allow=always submodule update --init --recursive || true
|
||||
fi
|
||||
# GitHub's source archive has no .git directory, so `git submodule
|
||||
# update` can never work against it (it needs an actual git repo to
|
||||
# resolve against) — it silently fails and leaves libocispec/ empty,
|
||||
# which only surfaces much later as a confusing "No rule to make target
|
||||
# 'all'" in that subdirectory. Fetch libocispec as its own pinned source
|
||||
# instead, matching every other dependency in this project, and unpack it
|
||||
# directly over the empty submodule directory.
|
||||
echo "==> [crun] fetching libocispec (git submodule, pinned separately — see versions.env)"
|
||||
libocispec_tarball=$(sb_fetch_and_verify "$LIBOCISPEC_SRC_URL" "$LIBOCISPEC_SRC_SHA256" "libocispec-$LIBOCISPEC_COMMIT.tar.gz")
|
||||
rm -rf "$srcdir/libocispec"
|
||||
mkdir -p "$srcdir/libocispec"
|
||||
tar -xf "$libocispec_tarball" -C "$srcdir/libocispec" --strip-components=1
|
||||
|
||||
# libocispec has its own two git submodules (image-spec, runtime-spec) —
|
||||
# same problem, one level deeper. Same fix, same reasoning.
|
||||
echo "==> [crun] fetching libocispec's image-spec/runtime-spec submodules"
|
||||
imagespec_tarball=$(sb_fetch_and_verify "$IMAGE_SPEC_SRC_URL" "$IMAGE_SPEC_SRC_SHA256" "image-spec-$IMAGE_SPEC_COMMIT.tar.gz")
|
||||
rm -rf "$srcdir/libocispec/image-spec"
|
||||
mkdir -p "$srcdir/libocispec/image-spec"
|
||||
tar -xf "$imagespec_tarball" -C "$srcdir/libocispec/image-spec" --strip-components=1
|
||||
|
||||
runtimespec_tarball=$(sb_fetch_and_verify "$RUNTIME_SPEC_SRC_URL" "$RUNTIME_SPEC_SRC_SHA256" "runtime-spec-$RUNTIME_SPEC_COMMIT.tar.gz")
|
||||
rm -rf "$srcdir/libocispec/runtime-spec"
|
||||
mkdir -p "$srcdir/libocispec/runtime-spec"
|
||||
tar -xf "$runtimespec_tarball" -C "$srcdir/libocispec/runtime-spec" --strip-components=1
|
||||
|
||||
# crun.c unconditionally #includes git-version.h. Its own Makefile only
|
||||
# generates that file from `git describe` (needs .git, which a plain
|
||||
# tarball checkout never has) or, failing that, from a pre-existing
|
||||
# .tarball-git-version.h — the file crun's own `make dist` would have
|
||||
# written. We're not running `make dist`, so write it ourselves in the
|
||||
# exact format that Makefile target expects; this is the documented
|
||||
# fallback path, not a workaround around it.
|
||||
printf '/* autogenerated. */\n#ifndef GIT_VERSION\n# define GIT_VERSION "%s"\n#endif\n' "$VERSION" \
|
||||
> .tarball-git-version.h
|
||||
|
||||
echo "==> [crun] autogen + configure"
|
||||
./autogen.sh
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# packages/nftables/
|
||||
|
||||
Pinned version: see `NFTABLES_VERSION` in [versions.env](../../versions.env).
|
||||
|
||||
Not built from source, and no `slack-desc` here (unlike this project's
|
||||
other packages) — `nftables.SlackBuild` fetches Slackware's own official
|
||||
`nftables` package and re-hosts it as-is under this project's naming and
|
||||
checksum convention. It's already a correctly-built Slackware package
|
||||
(built by the Slackware team for exactly this OS/glibc/arch); the
|
||||
slack-desc bundled inside it travels along unchanged.
|
||||
|
||||
netavark >= 2.0 dropped its iptables firewall driver entirely — nftables
|
||||
(via the `nft` binary this package provides) is the only firewall backend
|
||||
that works on Unraid (firewalld needs systemd/dbus, which Unraid has
|
||||
neither of). Unraid OS itself ships no `nft` binary.
|
||||
|
||||
Found by live-testing this plugin end-to-end against a real Unraid
|
||||
install, not from reading netavark's docs — see
|
||||
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# packages/nftables/nftables.SlackBuild
|
||||
#
|
||||
# Vendors Slackware's own official nftables package as-is — not rebuilt
|
||||
# from source, see versions.env's NFTABLES_* block for why. Unlike every
|
||||
# other package here, there is no compile step: the fetched .txz is
|
||||
# already a correctly-built Slackware package (built by the Slackware
|
||||
# team for exactly this OS/glibc/arch), so it is re-hosted under this
|
||||
# project's naming/checksum convention rather than unpacked and restaged
|
||||
# through makepkg, which would add risk (differing compression/metadata)
|
||||
# for no benefit.
|
||||
#
|
||||
# netavark >= 2.0 requires the nftables firewall driver (its iptables
|
||||
# driver was removed entirely) but Unraid OS ships no `nft` binary — see
|
||||
# config/containers.conf and docs/ARCHITECTURE.md section 8. Without this
|
||||
# package, every `podman run`/`podman pod create` that touches networking
|
||||
# fails with "netavark: Must provide a valid firewall backend" (found by
|
||||
# live-testing against a real Unraid install).
|
||||
# =============================================================================
|
||||
|
||||
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="$NFTABLES_VERSION"
|
||||
ARCH="$PKG_ARCH"
|
||||
BUILD="$PKG_BUILD"
|
||||
TAG="$PKG_TAG"
|
||||
|
||||
sb_init "nftables"
|
||||
|
||||
official_pkg=$(sb_fetch_and_verify "$NFTABLES_SRC_URL" "$NFTABLES_SRC_SHA256" "nftables-$VERSION-official.txz")
|
||||
|
||||
pkg_file="nftables-$VERSION-$ARCH-$BUILD$TAG.txz"
|
||||
cp "$official_pkg" "$OUTPUT/$pkg_file"
|
||||
|
||||
( cd "$OUTPUT" && sha256sum "$pkg_file" > "$pkg_file.sha256" )
|
||||
( cd "$OUTPUT" && md5sum "$pkg_file" > "$pkg_file.md5" )
|
||||
|
||||
echo "==> [nftables] vendored official Slackware package as $OUTPUT/$pkg_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
@@ -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"
|
||||
@@ -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:
|
||||
@@ -50,6 +50,18 @@ export BUILDTAGS="seccomp exclude_graphdriver_btrfs exclude_graphdriver_devicema
|
||||
export CGO_ENABLED=1
|
||||
export GOFLAGS="${GOFLAGS:--mod=mod}"
|
||||
|
||||
# podman's own Makefile wants to build ITS bundled copy of go-md2man from
|
||||
# test/tools/vendor/ for doc generation, but that vendor tree isn't
|
||||
# consistent enough to build standalone outside podman's own module scope
|
||||
# ("without -mod=vendor, directory ... has no package path"). Its Makefile
|
||||
# only attempts that build if test/tools/build/go-md2man doesn't already
|
||||
# exist — pre-seed it with our own system go-md2man (installed in
|
||||
# scripts/ci/setup-slackware-buildenv.sh) to skip the broken vendor build
|
||||
# entirely; it's the same tool doing the same job.
|
||||
mkdir -p test/tools/build
|
||||
cp "$(command -v go-md2man)" test/tools/build/go-md2man
|
||||
chmod +x test/tools/build/go-md2man
|
||||
|
||||
echo "==> [podman] make (BUILDTAGS=$BUILDTAGS)"
|
||||
make BUILDTAGS="$BUILDTAGS" GO_BUILD_FLAGS="-ldflags -s"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# packages/unraid-podman
|
||||
|
||||
Packaging recipe for the plugin's own scaffolding — **not** an upstream
|
||||
component like the other seven package directories. See
|
||||
component or vendored dependency like the other ten package directories. See
|
||||
`unraid-podman.SlackBuild`'s header comment for the full rationale.
|
||||
|
||||
Bundles:
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
# =============================================================================
|
||||
# packages/unraid-podman/unraid-podman.SlackBuild
|
||||
#
|
||||
# Unlike the other seven packages, this one does not compile anything from
|
||||
# an external upstream source — it packages THIS repository's own plugin
|
||||
# scaffolding (rc.podman, the sbin/ helper scripts, the official Unraid
|
||||
# event/ hooks, and the default config templates) into a single .txz,
|
||||
# exactly matching how real-world Unraid plugins bundle their own files
|
||||
# (verified against the actual unassigned.devices.plg / package layout —
|
||||
# see docs/ARCHITECTURE.md section 3.2 for the reference check that led to
|
||||
# this design). plugin/podman.plg installs this alongside the seven
|
||||
# compiled component packages, all via the same
|
||||
# Unlike the other ten packages, this one does not fetch anything from
|
||||
# an external upstream source at all — it packages THIS repository's own
|
||||
# plugin scaffolding (rc.podman, the sbin/ helper scripts, the official
|
||||
# Unraid event/ hooks, and the default config templates) into a single
|
||||
# .txz, exactly matching how real-world Unraid plugins bundle their own
|
||||
# files (verified against the actual unassigned.devices.plg / package
|
||||
# layout — see docs/ARCHITECTURE.md section 3.2 for the reference check
|
||||
# that led to this design). plugin/podman.plg installs this alongside the
|
||||
# other ten packages, all via the same
|
||||
# `upgradepkg --install-new --reinstall` mechanism.
|
||||
#
|
||||
# Version: taken directly from plugin/podman.plg's own <!ENTITY version>,
|
||||
@@ -28,7 +28,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
. "$REPO_ROOT/versions.env"
|
||||
|
||||
VERSION=$(grep -oP '(?<=<!ENTITY version[[:space:]]{1,10}")[^"]+' "$REPO_ROOT/plugin/podman.plg" | head -n1)
|
||||
# sed, not `grep -oP` with a lookbehind: the lookbehind here needed a
|
||||
# variable-length quantifier ({1,10}) to match the entity's flexible
|
||||
# whitespace, but that requires PCRE2 — the PCRE1 grep ships on Slackware
|
||||
# (and many other distros) rejects it outright ("lookbehind assertion is
|
||||
# not fixed length"). sed's basic regex has no such restriction.
|
||||
VERSION=$(sed -n 's/.*<!ENTITY version[[:space:]]\+"\([^"]*\)".*/\1/p' "$REPO_ROOT/plugin/podman.plg" | head -n1)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "!! Could not read <!ENTITY version> from $REPO_ROOT/plugin/podman.plg" >&2
|
||||
exit 1
|
||||
|
||||
@@ -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
|
||||
|
||||
+106
-86
@@ -32,14 +32,16 @@
|
||||
|
||||
Structure of this file:
|
||||
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
|
||||
per package (the seven upstream components plus this project's own
|
||||
"unraid-podman" scaffolding package, see packages/unraid-podman/).
|
||||
per package (the seven upstream components, catatonit/nftables/
|
||||
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
|
||||
hand-edit a *_txz_version/_file/_md5 entity — see that script.
|
||||
2. <PLUGIN> body:
|
||||
a. <CHANGES> — kept in sync with CHANGELOG.md by hand for now.
|
||||
b. Pre-install architecture sanity check.
|
||||
c. Eight <FILE> package install/update blocks.
|
||||
c. Eleven <FILE> package install/update blocks.
|
||||
d. Postinstall <FILE Run="/bin/bash"> — directory/config seeding,
|
||||
install-manifest generation, first start.
|
||||
e. <FILE Run="/bin/bash" Method="remove"> — uninstall.
|
||||
@@ -48,25 +50,31 @@
|
||||
<!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
|
||||
the eight removepkg calls in the Method="remove" block don't have to
|
||||
repeat "x86_64-1_unraidpodman" eight times by hand. -->
|
||||
the eleven removepkg calls in the Method="remove" block don't have to
|
||||
repeat "x86_64-1_unraidpodman" eleven times by hand. -->
|
||||
<!ENTITY pkgArch "x86_64">
|
||||
<!ENTITY pkgBuild "1">
|
||||
<!ENTITY pkgTag "_unraidpodman">
|
||||
@@ -78,33 +86,54 @@
|
||||
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/
|
||||
and packages/nftables/ READMEs for why each is needed (pod infra
|
||||
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.2.1">
|
||||
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
|
||||
<!ENTITY catatonit_txz_md5 "e17faf29b0c618a73def017583697aca">
|
||||
|
||||
<!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">
|
||||
|
||||
<!-- 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 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
|
||||
@@ -112,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;"
|
||||
@@ -145,7 +174,7 @@
|
||||
<!--
|
||||
Pre-install sanity check: this project only builds/ships x86_64 packages
|
||||
(see versions.env's PKG_ARCH) — fail with a clear message on any other
|
||||
architecture rather than letting eight package downloads 404 one by one.
|
||||
architecture rather than letting eleven package downloads 404 one by one.
|
||||
-->
|
||||
<FILE Run="/bin/bash">
|
||||
<INLINE>
|
||||
@@ -157,7 +186,9 @@ fi
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
The seven upstream component packages. Each is downloaded straight into
|
||||
The seven upstream component packages, plus catatonit, nftables, and
|
||||
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
|
||||
targets "this plugin release" as one unit, see
|
||||
@@ -170,66 +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>
|
||||
</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>
|
||||
</FILE>
|
||||
|
||||
<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>
|
||||
|
||||
<!--
|
||||
@@ -238,16 +256,12 @@ 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>
|
||||
|
||||
<!--
|
||||
Postinstall: everything that has to happen AFTER the eight packages above
|
||||
Postinstall: everything that has to happen AFTER the eleven packages above
|
||||
are on disk, but isn't itself package content — directory/config
|
||||
seeding, the install-manifest that plugin/sbin/podman-verify-packages.sh
|
||||
and podman-update-packages.sh read, and the first start. Runs on both
|
||||
@@ -274,7 +288,7 @@ set -u
|
||||
|
||||
echo "Setting permissions..."
|
||||
chmod 0755 /etc/rc.d/rc.podman
|
||||
chmod 0755 /usr/local/sbin/podman-*.sh
|
||||
chmod 0755 /usr/local/sbin/*.sh
|
||||
chmod 0755 /usr/local/emhttp/plugins/podman/event/disks_mounted
|
||||
chmod 0755 /usr/local/emhttp/plugins/podman/event/stopping
|
||||
|
||||
@@ -291,6 +305,9 @@ echo "NETAVARK_INSTALLED_VERSION=\"&netavark_txz_version;\"" >> "$MANIFEST"
|
||||
echo "AARDVARK_DNS_INSTALLED_VERSION=\"&aardvark_dns_txz_version;\"" >> "$MANIFEST"
|
||||
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 "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)..."
|
||||
@@ -351,6 +368,9 @@ removepkg &netavark_txz_file;
|
||||
removepkg &aardvark_dns_txz_file;
|
||||
removepkg &passt_txz_file;
|
||||
removepkg &fuse_overlayfs_txz_file;
|
||||
removepkg &catatonit_txz_file;
|
||||
removepkg &nftables_txz_file;
|
||||
removepkg &podman_compose_txz_file;
|
||||
removepkg &unraid_podman_txz_file;
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -98,7 +98,7 @@ podman_start() {
|
||||
# a shared process gives consistent state and event streaming.
|
||||
# --time=0 disables the idle-shutdown timeout (this is a long-running
|
||||
# daemon under our process management, not an on-demand activation).
|
||||
mkdir -p "$PODMAN_RUN_DIR"
|
||||
mkdir -p "$PODMAN_RUN_DIR" "$PODMAN_LOG_DIR"
|
||||
podman_log "start: starting podman system service on unix://$PODMAN_SOCKET"
|
||||
nohup podman system service --time=0 "unix://$PODMAN_SOCKET" \
|
||||
> "$PODMAN_LOG_DIR/podman-service.log" 2>&1 &
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# plugin/sbin/crun-no-pivot.sh
|
||||
#
|
||||
# Thin OCI-runtime wrapper around crun, injecting --no-pivot on `create`/
|
||||
# `run`. Unraid's / is the kernel's initial "rootfs" pseudo-filesystem —
|
||||
# Unraid never pivots to a real one during boot, the whole OS runs from
|
||||
# RAM — and pivot_root(2) unconditionally rejects that as the "old root"
|
||||
# (EINVAL). runc (what Docker uses) silently falls back to an MS_MOVE-based
|
||||
# chroot in that situation; crun has no such fallback and no config-file
|
||||
# equivalent, only this per-invocation flag — so config/containers.conf
|
||||
# points podman's crun runtime at this wrapper instead of crun directly.
|
||||
# See docs/ARCHITECTURE.md section 8.
|
||||
#
|
||||
# Verified live: without this, every container fails with
|
||||
# "crun: pivot_root: Invalid argument: OCI runtime error".
|
||||
#
|
||||
# --no-pivot is only a valid flag on crun's create/run subcommands (see
|
||||
# `crun run --help`) — every other subcommand (delete, exec, kill, list,
|
||||
# ...) is passed straight through unmodified. podman invokes crun with its
|
||||
# own global flags BEFORE the subcommand (e.g.
|
||||
# `crun --log-format=json --log <path> create --bundle <path> ...`), so
|
||||
# the subcommand is not reliably $1 — scan every argument instead of only
|
||||
# checking the first one, and insert --no-pivot immediately after
|
||||
# create/run wherever it actually appears.
|
||||
# =============================================================================
|
||||
|
||||
args=()
|
||||
found=0
|
||||
for arg in "$@"; do
|
||||
args+=("$arg")
|
||||
if [ "$found" -eq 0 ] && { [ "$arg" = create ] || [ "$arg" = run ]; }; then
|
||||
args+=("--no-pivot")
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
exec /usr/bin/crun "${args[@]}"
|
||||
@@ -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>
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
# needed — see docs/ARCHITECTURE.md section 13.1.
|
||||
#
|
||||
# Usage:
|
||||
# podman-update-packages.sh # reconcile all 7 packages
|
||||
# podman-update-packages.sh # reconcile all 11 packages
|
||||
# podman-update-packages.sh podman # reconcile a single package
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 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"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# =============================================================================
|
||||
# plugin/sbin/podman-verify-packages.sh
|
||||
#
|
||||
# "Pakete prüfen" — verifies the seven packages this plugin ships are
|
||||
# "Pakete prüfen" — verifies the eleven packages this plugin ships are
|
||||
# actually installed, at the version the plugin expects, and that the
|
||||
# backed-up .txz copies (see podman-backup.sh) haven't bit-rotted on disk.
|
||||
#
|
||||
@@ -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 unraid-podman"
|
||||
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
|
||||
|
||||
QUIET=0
|
||||
[ "${1:-}" = "--quiet" ] && QUIET=1
|
||||
@@ -95,7 +95,7 @@ for name in $PACKAGES; do
|
||||
esac
|
||||
|
||||
# --- Check 3: backup artifact integrity, if present --------------------
|
||||
# Packages of all 7 components released together as one plugin version
|
||||
# Packages of all components released together as one plugin version
|
||||
# are grouped under a single PLUGIN_VERSION directory (not per-component
|
||||
# version) — a rollback targets "go back to plugin release X", matching
|
||||
# podman-backup.sh's restore-packages <plugin-version>.
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
# Unraid Podman Plugin – Anforderungen an die WebUI
|
||||
|
||||
Du bist ein Senior UX Designer und Senior Full-Stack Entwickler mit Erfahrung in Unraid, Podman und Self-Hosting.
|
||||
|
||||
Entwirf eine moderne, performante und vollständig in Unraid integrierte WebUI für ein natives Podman-Plugin.
|
||||
|
||||
Die Oberfläche soll sich optisch und funktional wie ein offizieller Bestandteil von Unraid anfühlen und keine Fremdanwendung darstellen.
|
||||
|
||||
## Ziel
|
||||
|
||||
Die WebUI soll alle Funktionen bieten, die Administratoren für die Verwaltung von Podman benötigen, ohne auf die Kommandozeile angewiesen zu sein.
|
||||
|
||||
Die Bedienung soll sowohl für Einsteiger als auch für erfahrene Nutzer geeignet sein.
|
||||
|
||||
---
|
||||
|
||||
# Allgemeine Anforderungen
|
||||
|
||||
* Responsives Design
|
||||
* Dark-Mode kompatibel
|
||||
* Passend zum Unraid-Design
|
||||
* Sehr schnelle Ladezeiten
|
||||
* Modular aufgebaut
|
||||
* Erweiterbar
|
||||
* AJAX/WebSocket statt kompletter Seitenreloads
|
||||
* Suchfunktion auf allen Listen
|
||||
* Sortierung aller Tabellen
|
||||
* Filtermöglichkeiten
|
||||
* Mehrfachauswahl
|
||||
* Kontextmenüs
|
||||
* Bestätigungsdialoge
|
||||
* Benachrichtigungen (Success, Warning, Error)
|
||||
* Ladeanimationen
|
||||
* Fortschrittsanzeigen
|
||||
* Automatische Aktualisierung wichtiger Statusinformationen
|
||||
* Mehrsprachigkeit vorbereiten
|
||||
|
||||
---
|
||||
|
||||
# Dashboard
|
||||
|
||||
Das Dashboard soll auf einen Blick den Zustand der gesamten Podman-Umgebung zeigen.
|
||||
|
||||
Anzeigen:
|
||||
|
||||
* Anzahl Container
|
||||
* Laufende Container
|
||||
* Gestoppte Container
|
||||
* Pods
|
||||
* Images
|
||||
* Netzwerke
|
||||
* Volumes
|
||||
* CPU-Auslastung
|
||||
* RAM-Auslastung
|
||||
* Storage-Auslastung
|
||||
* Schreib-/Lese-I/O
|
||||
* Netzwerkdurchsatz
|
||||
* Podman-Version
|
||||
* Conmon-Version
|
||||
* crun-Version
|
||||
* Netavark-Version
|
||||
* Plugin-Version
|
||||
|
||||
Zusätzlich:
|
||||
|
||||
* Letzte Ereignisse
|
||||
* Fehler
|
||||
* Warnungen
|
||||
* Updates verfügbar
|
||||
* Container mit Problemen
|
||||
* Container ohne Healthcheck
|
||||
* Nicht verwendete Images
|
||||
* Nicht verwendete Volumes
|
||||
* Nicht verwendete Netzwerke
|
||||
|
||||
---
|
||||
|
||||
# Containerverwaltung
|
||||
|
||||
Für jeden Container:
|
||||
|
||||
* Start
|
||||
* Stop
|
||||
* Restart
|
||||
* Pause
|
||||
* Resume
|
||||
* Kill
|
||||
* Löschen
|
||||
* Umbenennen
|
||||
* Duplizieren
|
||||
* Exportieren
|
||||
* Commit als Image
|
||||
* Snapshot (falls unterstützt)
|
||||
|
||||
Informationen:
|
||||
|
||||
* Name
|
||||
* Image
|
||||
* Status
|
||||
* Uptime
|
||||
* CPU
|
||||
* RAM
|
||||
* PID
|
||||
* IP-Adresse
|
||||
* Netzwerk
|
||||
* Ports
|
||||
* Volumes
|
||||
* Labels
|
||||
* Environment
|
||||
* Health Status
|
||||
* Restart Policy
|
||||
|
||||
Tabs:
|
||||
|
||||
* Übersicht
|
||||
* Logs
|
||||
* Konsole (TTY)
|
||||
* Ressourcen
|
||||
* Netzwerke
|
||||
* Volumes
|
||||
* Environment
|
||||
* Mounts
|
||||
* Labels
|
||||
* Events
|
||||
* Inspect (JSON)
|
||||
* Healthcheck-Verlauf
|
||||
|
||||
---
|
||||
|
||||
# Pod-Verwaltung
|
||||
|
||||
* Pod erstellen
|
||||
* Pod löschen
|
||||
* Container hinzufügen
|
||||
* Container entfernen
|
||||
* Pod starten
|
||||
* Pod stoppen
|
||||
* Neustarten
|
||||
* Logs
|
||||
* Portübersicht
|
||||
* Netzwerkübersicht
|
||||
|
||||
---
|
||||
|
||||
# Image-Verwaltung
|
||||
|
||||
* Images anzeigen
|
||||
* Pull
|
||||
* Push
|
||||
* Taggen
|
||||
* Löschen
|
||||
* Prune
|
||||
* Registry auswählen
|
||||
* Größe anzeigen
|
||||
* Erstellungsdatum
|
||||
* Layer anzeigen
|
||||
* Historie anzeigen
|
||||
* Sicherheitsinformationen (wenn verfügbar)
|
||||
|
||||
---
|
||||
|
||||
# Registry-Unterstützung
|
||||
|
||||
Unterstützung für:
|
||||
|
||||
* Docker Hub
|
||||
* GitHub Container Registry
|
||||
* Quay.io
|
||||
* GitLab Registry
|
||||
* Harbor
|
||||
* Eigene Registries
|
||||
|
||||
Funktionen:
|
||||
|
||||
* Login
|
||||
* Logout
|
||||
* Zugangsdaten speichern
|
||||
* TLS-Konfiguration
|
||||
* Unsichere Registry optional aktivieren
|
||||
|
||||
---
|
||||
|
||||
# Netzwerke
|
||||
|
||||
* Netzwerke erstellen
|
||||
* Löschen
|
||||
* Bearbeiten
|
||||
* Verbundene Container anzeigen
|
||||
* Treiber anzeigen
|
||||
* Subnetz
|
||||
* Gateway
|
||||
* DNS
|
||||
* MTU
|
||||
* VLAN (falls unterstützt)
|
||||
|
||||
---
|
||||
|
||||
# Volumes
|
||||
|
||||
* Erstellen
|
||||
* Löschen
|
||||
* Mountpunkte anzeigen
|
||||
* Speicherverbrauch
|
||||
* Zugeordnete Container
|
||||
* Backup starten
|
||||
* Wiederherstellen
|
||||
* Exportieren
|
||||
|
||||
---
|
||||
|
||||
# Compose-Unterstützung
|
||||
|
||||
Unterstützung für:
|
||||
|
||||
* podman compose
|
||||
|
||||
Funktionen:
|
||||
|
||||
* Compose-Datei importieren
|
||||
* Stack erstellen
|
||||
* Start
|
||||
* Stop
|
||||
* Neustart
|
||||
* Logs
|
||||
* Bearbeiten
|
||||
* Aktualisieren
|
||||
* Löschen
|
||||
* YAML-Editor mit Syntax-Highlighting
|
||||
* Validierung vor dem Speichern
|
||||
|
||||
---
|
||||
|
||||
# Template-Unterstützung
|
||||
|
||||
Kompatibilität mit Unraid-Templates.
|
||||
|
||||
Unterstützung für:
|
||||
|
||||
* XML importieren
|
||||
* XML exportieren
|
||||
* Template erstellen
|
||||
* Template bearbeiten
|
||||
* Icons
|
||||
* Kategorien
|
||||
* Repository-Links
|
||||
|
||||
---
|
||||
|
||||
# Container-Erstellung
|
||||
|
||||
Wizard mit mehreren Schritten:
|
||||
|
||||
* Name
|
||||
* Image auswählen
|
||||
* Registry auswählen
|
||||
* Ports
|
||||
* Volumes
|
||||
* Environment
|
||||
* Labels
|
||||
* Geräte
|
||||
* GPU
|
||||
* USB
|
||||
* Netzwerk
|
||||
* Ressourcen
|
||||
* Healthcheck
|
||||
* Restart Policy
|
||||
* Zusammenfassung
|
||||
* Validierung
|
||||
* Container erzeugen
|
||||
|
||||
---
|
||||
|
||||
# Logs
|
||||
|
||||
* Live-Logs
|
||||
* Suchfunktion
|
||||
* Filter
|
||||
* Zeitfilter
|
||||
* Auto-Scroll
|
||||
* Download
|
||||
* Kopieren
|
||||
* ANSI-Farben korrekt darstellen
|
||||
|
||||
---
|
||||
|
||||
# Konsole
|
||||
|
||||
Browser-Terminal mit:
|
||||
|
||||
* Vollbildmodus
|
||||
* Kopieren/Einfügen
|
||||
* Mehrere Sitzungen
|
||||
* Größenanpassung
|
||||
* UTF-8-Unterstützung
|
||||
|
||||
---
|
||||
|
||||
# Ressourcenverwaltung
|
||||
|
||||
Container-Limits bearbeiten:
|
||||
|
||||
* CPU
|
||||
* RAM
|
||||
* Swap
|
||||
* PIDs
|
||||
* Block-I/O
|
||||
* Geräte
|
||||
* HugePages (falls unterstützt)
|
||||
|
||||
Visualisierung:
|
||||
|
||||
* CPU
|
||||
* RAM
|
||||
* Netzwerk
|
||||
* Festplatten-I/O
|
||||
|
||||
---
|
||||
|
||||
# Ereignisse
|
||||
|
||||
Live-Event-Ansicht:
|
||||
|
||||
* Container gestartet
|
||||
* Container gestoppt
|
||||
* Image geladen
|
||||
* Fehler
|
||||
* Netzwerkänderungen
|
||||
* Volumeänderungen
|
||||
|
||||
Filter nach:
|
||||
|
||||
* Container
|
||||
* Typ
|
||||
* Zeitraum
|
||||
* Schweregrad
|
||||
|
||||
---
|
||||
|
||||
# Einstellungen
|
||||
|
||||
Plugin-Einstellungen:
|
||||
|
||||
* Storage-Pfad
|
||||
* API
|
||||
* Socket
|
||||
* Registry-Einstellungen
|
||||
* Standardnetzwerk
|
||||
* Standard-Runtime
|
||||
* Logging
|
||||
* Autostart
|
||||
* Updates
|
||||
* Debugmodus
|
||||
|
||||
---
|
||||
+17
-10
@@ -2,10 +2,11 @@
|
||||
# =============================================================================
|
||||
# scripts/build-packages.sh
|
||||
#
|
||||
# Orchestrates building all seven Slackware .txz packages this plugin ships
|
||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs), by
|
||||
# running each package's <name>.SlackBuild in turn. See
|
||||
# docs/ARCHITECTURE.md section 5.2 (Build-Strategie).
|
||||
# Orchestrates building all Slackware .txz packages this plugin ships
|
||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
|
||||
# catatonit, nftables, podman-compose), by running each package's
|
||||
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
|
||||
# (Build-Strategie).
|
||||
#
|
||||
# This script itself does not containerize anything — it assumes it is
|
||||
# already running inside a Slackware-compatible build environment (see
|
||||
@@ -37,13 +38,19 @@ 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=(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)
|
||||
|
||||
# Podman is listed second-to-last on purpose: among the seven upstream
|
||||
# components it is the slowest build and the one most likely to fail on a
|
||||
# dependency/tag mistake, so faster packages surface problems first during
|
||||
# local iteration. unraid-podman is genuinely last since it packages this
|
||||
# repo's own files and has no compile step at all.
|
||||
# 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
|
||||
# during local iteration. unraid-podman is genuinely last since it
|
||||
# packages this repo's own files and has no compile step at all.
|
||||
|
||||
requested=("$@")
|
||||
if [ "${#requested[@]}" -eq 0 ]; then
|
||||
|
||||
@@ -16,7 +16,7 @@ GO_VERSION="1.26.5"
|
||||
GO_SRC_URL="https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz"
|
||||
# Checksum as published by go.dev itself (`curl -s https://go.dev/dl/?mode=json`)
|
||||
# — must be re-derived from the same source whenever GO_VERSION changes.
|
||||
GO_SRC_SHA256="88c162b204e6eefcc32499453b492e80209f4a4c78c33092636901c540fb0d05"
|
||||
GO_SRC_SHA256="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
|
||||
|
||||
# Rust toolchain (builds netavark, aardvark-dns) — installed via rustup
|
||||
# rather than a pinned tarball, since rustup itself provides reproducible,
|
||||
@@ -44,3 +44,15 @@ LIBSECCOMP_SRC_SHA256="f9a13e4c633d319a9240189760ca348caa0837c0ebe2a09b17061da8c
|
||||
YAJL_VERSION="2.1.0"
|
||||
YAJL_SRC_URL="https://github.com/lloyd/yajl/archive/refs/tags/${YAJL_VERSION}.tar.gz"
|
||||
YAJL_SRC_SHA256="3fb73364a5a30efe615046d07e6db9d09fd2b41c763c5f7d3bfb121cd5c5ac5a"
|
||||
|
||||
# protoc (netavark's build.rs shells out to it via the prost-build crate) —
|
||||
# Slackware ships no protobuf/protoc package at all, official prebuilt
|
||||
# release binary used instead of a from-source C++ build.
|
||||
PROTOC_VERSION="35.1"
|
||||
PROTOC_SRC_URL="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip"
|
||||
PROTOC_SRC_SHA256="6930ebf62bd4ea607b98fff052596c6ee564b9835b4ce172c75a3f53ae9d91b7"
|
||||
|
||||
# go-md2man (conmon's `make install` shells out to it to generate its man
|
||||
# page) — installed via `go install`, pinned to a tagged release rather
|
||||
# than @latest so this build stays reproducible.
|
||||
GO_MD2MAN_VERSION="2.0.7"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# scripts/ci/setup-slackware-buildenv.sh
|
||||
#
|
||||
# Prepares a Slackware container (see .github/workflows/build-packages.yml)
|
||||
# to build all seven packages under packages/. Idempotent and safe to re-run.
|
||||
# to build all eleven packages under packages/. Idempotent and safe to re-run.
|
||||
#
|
||||
# Strategy: vbatts/slackware:15.0 (the image build-packages.yml runs this
|
||||
# in) is a minimal rootfs — it ships none of the 'D' (development) series,
|
||||
@@ -14,9 +14,10 @@
|
||||
# below must name every package explicitly, including curl's HTTPS
|
||||
# runtime libs (nghttp2, brotli, cyrus-sasl), or you get a shared-library
|
||||
# error at the first invocation, not an install-time failure. What's left
|
||||
# after this (libseccomp, yajl — neither ships in stock Slackware — plus
|
||||
# the Go and Rust toolchains, which no Slackware install ships) is
|
||||
# bootstrapped from source, further down. This makes the script tolerant
|
||||
# after this (libseccomp, yajl, protoc — none ship in stock Slackware —
|
||||
# plus the Go/Rust toolchains and go-md2man, which no Slackware install
|
||||
# ships) is bootstrapped from source or official upstream releases,
|
||||
# further down. This makes the script tolerant
|
||||
# of small differences between Slackware base image variants (it skips
|
||||
# anything slackpkg reports as already installed) instead of assuming one
|
||||
# exact image layout, while still failing loudly if something we cannot
|
||||
@@ -75,7 +76,9 @@ if command -v slackpkg > /dev/null 2>&1; then
|
||||
slackpkg -batch=on -default_answer=y update
|
||||
slackpkg -batch=on -default_answer=y install \
|
||||
gcc gcc-g++ binutils make m4 perl autoconf automake libtool pkg-config \
|
||||
curl nghttp2 brotli cyrus-sasl ca-certificates glib2 libcap fuse3
|
||||
curl nghttp2 brotli cyrus-sasl ca-certificates glib2 libcap fuse3 \
|
||||
cmake libarchive lz4 libxml2 guile gc kernel-headers flex elfutils \
|
||||
python3 json-c
|
||||
else
|
||||
echo "==> slackpkg not found, assuming toolchain is already provided by the base image"
|
||||
fi
|
||||
@@ -93,6 +96,7 @@ require_binary git "Needed to fetch crun's git submodules."
|
||||
require_binary curl "Needed to fetch pinned source tarballs."
|
||||
require_binary makepkg "Slackware's own packaging tool (pkgtools); should always be present."
|
||||
require_binary strip "Part of binutils; part of Slackware's 'D' series."
|
||||
require_binary python3 "Needed by crun's configure script (checks for Python >= 3)."
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 2. C library dependencies expected to already be present.
|
||||
@@ -100,6 +104,7 @@ require_binary strip "Part of binutils; part of Slackware's 'D' series."
|
||||
require_pkgconfig glib-2.0 "Install Slackware's glib2 package (needed by conmon)."
|
||||
require_pkgconfig libcap "Install Slackware's libcap package (needed by crun)." || true
|
||||
require_pkgconfig fuse3 "Install Slackware's fuse3 package (needed by fuse-overlayfs)." || true
|
||||
require_pkgconfig json-c "Install Slackware's json-c package (needed by crun, >= 0.14)." || true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 3. libseccomp — not part of stock Slackware, build from source if missing.
|
||||
@@ -134,7 +139,9 @@ if ! pkg-config --exists yajl 2>/dev/null; then
|
||||
exit 1
|
||||
}
|
||||
mkdir -p "$d/src" && tar -xf "$d/src.tar.gz" -C "$d/src" --strip-components=1
|
||||
# yajl uses its own cmake-free ./configure wrapper script.
|
||||
# yajl's ./configure is a thin wrapper around CMake (not a cmake-free
|
||||
# autoconf script, despite its name) — cmake must already be on PATH,
|
||||
# see the slackpkg install list in step 0 above.
|
||||
( cd "$d/src" && ./configure -p /usr && make -C build install )
|
||||
ldconfig 2>/dev/null || true
|
||||
else
|
||||
@@ -144,27 +151,54 @@ fi
|
||||
# -----------------------------------------------------------------------------
|
||||
# 5. Go toolchain (podman) — official upstream tarball.
|
||||
# -----------------------------------------------------------------------------
|
||||
if ! command -v go > /dev/null 2>&1; then
|
||||
echo "==> Go not found, installing $GO_VERSION"
|
||||
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
|
||||
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
|
||||
[ "$actual" = "$GO_SRC_SHA256" ] || {
|
||||
echo "!! Go toolchain checksum mismatch (expected $GO_SRC_SHA256, got $actual)" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xf "$WORK/go.tar.gz"
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
# Persist PATH for subsequent steps in the same GitHub Actions job.
|
||||
# Always install our pinned Go, unconditionally — do NOT skip this just
|
||||
# because `command -v go` finds something. On this image, slackpkg's
|
||||
# batch-mode "install gcc" (step 0 above) pulls in every gcc-<lang>
|
||||
# sibling package built from the same Slackware gcc SlackBuild, including
|
||||
# gcc-go, which ships an ancient bundled Go (gccgo, go1.16.5) at
|
||||
# /usr/bin/go — old enough that its go.mod parser rejects the 3-component
|
||||
# "go 1.25.x" directive modern modules use, and would silently shadow our
|
||||
# intended $GO_VERSION if we only installed when `go` was missing.
|
||||
# Overwriting /usr/local/go and prepending it to PATH/GITHUB_PATH here
|
||||
# guarantees the pinned toolchain wins regardless of what else provides a
|
||||
# `go` binary.
|
||||
echo "==> installing Go $GO_VERSION (unconditionally, see comment above)"
|
||||
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
|
||||
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
|
||||
[ "$actual" = "$GO_SRC_SHA256" ] || {
|
||||
echo "!! Go toolchain checksum mismatch (expected $GO_SRC_SHA256, got $actual)" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xf "$WORK/go.tar.gz"
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
# Persist PATH for subsequent steps in the same GitHub Actions job.
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
echo "==> using: $(go version)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. go-md2man (conmon) — its `make install` shells out to this to render
|
||||
# docs/conmon.8.md into a man page; not packaged by Slackware, and no
|
||||
# prebuilt binary release exists upstream, so `go install` it (now that
|
||||
# our pinned Go from step 5 is on PATH). Pinned to a tagged release
|
||||
# rather than @latest to keep this build reproducible.
|
||||
# -----------------------------------------------------------------------------
|
||||
if ! command -v go-md2man > /dev/null 2>&1; then
|
||||
echo "==> installing go-md2man v$GO_MD2MAN_VERSION"
|
||||
go install "github.com/cpuguy83/go-md2man/v2@v${GO_MD2MAN_VERSION}"
|
||||
gobin="$(go env GOPATH)/bin"
|
||||
export PATH="$gobin:$PATH"
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
|
||||
echo "$gobin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
else
|
||||
echo "==> Go already present: $(go version)"
|
||||
echo "==> go-md2man already present: $(command -v go-md2man)"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 6. Rust toolchain (netavark, aardvark-dns) — via rustup.
|
||||
# 7. Rust toolchain (netavark, aardvark-dns) — via rustup.
|
||||
# -----------------------------------------------------------------------------
|
||||
if ! command -v cargo > /dev/null 2>&1; then
|
||||
echo "==> Rust/cargo not found, installing via rustup ($RUST_CHANNEL channel)"
|
||||
@@ -179,5 +213,34 @@ else
|
||||
echo "==> Rust already present: $(cargo --version)"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 8. protoc (netavark) — its build.rs (via the prost-build crate) shells
|
||||
# out to a `protoc` binary to compile .proto files; Slackware packages no
|
||||
# protobuf/protoc at all (checked: not in any of the main/extra/pasture/
|
||||
# testing repos). Official prebuilt release binary used instead of a
|
||||
# from-source C++ build. No `unzip` on this image either, so extract with
|
||||
# `python3 -m zipfile` (python3 is already installed, see step 0/1, for
|
||||
# crun's configure script) rather than adding yet another package.
|
||||
# -----------------------------------------------------------------------------
|
||||
if ! command -v protoc > /dev/null 2>&1; then
|
||||
echo "==> installing protoc v$PROTOC_VERSION"
|
||||
curl -fL --retry 3 -o "$WORK/protoc.zip" "$PROTOC_SRC_URL"
|
||||
actual=$(sha256sum "$WORK/protoc.zip" | awk '{print $1}')
|
||||
[ "$actual" = "$PROTOC_SRC_SHA256" ] || {
|
||||
echo "!! protoc checksum mismatch (expected $PROTOC_SRC_SHA256, got $actual)" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -rf /usr/local/protoc
|
||||
mkdir -p /usr/local/protoc
|
||||
python3 -m zipfile -e "$WORK/protoc.zip" /usr/local/protoc
|
||||
chmod +x /usr/local/protoc/bin/protoc
|
||||
export PATH="/usr/local/protoc/bin:$PATH"
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "/usr/local/protoc/bin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
else
|
||||
echo "==> protoc already present: $(protoc --version)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "==> Build environment ready."
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# Centralizing this logic means each individual SlackBuild only has to
|
||||
# describe *how to compile* its component — fetching, checksum verification,
|
||||
# and final .txz packaging are implemented once, here, and used the same way
|
||||
# by all seven packages. This is what keeps the seven build scripts
|
||||
# consistent and short instead of each reinventing (and potentially
|
||||
# by all ten packages (unraid-podman's own SlackBuild has no upstream
|
||||
# source to fetch, so it doesn't need this). This is what keeps the build
|
||||
# scripts consistent and short instead of each reinventing (and potentially
|
||||
# forgetting) checksum verification or Slackware package metadata.
|
||||
#
|
||||
# Every SlackBuild is expected to:
|
||||
@@ -68,7 +69,13 @@ sb_fetch_and_verify() {
|
||||
local dest_name="$3"
|
||||
local dest_path="$TMP/$dest_name"
|
||||
|
||||
echo "==> [$PRGNAM] Fetching $url"
|
||||
# All progress/diagnostic output here must go to stderr, not stdout:
|
||||
# callers capture this function's return value via `tarball=$(sb_fetch_and_verify ...)`,
|
||||
# and command substitution captures everything written to stdout — a
|
||||
# stray stdout echo above the final `echo "$dest_path"` would get
|
||||
# concatenated into that captured value instead of just printing to the
|
||||
# log.
|
||||
echo "==> [$PRGNAM] Fetching $url" >&2
|
||||
curl -fL --retry 3 --retry-delay 2 -o "$dest_path" "$url"
|
||||
|
||||
local actual_sha256
|
||||
@@ -84,7 +91,7 @@ sb_fetch_and_verify() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)"
|
||||
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)" >&2
|
||||
echo "$dest_path"
|
||||
}
|
||||
|
||||
@@ -166,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" )
|
||||
|
||||
|
||||
+20
-14
@@ -4,7 +4,7 @@
|
||||
#
|
||||
# Cuts a release of the plugin itself:
|
||||
# 1. Bumps the &version; entity in plugin/podman.plg to <new-version>.
|
||||
# 2. Builds all seven packages (scripts/build-packages.sh) unless
|
||||
# 2. Builds all eleven packages (scripts/build-packages.sh) unless
|
||||
# SKIP_BUILD=1 is set (useful when CI already built them in a prior job
|
||||
# and only wants this script to do the .plg/CHANGELOG bookkeeping).
|
||||
# 3. Verifies + consolidates checksums (scripts/checksums.sh).
|
||||
@@ -45,19 +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.
|
||||
# unraid-podman is the plugin's own scaffolding package (see
|
||||
# packages/unraid-podman/README.md), not an upstream component, but it's
|
||||
# released and entity-updated exactly like the other seven.
|
||||
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman)
|
||||
# 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 podman-compose unraid-podman)
|
||||
|
||||
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
|
||||
|
||||
@@ -140,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."
|
||||
|
||||
@@ -37,6 +37,31 @@ CRUN_VERSION="1.28"
|
||||
CRUN_SRC_URL="https://github.com/containers/crun/archive/refs/tags/${CRUN_VERSION}.tar.gz"
|
||||
CRUN_SRC_SHA256="90284c7f097f8ee72a6447978c263e1b1355727c2f2ca0ac667e6d57788f46f5"
|
||||
|
||||
# crun's build depends on the libocispec git submodule, which GitHub's
|
||||
# source archive (fetched above) never includes — a plain tarball has no
|
||||
# .git directory for `git submodule update` to work against, so that has
|
||||
# to be fetched as its own separate pinned source instead. Commit pinned
|
||||
# to exactly what crun 1.28 references (verified via
|
||||
# `curl https://api.github.com/repos/containers/crun/contents/libocispec?ref=1.28`);
|
||||
# re-derive it the same way whenever CRUN_VERSION changes.
|
||||
LIBOCISPEC_COMMIT="8034d0ecd27f646ba3ffae5ff24db234ce062825"
|
||||
LIBOCISPEC_SRC_URL="https://github.com/containers/libocispec/archive/${LIBOCISPEC_COMMIT}.tar.gz"
|
||||
LIBOCISPEC_SRC_SHA256="3e9170e54ddf487dc087ff1b88d0722e134a206cf36bba87cc946819ccf036ab"
|
||||
|
||||
# libocispec itself has two more git submodules (same problem, one level
|
||||
# deeper) — its own generate.py needs both schema trees present at build
|
||||
# time. Commits pinned to exactly what the LIBOCISPEC_COMMIT above
|
||||
# references (verified the same way, via
|
||||
# `curl https://api.github.com/repos/containers/libocispec/contents/image-spec?ref=$LIBOCISPEC_COMMIT`
|
||||
# and .../runtime-spec?ref=...).
|
||||
IMAGE_SPEC_COMMIT="26647a49f642c7d22a1cd3aa0a48e4650a542269"
|
||||
IMAGE_SPEC_SRC_URL="https://github.com/opencontainers/image-spec/archive/${IMAGE_SPEC_COMMIT}.tar.gz"
|
||||
IMAGE_SPEC_SRC_SHA256="8668357de6a1162220b2d1fb654a4182a55844b90ad2774c3b99640eec7e2f54"
|
||||
|
||||
RUNTIME_SPEC_COMMIT="d64c1d945da7cf6970061c7c9ff4391fafdf2a15"
|
||||
RUNTIME_SPEC_SRC_URL="https://github.com/opencontainers/runtime-spec/archive/${RUNTIME_SPEC_COMMIT}.tar.gz"
|
||||
RUNTIME_SPEC_SRC_SHA256="1698ebaa7ff07f8409c084fe9539d0391820e71b7a6e6d877aa1ce8b383a4b50"
|
||||
|
||||
# --- netavark --------------------------------------------------------------
|
||||
# https://github.com/containers/netavark
|
||||
NETAVARK_VERSION="2.0.0"
|
||||
@@ -68,6 +93,64 @@ PASST_VERSION="git${PASST_COMMIT:0:7}"
|
||||
PASST_SRC_URL="https://passt.top/passt/snapshot/passt-${PASST_COMMIT}.tar.gz"
|
||||
PASST_SRC_SHA256="4c58a77504a77d613464dddf22ae69d749a5ba64cb87e44c3b8c252333e209fc"
|
||||
|
||||
# --- catatonit -----------------------------------------------------------
|
||||
# https://github.com/openSUSE/catatonit — the init process podman runs
|
||||
# inside every pod's infra container to reap zombies; not built by
|
||||
# podman's own Makefile, not packaged by Slackware, needed at runtime on
|
||||
# every target Unraid install (found by live-testing `podman pod create`
|
||||
# against a real install: "finding catatonit binary: exec: catatonit: no
|
||||
# such file or directory"). Upstream publishes a prebuilt static
|
||||
# (non-dynamic-linked) x86_64 binary release asset — no from-source build
|
||||
# needed, no runtime library surprises like the crun/yajl chain had.
|
||||
CATATONIT_VERSION="0.2.1"
|
||||
CATATONIT_SRC_URL="https://github.com/openSUSE/catatonit/releases/download/v${CATATONIT_VERSION}/catatonit.x86_64"
|
||||
CATATONIT_SRC_SHA256="8293951eaa7767fa411e3b89777bd01bc5e56db9ba6d145ad10cc4d05b01e961"
|
||||
|
||||
# --- nftables --------------------------------------------------------------
|
||||
# netavark >= 2.0 dropped its iptables firewall driver entirely (see
|
||||
# config/containers.conf and docs/ARCHITECTURE.md section 8) — nftables is
|
||||
# now the only viable firewall backend on Unraid (no systemd/dbus for
|
||||
# firewalld), but Unraid OS ships no `nft` binary. Slackware — Unraid's own
|
||||
# base distro — already builds and ships this as an official package, so
|
||||
# it is vendored through as-is (verified working live) rather than
|
||||
# rebuilt from source: nftables pulls in its own dependency chain
|
||||
# (libmnl, libnftnl, gmp, ...) for no benefit over the distro's own build,
|
||||
# which is already correctly built for this exact environment.
|
||||
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"
|
||||
|
||||
# --- 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 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
|
||||
# package must be rebuilt without an upstream version change (e.g. a
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Dynamix-style WebUI pages, following Unraid's plugin GUI convention of
|
||||
`/usr/local/emhttp/plugins/<name>/`. `webui/plugins/podman/` is staged to
|
||||
that path by the `unraid-podman` scaffolding package (see
|
||||
`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs
|
||||
alongside the seven compiled components.
|
||||
alongside the other ten packages.
|
||||
|
||||
**Status: implemented**, covering all ten sections from
|
||||
[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Menu="Podman"
|
||||
Menu="Tasks:66"
|
||||
Type="xmenu"
|
||||
Tabs="false"
|
||||
Title="Podman"
|
||||
Icon="podman"
|
||||
---
|
||||
@@ -16,8 +18,37 @@ Icon="podman"
|
||||
* page's markup mirrors (same structure, same CSS classes, real data
|
||||
* instead of static samples).
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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 = '/usr/local/emhttp/plugins/podman' . $relPath;
|
||||
return is_file($full) ? (string) filemtime($full) : '0';
|
||||
}
|
||||
?>
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css?v=<?=podman_asset_version('/styles/podman.css')?>">
|
||||
|
||||
<div class="podman-plugin">
|
||||
|
||||
@@ -39,6 +70,7 @@ Icon="podman"
|
||||
<nav class="podman-subnav">
|
||||
<button class="active" data-panel="dashboard">Dashboard</button>
|
||||
<button data-panel="containers">Containers</button>
|
||||
<button data-panel="templates">Templates</button>
|
||||
<button data-panel="pods">Pods</button>
|
||||
<button data-panel="images">Images</button>
|
||||
<button data-panel="volumes">Volumes</button>
|
||||
@@ -68,11 +100,14 @@ Icon="podman"
|
||||
<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">
|
||||
<table>
|
||||
@@ -83,6 +118,9 @@ Icon="podman"
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= TEMPLATES ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-templates"></section>
|
||||
|
||||
<!-- ============================= PODS ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-pods"></section>
|
||||
|
||||
@@ -91,6 +129,7 @@ Icon="podman"
|
||||
<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">⬇ Pull Image</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
@@ -145,7 +184,7 @@ Icon="podman"
|
||||
<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>
|
||||
@@ -159,12 +198,22 @@ Icon="podman"
|
||||
<!-- ============================= 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">▶ Open Terminal</button>
|
||||
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>■ 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>
|
||||
@@ -173,15 +222,22 @@ Icon="podman"
|
||||
<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">⬇ Pull</button>
|
||||
<button class="podman-btn" id="compose-action-down">■ Down</button>
|
||||
<button class="podman-btn podman-btn-primary" id="compose-action-up">▶ 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>
|
||||
@@ -189,9 +245,32 @@ Icon="podman"
|
||||
|
||||
<!-- ============================= 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">▶ Start Podman</button>
|
||||
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="settings-service-stop-btn">■ Stop Podman</button>
|
||||
<button class="podman-btn" id="settings-service-restart-btn">↻ 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>
|
||||
@@ -200,41 +279,60 @@ Icon="podman"
|
||||
</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 & 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 & 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>
|
||||
@@ -243,14 +341,12 @@ Icon="podman"
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/plugins/podman/javascript/app.js"></script>
|
||||
<script src="/plugins/podman/javascript/dashboard.js"></script>
|
||||
<script src="/plugins/podman/javascript/containers.js"></script>
|
||||
<script src="/plugins/podman/javascript/pods.js"></script>
|
||||
<script src="/plugins/podman/javascript/images.js"></script>
|
||||
<script src="/plugins/podman/javascript/volumes.js"></script>
|
||||
<script src="/plugins/podman/javascript/networks.js"></script>
|
||||
<script src="/plugins/podman/javascript/logs.js"></script>
|
||||
<script src="/plugins/podman/javascript/terminal.js"></script>
|
||||
<script src="/plugins/podman/javascript/compose.js"></script>
|
||||
<script src="/plugins/podman/javascript/settings.js"></script>
|
||||
<?php
|
||||
foreach ([
|
||||
'app', 'dashboard', 'containers', 'templates', 'pods', 'images', 'volumes',
|
||||
'networks', 'logs', 'terminal', 'compose', 'settings',
|
||||
] as $podmanJsModule) {
|
||||
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
|
||||
echo '<script src="/plugins/podman' . $podmanJsPath . '?v=' . podman_asset_version($podmanJsPath) . '"></script>' . "\n";
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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;
|
||||
@@ -121,8 +132,92 @@ function compose_status(string $composeDir, string $project): string
|
||||
if ($result['exitCode'] !== 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
$decoded = json_decode($result['output'], true);
|
||||
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
|
||||
// `podman compose ps --format json` emits one JSON object PER LINE
|
||||
// (JSONL), not a single JSON array — decoding the whole blob in one
|
||||
// json_decode() call fails silently (-> null) as soon as a project has
|
||||
// more than one service (verified live with a 2-service project).
|
||||
// stdout only, too: the "external compose provider" banner goes to
|
||||
// stderr and would otherwise corrupt this either way.
|
||||
$running = 0;
|
||||
foreach (explode("\n", trim($result['stdout'])) as $line) {
|
||||
if (trim($line) !== '' && is_array(json_decode($line, true))) {
|
||||
$running++;
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -155,17 +250,17 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
|
||||
* surface even though $project has already been validated above too).
|
||||
*
|
||||
* @param array<int,string> $subcommand
|
||||
* @return array{exitCode:int,output:string}
|
||||
* @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']];
|
||||
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
|
||||
if (!is_resource($process)) {
|
||||
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
|
||||
return ['exitCode' => 127, 'stdout' => '', 'output' => 'Could not start podman compose process'];
|
||||
}
|
||||
|
||||
stream_set_timeout($pipes[1], $timeoutSeconds);
|
||||
@@ -175,5 +270,13 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
|
||||
// 'stdout' (raw) for callers that need to parse machine-readable
|
||||
// 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)];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,20 @@
|
||||
* stop POST {"id": "...", "timeout": 10}
|
||||
* restart POST {"id": "...", "timeout": 10}
|
||||
* remove POST {"id": "...", "force": false}
|
||||
* pause POST {"id": "..."}
|
||||
* unpause POST {"id": "..."}
|
||||
* 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", "pod": "<existing-pod-name>",
|
||||
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
|
||||
* "privileged": false, "startAfterCreate": true}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -68,10 +81,311 @@ switch ($action) {
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
case 'pause':
|
||||
$body = podman_read_json_body();
|
||||
$client->pauseContainer(require_id($body));
|
||||
podman_json_response(['status' => 'paused']);
|
||||
break;
|
||||
|
||||
case 'unpause':
|
||||
$body = podman_read_json_body();
|
||||
$client->unpauseContainer(require_id($body));
|
||||
podman_json_response(['status' => 'unpaused']);
|
||||
break;
|
||||
|
||||
case 'kill':
|
||||
$body = podman_read_json_body();
|
||||
$client->killContainer(require_id($body), (string) ($body['signal'] ?? 'SIGKILL'));
|
||||
podman_json_response(['status' => 'killed']);
|
||||
break;
|
||||
|
||||
case 'rename':
|
||||
$body = podman_read_json_body();
|
||||
$newName = trim((string) ($body['name'] ?? ''));
|
||||
if ($newName === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
$client->renameContainer(require_id($body), $newName);
|
||||
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'] ?? ''));
|
||||
if ($image === '') {
|
||||
podman_json_error('Missing image in request body', 400);
|
||||
}
|
||||
$spec = build_container_spec($image, $body);
|
||||
// Unlike `podman run`, /containers/create does NOT auto-pull a
|
||||
// missing image — it fails outright with a 404 "no such image"
|
||||
// (found by live-testing the Create Container form against a
|
||||
// freshly-typed image reference that wasn't pulled yet). Retry
|
||||
// once after an explicit pull rather than always pulling
|
||||
// up-front, so re-creating with an image the user already has
|
||||
// stays fast and offline-friendly.
|
||||
try {
|
||||
$id = $client->createContainer($spec);
|
||||
} catch (PodmanApiException $e) {
|
||||
if ($e->httpStatus !== 404) {
|
||||
throw $e;
|
||||
}
|
||||
$client->pullImage($image);
|
||||
$id = $client->createContainer($spec);
|
||||
}
|
||||
if ($body['startAfterCreate'] ?? true) {
|
||||
$client->startContainer($id);
|
||||
}
|
||||
podman_json_response(['id' => $id, 'status' => ($body['startAfterCreate'] ?? true) ? 'started' : 'created']);
|
||||
break;
|
||||
|
||||
default:
|
||||
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
|
||||
* (portmappings, netns, networks, mounts, volumes, restart_policy) were
|
||||
* verified live against a real podman system service — see
|
||||
* PodmanClient::createContainer()'s header comment.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function build_container_spec(string $image, array $body): array
|
||||
{
|
||||
$spec = ['image' => $image];
|
||||
|
||||
$name = trim((string) ($body['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
// Same character set podman itself enforces (define.NameRegex in
|
||||
// libpod) — validated here too so a space/invalid character gets
|
||||
// a clear message instead of podman's raw "running container
|
||||
// create option: names must match ...: invalid argument" (found
|
||||
// live: a template-derived container name with a space in it hit
|
||||
// exactly this).
|
||||
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
|
||||
podman_json_error(
|
||||
"Container name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
|
||||
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
|
||||
400
|
||||
);
|
||||
}
|
||||
$spec['name'] = $name;
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach (($body['env'] ?? []) as $row) {
|
||||
$key = trim((string) ($row['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$env[$key] = (string) ($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($env !== []) {
|
||||
$spec['env'] = $env;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$mounts = [];
|
||||
$volumes = [];
|
||||
foreach (($body['volumes'] ?? []) as $row) {
|
||||
$source = trim((string) ($row['source'] ?? ''));
|
||||
$containerPath = trim((string) ($row['containerPath'] ?? ''));
|
||||
if ($source === '' || $containerPath === '') {
|
||||
continue;
|
||||
}
|
||||
if (($row['kind'] ?? 'named') === 'path') {
|
||||
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => ['rbind']];
|
||||
} else {
|
||||
$volumes[] = ['name' => $source, 'dest' => $containerPath];
|
||||
}
|
||||
}
|
||||
if ($mounts !== []) {
|
||||
$spec['mounts'] = $mounts;
|
||||
}
|
||||
if ($volumes !== []) {
|
||||
$spec['volumes'] = $volumes;
|
||||
}
|
||||
|
||||
// "bridge"/"host"/"none" are podman's own reserved netns modes; any
|
||||
// other value is an existing custom podman network's name, attached
|
||||
// via the "networks" field instead (verified live: passing a
|
||||
// network name through "networks" attaches it without needing an
|
||||
// explicit netns mode at all).
|
||||
$networkMode = (string) ($body['networkMode'] ?? 'bridge');
|
||||
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
|
||||
$spec['netns'] = ['nsmode' => $networkMode];
|
||||
} elseif ($networkMode !== '') {
|
||||
// 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'] !== '') {
|
||||
$spec['restart_policy'] = (string) $body['restartPolicy'];
|
||||
}
|
||||
if ($body['privileged'] ?? false) {
|
||||
$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;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_id(array $body): string
|
||||
{
|
||||
@@ -96,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'] ?? ''));
|
||||
|
||||
@@ -111,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'] ?? '')),
|
||||
@@ -124,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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -59,7 +59,10 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
||||
return [
|
||||
'reachable' => true,
|
||||
'socketPath' => $config->socketPath,
|
||||
'podmanVersion' => $info['Version']['Version'] ?? null,
|
||||
// libpod's /info nests the version block under lowercase "version"
|
||||
// (unlike most other libpod endpoints, which are PascalCase
|
||||
// throughout) — verified live against a real podman system service.
|
||||
'podmanVersion' => $info['version']['Version'] ?? null,
|
||||
'containers' => [
|
||||
'total' => count($containers),
|
||||
'running' => $running,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
/**
|
||||
* ajax/templates.php
|
||||
*
|
||||
* Backs the Templates panel — reusable container configs, saved and
|
||||
* loaded as XML in the same schema Unraid's own Docker Manager uses for
|
||||
* its Community Applications templates (<Container version="2"> with
|
||||
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
|
||||
* schema, not a podman-specific one of our own: it's the one users and
|
||||
* template authors already know, and it means a template exported here
|
||||
* carries over the fields (image, ports, paths, variables, icon,
|
||||
* category, overview) a Docker template would too, even though the two
|
||||
* ecosystems' XML isn't fully interchangeable (this plugin's Config
|
||||
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
|
||||
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
|
||||
*
|
||||
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
|
||||
* reasoning as compose/autostart/networks (see ajax/compose.php).
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> [{name, image, icon, category, overview}, ...]
|
||||
* get GET (&name=...) -> full parsed config, for prefilling the
|
||||
* Create Container form ("Use template")
|
||||
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
|
||||
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
|
||||
* "overview": "...", "networkMode": "...", "privileged": false,
|
||||
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
|
||||
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
|
||||
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
|
||||
* dockerMan template directories (real existing Docker
|
||||
* templates the user already has — see
|
||||
* local_dockerman_templates_list()), for a "browse local
|
||||
* templates" picker instead of copy-pasting XML by hand
|
||||
* import_local POST {"file": "gitea.xml"} -> imports one by filename
|
||||
* (validated against the same directories list_local
|
||||
* scanned, never an arbitrary path from the client)
|
||||
* remove POST {"name": "..."}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../include/bootstrap.php';
|
||||
|
||||
$templatesDir = $podmanConfig->bootDir . '/templates';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
podman_json_response(templates_list($templatesDir));
|
||||
break;
|
||||
|
||||
case 'get':
|
||||
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
|
||||
break;
|
||||
|
||||
case 'export':
|
||||
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
|
||||
podman_json_response(['xml' => file_get_contents($path)]);
|
||||
break;
|
||||
|
||||
case 'save':
|
||||
$body = podman_read_json_body();
|
||||
$name = trim((string) ($body['name'] ?? ''));
|
||||
if (!is_valid_template_name($name)) {
|
||||
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
|
||||
}
|
||||
template_write($templatesDir, $name, $body);
|
||||
podman_json_response(['status' => 'saved', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'import':
|
||||
$body = podman_read_json_body();
|
||||
$xml = (string) ($body['xml'] ?? '');
|
||||
if (trim($xml) === '') {
|
||||
podman_json_error('Missing xml in request body', 400);
|
||||
}
|
||||
$name = template_import($templatesDir, $xml);
|
||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'list_local':
|
||||
podman_json_response(local_dockerman_templates_list());
|
||||
break;
|
||||
|
||||
case 'import_local':
|
||||
$body = podman_read_json_body();
|
||||
$file = (string) ($body['file'] ?? '');
|
||||
$name = template_import($templatesDir, local_dockerman_template_read($file));
|
||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
$body = podman_read_json_body();
|
||||
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
|
||||
unlink($path);
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template names become filenames — restricted to a fixed safe character
|
||||
* set BEFORE ever being used to build a filesystem path, the same
|
||||
* pattern ajax/compose.php's is_valid_project_name() uses for the same
|
||||
* reason.
|
||||
*/
|
||||
function is_valid_template_name(string $name): bool
|
||||
{
|
||||
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
|
||||
}
|
||||
|
||||
function require_template_path(string $templatesDir, string $name): string
|
||||
{
|
||||
if (!is_valid_template_name($name)) {
|
||||
podman_json_error('Invalid template name', 400);
|
||||
}
|
||||
$path = $templatesDir . '/' . $name . '.xml';
|
||||
if (!is_file($path)) {
|
||||
podman_json_error("Template '{$name}' not found", 404);
|
||||
}
|
||||
return $path;
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
function templates_list(string $templatesDir): array
|
||||
{
|
||||
if (!is_dir($templatesDir)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach (scandir($templatesDir) ?: [] as $entry) {
|
||||
if (!str_ends_with($entry, '.xml')) {
|
||||
continue;
|
||||
}
|
||||
$name = substr($entry, 0, -4);
|
||||
if (!is_valid_template_name($name)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$parsed = template_read($templatesDir . '/' . $entry);
|
||||
} catch (\Throwable $e) {
|
||||
continue; // a hand-edited/corrupt file shouldn't break the whole list
|
||||
}
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'image' => $parsed['image'],
|
||||
'icon' => $parsed['icon'],
|
||||
'category' => $parsed['category'],
|
||||
'overview' => $parsed['overview'],
|
||||
];
|
||||
}
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses one template XML file into the same shape
|
||||
* ajax/containers.php's build_container_spec() (Create Container form)
|
||||
* consumes, so "Use template" can feed straight into that form/action.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function template_read(string $path): array
|
||||
{
|
||||
$xml = @simplexml_load_file($path);
|
||||
if ($xml === false) {
|
||||
podman_json_error("Could not parse template XML: {$path}", 500);
|
||||
}
|
||||
|
||||
$ports = [];
|
||||
$volumes = [];
|
||||
$env = [];
|
||||
foreach ($xml->Config as $cfg) {
|
||||
$attrs = $cfg->attributes();
|
||||
$type = (string) ($attrs['Type'] ?? '');
|
||||
$value = trim((string) $cfg);
|
||||
$target = (string) ($attrs['Target'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
switch ($type) {
|
||||
case 'Port':
|
||||
$ports[] = [
|
||||
'hostPort' => (int) $value,
|
||||
'containerPort' => (int) ($target !== '' ? $target : $value),
|
||||
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
|
||||
];
|
||||
break;
|
||||
case 'Path':
|
||||
$volumes[] = [
|
||||
'kind' => 'path',
|
||||
'source' => $value,
|
||||
'containerPath' => $target !== '' ? $target : $value,
|
||||
];
|
||||
break;
|
||||
case 'Variable':
|
||||
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'image' => (string) $xml->Repository,
|
||||
'networkMode' => (string) ($xml->Network ?: 'bridge'),
|
||||
'privileged' => strtolower((string) $xml->Privileged) === 'true',
|
||||
'icon' => (string) $xml->Icon,
|
||||
'category' => (string) $xml->Category,
|
||||
'overview' => (string) $xml->Overview,
|
||||
'ports' => $ports,
|
||||
'volumes' => $volumes,
|
||||
'env' => $env,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a template XML file from the Create Container form's field
|
||||
* shapes (same as build_container_spec() in ajax/containers.php takes)
|
||||
* plus template-only metadata (icon/category/overview). Uses DOMDocument
|
||||
* rather than string concatenation so every value is properly escaped —
|
||||
* no risk of a "<" or "&" in an image name/description breaking the XML.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*/
|
||||
function template_write(string $templatesDir, string $name, array $body): void
|
||||
{
|
||||
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
||||
podman_json_error("Could not create {$templatesDir}", 500);
|
||||
}
|
||||
|
||||
$doc = new \DOMDocument('1.0');
|
||||
$doc->formatOutput = true;
|
||||
$root = $doc->createElement('Container');
|
||||
$root->setAttribute('version', '2');
|
||||
$doc->appendChild($root);
|
||||
|
||||
$append = static function (string $tag, string $value) use ($doc, $root): void {
|
||||
$root->appendChild($doc->createElement($tag, $value));
|
||||
};
|
||||
$append('Name', $name);
|
||||
$append('Repository', trim((string) ($body['image'] ?? '')));
|
||||
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
|
||||
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
|
||||
$append('Overview', (string) ($body['overview'] ?? ''));
|
||||
$append('Category', (string) ($body['category'] ?? ''));
|
||||
$append('Icon', (string) ($body['icon'] ?? ''));
|
||||
|
||||
foreach (($body['ports'] ?? []) as $row) {
|
||||
$hostPort = (string) ($row['hostPort'] ?? '');
|
||||
$containerPort = (string) ($row['containerPort'] ?? '');
|
||||
if ($hostPort === '' || $containerPort === '') {
|
||||
continue;
|
||||
}
|
||||
$cfg = $doc->createElement('Config', $hostPort);
|
||||
$cfg->setAttribute('Name', 'Port ' . $containerPort);
|
||||
$cfg->setAttribute('Target', $containerPort);
|
||||
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
|
||||
$cfg->setAttribute('Type', 'Port');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
foreach (($body['volumes'] ?? []) as $row) {
|
||||
$source = (string) ($row['source'] ?? '');
|
||||
$containerPath = (string) ($row['containerPath'] ?? '');
|
||||
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
|
||||
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
|
||||
}
|
||||
$cfg = $doc->createElement('Config', $source);
|
||||
$cfg->setAttribute('Name', basename($containerPath));
|
||||
$cfg->setAttribute('Target', $containerPath);
|
||||
$cfg->setAttribute('Mode', 'rw');
|
||||
$cfg->setAttribute('Type', 'Path');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
foreach (($body['env'] ?? []) as $row) {
|
||||
$key = (string) ($row['key'] ?? '');
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
|
||||
$cfg->setAttribute('Name', $key);
|
||||
$cfg->setAttribute('Target', $key);
|
||||
$cfg->setAttribute('Type', 'Variable');
|
||||
$root->appendChild($cfg);
|
||||
}
|
||||
|
||||
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
|
||||
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a pasted/uploaded template XML (this plugin's own schema, or
|
||||
* a plain Unraid dockerMan template — both use the same <Container>/
|
||||
* <Config> shape). Validates it parses and has a usable <Name> before
|
||||
* writing it under our own templates dir with a sanitized filename.
|
||||
*/
|
||||
function template_import(string $templatesDir, string $xmlText): string
|
||||
{
|
||||
$xml = @simplexml_load_string($xmlText);
|
||||
if ($xml === false || $xml->getName() !== 'Container') {
|
||||
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
|
||||
}
|
||||
|
||||
$rawName = trim((string) $xml->Name);
|
||||
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
|
||||
if (!is_valid_template_name((string) $name)) {
|
||||
podman_json_error('Template XML has no usable <Name>', 400);
|
||||
}
|
||||
|
||||
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
|
||||
podman_json_error("Could not create {$templatesDir}", 500);
|
||||
}
|
||||
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
|
||||
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
|
||||
}
|
||||
return (string) $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unraid's own Docker Manager plugin stores every template a user has
|
||||
* ever saved/customized under templates-user/, plus a local cache of
|
||||
* Community Applications' own catalog under templates-community/ (may
|
||||
* be empty/absent if CA was never installed) — both in the same
|
||||
* <Container>/<Config> XML shape this plugin reads. Read-only: this
|
||||
* plugin never writes into dockerMan's own directories.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
function dockerman_template_dirs(): array
|
||||
{
|
||||
return array_values(array_filter([
|
||||
'/boot/config/plugins/dockerMan/templates-user',
|
||||
'/boot/config/plugins/dockerMan/templates-community',
|
||||
], 'is_dir'));
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,string>> */
|
||||
function local_dockerman_templates_list(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (dockerman_template_dirs() as $dir) {
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if (!str_ends_with($entry, '.xml')) {
|
||||
continue;
|
||||
}
|
||||
$xml = @simplexml_load_file($dir . '/' . $entry);
|
||||
if ($xml === false) {
|
||||
continue; // skip anything that doesn't parse rather than failing the whole list
|
||||
}
|
||||
$out[] = [
|
||||
'file' => $entry,
|
||||
'name' => (string) $xml->Name,
|
||||
'image' => (string) $xml->Repository,
|
||||
'icon' => (string) $xml->Icon,
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one local dockerMan template's raw XML by filename. $file comes
|
||||
* straight from client input — reduced to its basename and required to
|
||||
* actually exist in one of dockerman_template_dirs() (the same list
|
||||
* list_local scanned), never treated as an arbitrary path.
|
||||
*/
|
||||
function local_dockerman_template_read(string $file): string
|
||||
{
|
||||
$file = basename($file);
|
||||
if (!str_ends_with($file, '.xml')) {
|
||||
podman_json_error('Invalid template file', 400);
|
||||
}
|
||||
foreach (dockerman_template_dirs() as $dir) {
|
||||
$path = $dir . '/' . $file;
|
||||
if (is_file($path)) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content !== false) {
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
}
|
||||
podman_json_error("Local template '{$file}' not found", 404);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized volume list, with usedBy counts
|
||||
* create POST {"name": "...", "driver": "local"}
|
||||
* create POST {"name": "...", "driver": "local", "path": "/mnt/cache/..." (optional)}
|
||||
* remove POST {"name": "...", "force": false}
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,11 @@ switch ($action) {
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
|
||||
$path = trim((string) ($body['path'] ?? ''));
|
||||
if ($path !== '' && !str_starts_with($path, '/')) {
|
||||
podman_json_error("Host path ({$path}) must be an absolute path.", 400);
|
||||
}
|
||||
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local'), $path !== '' ? $path : null));
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
@@ -65,10 +69,19 @@ function volumes_list(PodmanClient $client): array
|
||||
$out = [];
|
||||
foreach ($raw as $v) {
|
||||
$name = (string) ($v['Name'] ?? '');
|
||||
$options = $v['Options'] ?? [];
|
||||
// A volume created with our "Host path" field carries
|
||||
// type=none,o=bind,device=<path> (see PodmanClient::createVolume)
|
||||
// — surfaced separately from 'mountpoint' (podman's own internal
|
||||
// storage path, which stays populated even for bind-backed
|
||||
// volumes) so the UI can show users the host path they actually
|
||||
// asked for.
|
||||
$hostPath = (is_array($options) && ($options['o'] ?? '') === 'bind') ? (string) ($options['device'] ?? '') : null;
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'driver' => (string) ($v['Driver'] ?? 'local'),
|
||||
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
|
||||
'hostPath' => $hostPath,
|
||||
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
|
||||
'usedBy' => $usageCounts[$name] ?? 0,
|
||||
];
|
||||
|
||||
@@ -101,6 +101,31 @@ final class PodmanClient
|
||||
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /containers/create — takes a libpod SpecGenerator body. Field
|
||||
* names/shapes below (image, name, command, env, portmappings,
|
||||
* netns, networks, mounts, volumes, restart_policy, privileged) were
|
||||
* verified live against a real podman system service, not assumed
|
||||
* from docs — see ajax/containers.php's create action, which builds
|
||||
* this array from the WebUI's Create Container form.
|
||||
*
|
||||
* @param array<string,mixed> $spec
|
||||
* @return string the new container's ID
|
||||
*/
|
||||
public function createContainer(array $spec): string
|
||||
{
|
||||
// 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'] ?? '');
|
||||
}
|
||||
|
||||
public function startContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
|
||||
@@ -121,6 +146,27 @@ final class PodmanClient
|
||||
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
||||
}
|
||||
|
||||
public function pauseContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/pause', [], true);
|
||||
}
|
||||
|
||||
public function unpauseContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/unpause', [], true);
|
||||
}
|
||||
|
||||
/** $signal accepts both a name ("SIGKILL") and a bare number, matching libpod's own `kill?signal=` parsing. */
|
||||
public function killContainer(string $id, string $signal = 'SIGKILL'): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/kill', ['signal' => $signal], true);
|
||||
}
|
||||
|
||||
public function renameContainer(string $id, string $newName): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/rename', ['name' => $newName], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
|
||||
* where possible) log text. Podman's non-TTY log stream uses the same
|
||||
@@ -147,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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -196,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);
|
||||
@@ -206,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);
|
||||
@@ -220,10 +252,61 @@ final class PodmanClient
|
||||
return $this->request('GET', '/images/json');
|
||||
}
|
||||
|
||||
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
|
||||
/**
|
||||
* POST /images/pull — pulls (or updates) an image by reference, e.g.
|
||||
* "docker.io/library/postgres:16".
|
||||
*
|
||||
* Unlike virtually every other libpod endpoint, a successful pull's
|
||||
* response body is NOT one JSON document — it's newline-delimited
|
||||
* JSON, one progress object per line (verified live:
|
||||
* `{"status":"pulling","stream":"..."}` repeated, then a final
|
||||
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
|
||||
* that whole blob through the normal single-document request() here
|
||||
* made json_decode() fail on every successful pull with "Expected a
|
||||
* JSON object/array response from /images/pull" — found by
|
||||
* live-testing a real pull through the WebUI's Images panel, not
|
||||
* from reading libpod's docs. An error that happens before any
|
||||
* image data is found (e.g. unknown reference) is unaffected: libpod
|
||||
* sends that as a normal single-JSON-object 4xx response, which
|
||||
* requestRaw()/request()'s existing status>=400 handling already
|
||||
* covers correctly.
|
||||
*/
|
||||
public function pullImage(string $reference): array
|
||||
{
|
||||
return $this->request('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) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$decoded = json_decode($line, true);
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
// A mid-stream error (pull started, then failed — e.g. the
|
||||
// connection dropped partway through a layer) is reported as
|
||||
// an {"error": "..."} line rather than an HTTP error status,
|
||||
// since headers/status are already committed by the time
|
||||
// libpod knows the pull failed.
|
||||
if (isset($decoded['error'])) {
|
||||
throw new PodmanApiException((string) $decoded['error'], 502);
|
||||
}
|
||||
$last = $decoded;
|
||||
}
|
||||
|
||||
if ($last === null) {
|
||||
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
|
||||
}
|
||||
return $last;
|
||||
}
|
||||
|
||||
public function removeImage(string $id, bool $force = false): void
|
||||
@@ -231,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
|
||||
// -------------------------------------------------------------------
|
||||
@@ -240,9 +345,21 @@ final class PodmanClient
|
||||
return $this->request('GET', '/volumes/json');
|
||||
}
|
||||
|
||||
public function createVolume(string $name, string $driver = 'local'): array
|
||||
/**
|
||||
* $hostPath, if given, binds the volume directly to an existing host
|
||||
* directory instead of a podman-managed one — the local driver's
|
||||
* `type=none,o=bind,device=<path>` option trio (same mechanism
|
||||
* `podman volume create --opt type=none --opt o=bind --opt device=...`
|
||||
* uses on the CLI). Verified live: a container mounting such a volume
|
||||
* reads/writes the host path directly, not an internal copy.
|
||||
*/
|
||||
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
|
||||
{
|
||||
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
|
||||
$body = ['Name' => $name, 'Driver' => $driver];
|
||||
if ($hostPath !== null && $hostPath !== '') {
|
||||
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
|
||||
}
|
||||
return $this->request('POST', '/volumes/create', [], false, $body);
|
||||
}
|
||||
|
||||
public function removeVolume(string $name, bool $force = false): void
|
||||
@@ -259,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);
|
||||
}
|
||||
|
||||
@@ -285,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 [];
|
||||
}
|
||||
@@ -306,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)) {
|
||||
@@ -319,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) {
|
||||
|
||||
@@ -42,13 +42,25 @@ function podman_format_duration(int $seconds): string
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
|
||||
function podman_parse_time(?string $rfc3339): ?int
|
||||
/**
|
||||
* Converts a libpod timestamp to a Unix timestamp, or null if unparsable.
|
||||
* Accepts both the RFC3339 strings `inspect`-style endpoints return and
|
||||
* the raw Unix-epoch integers `list`-style endpoints return for the same
|
||||
* logical field (e.g. containers/json's StartedAt/Created vs. inspect's)
|
||||
* — verified live against a real podman system service, not just docs.
|
||||
*/
|
||||
function podman_parse_time(string|int|null $value): ?int
|
||||
{
|
||||
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime($rfc3339);
|
||||
if (is_int($value)) {
|
||||
return $value > 0 ? $value : null;
|
||||
}
|
||||
if (str_starts_with($value, '0001-01-01')) {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime($value);
|
||||
return $ts === false ? null : $ts;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ window.Podman = (function () {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
|
||||
// every PHP request, not something this plugin controls) kills any
|
||||
// POST request with no output at all unless it carries the page's
|
||||
// CSRF token — either as a "csrf_token" POST field or this header.
|
||||
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
|
||||
// Unraid page before plugin JS loads (verified live: without this
|
||||
// header, every mutating action failed with "JSON.parse: unexpected
|
||||
// end of data", i.e. an empty response body from csrf_terminate()).
|
||||
if (method === 'POST' && typeof window.csrf_token === 'string') {
|
||||
opts.headers['X-CSRF-Token'] = window.csrf_token;
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
.then(function (res) {
|
||||
@@ -115,6 +126,229 @@ window.Podman = (function () {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Modal form dialog -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows a small form modal in place of browser-native prompt()/confirm()
|
||||
* — needed for any action that takes more than one related value (e.g.
|
||||
* "New Volume" wants a name AND an optional host path together; chaining
|
||||
* prompt() calls for that is both bad UX and can't show both fields at
|
||||
* once, or offer a hint under the path field explaining what it does).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title
|
||||
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
|
||||
* @param {string} [opts.submitLabel]
|
||||
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
|
||||
* Called with {fieldName: value}. Rejecting keeps the modal open and
|
||||
* shows the error inline; resolving closes it.
|
||||
*/
|
||||
function openFormModal(opts) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
|
||||
const fieldsHtml = opts.fields.map(function (f) {
|
||||
return '' +
|
||||
'<div class="podman-modal-field">' +
|
||||
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
|
||||
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
|
||||
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
|
||||
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
|
||||
'<form class="podman-modal-body">' + fieldsHtml + '</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">' +
|
||||
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
|
||||
'</div></div>';
|
||||
|
||||
// Appended inside .podman-plugin, not document.body: the --surface/
|
||||
// --border/etc. custom properties this modal's CSS relies on are
|
||||
// scoped to .podman-plugin (see podman.css's token strategy comment),
|
||||
// so a modal appended to body would resolve none of them — verified
|
||||
// live: the backdrop dimming and card background were both missing,
|
||||
// only the (inherited-from-body) text was visible. position:fixed
|
||||
// still overlays the full viewport regardless of this nesting.
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
|
||||
const firstInput = backdrop.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
|
||||
function close() {
|
||||
backdrop.remove();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const values = {};
|
||||
opts.fields.forEach(function (f) {
|
||||
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
|
||||
});
|
||||
for (const f of opts.fields) {
|
||||
if (f.required && !values[f.name]) {
|
||||
showError('"' + f.label + '" is required.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
showError(err.message || String(err));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* one icon button each. Only one menu is ever open at a time.
|
||||
*
|
||||
* @param {HTMLElement} anchorEl button the menu opens from/closes on
|
||||
* @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items
|
||||
*/
|
||||
let openMenuCloser = null;
|
||||
function openContextMenu(anchorEl, items) {
|
||||
if (openMenuCloser) {
|
||||
openMenuCloser();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'podman-context-menu';
|
||||
menu.innerHTML = items.map(function (item) {
|
||||
if (item === 'separator') return '<div class="podman-context-menu-sep"></div>';
|
||||
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
|
||||
(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 + 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 = buttons[buttonIndex];
|
||||
buttonIndex++;
|
||||
if (item.disabled) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
if (item.onClick) item.onClick();
|
||||
});
|
||||
});
|
||||
|
||||
function close() {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', onOutsideClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
openMenuCloser = null;
|
||||
}
|
||||
function onOutsideClick(e) {
|
||||
if (!menu.contains(e.target)) close();
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') close();
|
||||
}
|
||||
|
||||
openMenuCloser = close;
|
||||
// Deferred so the click that opened the menu doesn't immediately
|
||||
// trigger onOutsideClick via event bubbling.
|
||||
setTimeout(function () {
|
||||
document.addEventListener('click', onOutsideClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
@@ -180,6 +414,9 @@ window.Podman = (function () {
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
openFormModal: openFormModal,
|
||||
openLogModal: openLogModal,
|
||||
openContextMenu: openContextMenu,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,38 +10,429 @@
|
||||
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">—</span>';
|
||||
const updateBadge = hasUpdate(c)
|
||||
? ' <span class="podman-badge-update" title="A newer image is available">↑ 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><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></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>' + 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(', ') || '—') + '</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">↑</button>'
|
||||
: '';
|
||||
if (c.state === 'running') {
|
||||
return '' +
|
||||
return updateBtn +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
return '' +
|
||||
if (c.state === 'paused') {
|
||||
return updateBtn +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
return updateBtn +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
|
||||
function openRowMenu(c, anchorBtn) {
|
||||
const items = [];
|
||||
if (c.state === 'running') {
|
||||
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } });
|
||||
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',
|
||||
danger: true,
|
||||
disabled: c.state === 'running',
|
||||
onClick: function () { handleAction(c.id, 'remove'); },
|
||||
});
|
||||
P.openContextMenu(anchorBtn, items);
|
||||
}
|
||||
|
||||
function openRenameModal(c) {
|
||||
P.openFormModal({
|
||||
title: 'Rename Container',
|
||||
submitLabel: 'Rename',
|
||||
fields: [{ name: 'name', label: 'New name', required: true, placeholder: c.name }],
|
||||
onSubmit: function (values) {
|
||||
return P.post('containers', 'rename', { id: c.id, name: values.name }).then(load);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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) —
|
||||
// no new backend endpoint needed, just slicing that one payload into
|
||||
// tabs. Field names below (Config.Env, Config.Labels, Mounts,
|
||||
// NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked
|
||||
// live against a real inspect response, not assumed from docs.
|
||||
|
||||
function kvTable(rows) {
|
||||
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
|
||||
return '<table class="podman-detail-table">' + rows.map(function (r) {
|
||||
return '<tr><td>' + P.escapeHtml(r[0]) + '</td><td class="mono">' + P.escapeHtml(r[1]) + '</td></tr>';
|
||||
}).join('') + '</table>';
|
||||
}
|
||||
|
||||
function renderOverviewTab(d) {
|
||||
const cfg = d.Config || {};
|
||||
const hostCfg = d.HostConfig || {};
|
||||
return kvTable([
|
||||
['Name', (d.Name || '').replace(/^\//, '')],
|
||||
['ID', d.Id || ''],
|
||||
['Image', cfg.Image || d.Image || ''],
|
||||
['Created', d.Created || ''],
|
||||
['Command', (cfg.Cmd || []).join(' ') || (cfg.Entrypoint || []).join(' ') || '—'],
|
||||
['State', (d.State && d.State.Status) || '—'],
|
||||
['Restart policy', (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || '—'],
|
||||
['Restart count', String(d.RestartCount || 0)],
|
||||
['Privileged', hostCfg.Privileged ? 'yes' : 'no'],
|
||||
['Working dir', cfg.WorkingDir || '—'],
|
||||
]);
|
||||
}
|
||||
|
||||
function renderEnvTab(d) {
|
||||
const env = (d.Config && d.Config.Env) || [];
|
||||
return kvTable(env.map(function (line) {
|
||||
const idx = line.indexOf('=');
|
||||
return idx === -1 ? [line, ''] : [line.slice(0, idx), line.slice(idx + 1)];
|
||||
}));
|
||||
}
|
||||
|
||||
function renderLabelsTab(d) {
|
||||
const labels = (d.Config && d.Config.Labels) || {};
|
||||
return kvTable(Object.keys(labels).map(function (k) { return [k, labels[k]]; }));
|
||||
}
|
||||
|
||||
function renderMountsTab(d) {
|
||||
const mounts = d.Mounts || [];
|
||||
if (mounts.length === 0) return '<div class="podman-detail-empty">No mounts.</div>';
|
||||
return '<table class="podman-detail-table">' +
|
||||
'<tr><td>Type</td><td>Source → Destination</td></tr>' +
|
||||
mounts.map(function (m) {
|
||||
const mode = m.RW ? 'rw' : 'ro';
|
||||
return '<tr><td>' + P.escapeHtml(m.Type || '') + '</td><td class="mono">' +
|
||||
P.escapeHtml(m.Source || '') + ' → ' + P.escapeHtml(m.Destination || '') +
|
||||
' <span class="podman-row-sub">(' + mode + ')</span></td></tr>';
|
||||
}).join('') + '</table>';
|
||||
}
|
||||
|
||||
function renderNetworksTab(d) {
|
||||
const networks = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
|
||||
const names = Object.keys(networks);
|
||||
if (names.length === 0) return '<div class="podman-detail-empty">No networks (host or none mode).</div>';
|
||||
return names.map(function (name) {
|
||||
const n = networks[name];
|
||||
return '<div style="margin-bottom:14px;"><div style="font-weight:700; font-size:12.5px; margin-bottom:6px;">' +
|
||||
P.escapeHtml(name) + '</div>' + kvTable([
|
||||
['IP address', n.IPAddress || '—'],
|
||||
['Gateway', n.Gateway || '—'],
|
||||
['MAC address', n.MacAddress || '—'],
|
||||
['Aliases', (n.Aliases || []).join(', ') || '—'],
|
||||
]) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderInspectTab(d) {
|
||||
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
|
||||
}
|
||||
|
||||
const DETAIL_TABS = [
|
||||
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
|
||||
{ id: 'env', label: 'Environment', render: renderEnvTab },
|
||||
{ id: 'labels', label: 'Labels', render: renderLabelsTab },
|
||||
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
|
||||
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
|
||||
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
|
||||
];
|
||||
|
||||
function openDetailModal(c) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal podman-modal-xwide" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>' + P.escapeHtml(c.name) + '</h3></div>' +
|
||||
'<div class="podman-detail-tabs">' + DETAIL_TABS.map(function (t, i) {
|
||||
return '<button type="button" data-tab="' + t.id + '"' + (i === 0 ? ' class="active"' : '') + '>' + t.label + '</button>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="podman-detail-body"><div class="podman-loading">Loading…</div></div>' +
|
||||
'<div class="podman-modal-actions"><button type="button" class="podman-btn" data-role="cancel">Close</button></div>' +
|
||||
'</div>';
|
||||
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { backdrop.remove(); });
|
||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) backdrop.remove(); });
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
|
||||
});
|
||||
|
||||
const body = backdrop.querySelector('.podman-detail-body');
|
||||
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
|
||||
function showTab(tabId) {
|
||||
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
|
||||
body.innerHTML = tab.render(data);
|
||||
}
|
||||
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
backdrop.querySelectorAll('[data-tab]').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
showTab(btn.dataset.tab);
|
||||
});
|
||||
});
|
||||
showTab('overview');
|
||||
}).catch(function (err) {
|
||||
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
@@ -80,23 +471,392 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create Container -----------------------------------------------------
|
||||
//
|
||||
// Purpose-built modal (not app.js's generic openFormModal, which only
|
||||
// supports flat text fields) — port/volume/env rows are dynamic
|
||||
// add/remove groups, and network needs a <select> populated from the
|
||||
// real network list, none of which fits the generic helper. Reuses its
|
||||
// .podman-modal-* CSS classes for visual consistency.
|
||||
|
||||
function portRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
|
||||
'<span>→</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">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function volumeRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<select data-field="kind"><option value="named">Volume</option><option value="path">Host path</option></select>' +
|
||||
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
||||
'<span>→</span>' +
|
||||
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function envRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<input type="text" class="mono" data-field="key" placeholder="KEY">' +
|
||||
'<span>=</span>' +
|
||||
'<input type="text" class="mono" data-field="value" placeholder="value">' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function addRow(groupEl, rowHtmlFn, values) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = rowHtmlFn();
|
||||
const row = div.firstElementChild;
|
||||
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
||||
if (values) {
|
||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||
if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field];
|
||||
});
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|null} prefill Optional template data (same shape
|
||||
* 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, 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>' + (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>' +
|
||||
'<div class="podman-modal-field"><label>Name (optional)</label>' +
|
||||
'<input type="text" id="cc-name" placeholder="my-container">' +
|
||||
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
|
||||
'<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" 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 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>' +
|
||||
'<div class="podman-modal-field"><label>Environment variables</label>' +
|
||||
'<div class="podman-row-group" id="cc-env"></div>' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" data-add="env">+ Add variable</button></div>' +
|
||||
'<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>' +
|
||||
'<input type="checkbox" id="cc-start" checked> Start after create</label></div>' +
|
||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||
'<input type="checkbox" id="cc-save-template"> Save as template</label></div>' +
|
||||
'<div class="podman-modal-field" id="cc-template-fields" style="display:none;">' +
|
||||
'<label>Template name</label><input type="text" id="cc-template-name" placeholder="my-template">' +
|
||||
'<label style="margin-top:10px;">Icon URL (optional)</label><input type="text" id="cc-template-icon" placeholder="https://...">' +
|
||||
'<label style="margin-top:10px;">Category (optional)</label><input type="text" id="cc-template-category" placeholder="Databases:">' +
|
||||
'<label style="margin-top:10px;">Description (optional)</label><input type="text" id="cc-template-overview" placeholder="What this template runs">' +
|
||||
'</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">' + (editing ? 'Save & 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');
|
||||
const envGroup = backdrop.querySelector('#cc-env');
|
||||
// A template may carry zero, one, or several rows of each kind — always
|
||||
// leave at least one (blank) row so the user has somewhere to type,
|
||||
// matching the blank-form behavior.
|
||||
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
|
||||
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
|
||||
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
|
||||
|
||||
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
||||
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
||||
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
||||
|
||||
// Populate the network dropdown with any existing custom (non-default)
|
||||
// podman networks, in addition to the built-in bridge/host/none modes
|
||||
// — best-effort: if the list call fails, the three built-ins still work.
|
||||
P.get('networks', 'list').then(function (networks) {
|
||||
const select = backdrop.querySelector('#cc-network');
|
||||
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 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) {
|
||||
backdrop.querySelector('#cc-template-fields').style.display = e.target.checked ? '' : 'none';
|
||||
});
|
||||
|
||||
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() {
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
const name = backdrop.querySelector('#cc-name').value.trim();
|
||||
// Same character set podman itself enforces — checked here too so
|
||||
// a typo (most commonly a space, e.g. copying a template's display
|
||||
// name straight in) gets caught before a round trip to the server.
|
||||
if (name && !/^[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 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; });
|
||||
|
||||
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
|
||||
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
|
||||
if (saveAsTemplate && !templateName) {
|
||||
showError('"Template name" is required when "Save as template" is checked.');
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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.
|
||||
if (!saveAsTemplate) return null;
|
||||
return P.post('templates', 'save', {
|
||||
name: templateName,
|
||||
image: image,
|
||||
networkMode: networkMode,
|
||||
privileged: privileged,
|
||||
ports: ports,
|
||||
volumes: volumes,
|
||||
env: env,
|
||||
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
|
||||
category: backdrop.querySelector('#cc-template-category').value.trim(),
|
||||
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
|
||||
}).catch(function (err) {
|
||||
alert('Container created, but saving the template failed: ' + err.message);
|
||||
});
|
||||
}).then(function () {
|
||||
close();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
showError((editing ? 'The old container may already be removed. ' : '') + 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 handleAction(id, action, btn) {
|
||||
const doIt = function (extra) {
|
||||
btn.disabled = true;
|
||||
if (btn) btn.disabled = true;
|
||||
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
||||
alert('Action failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
if (btn) btn.disabled = false;
|
||||
});
|
||||
};
|
||||
if (action === 'remove') {
|
||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
||||
doIt({ force: true });
|
||||
} else if (action === 'kill') {
|
||||
if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return;
|
||||
doIt({});
|
||||
} else {
|
||||
doIt({});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
|
||||
|
||||
P.el('containers-search').addEventListener('input', function (e) {
|
||||
searchTerm = e.target.value.trim().toLowerCase();
|
||||
renderTable();
|
||||
@@ -115,11 +875,42 @@
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const row = btn.closest('tr');
|
||||
handleAction(row.dataset.id, btn.dataset.action, btn);
|
||||
const id = row.dataset.id;
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
// Exposed for templates.js's "Use template" action, which needs to open
|
||||
// this same modal pre-filled — templates.js loads after containers.js
|
||||
// (see Podman.page's script list), so this is already set by then.
|
||||
P.openCreateContainerModal = openCreateContainerModal;
|
||||
|
||||
P.registerPanel('containers', { init: init, refresh: load });
|
||||
})();
|
||||
|
||||
@@ -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"') + '>🗑</button></td>' +
|
||||
'<td class="podman-actions"><div class="podman-actions-row">' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">🏷</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"') + '>🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -43,23 +45,78 @@
|
||||
|
||||
function init() {
|
||||
P.el('images-pull-btn').addEventListener('click', function () {
|
||||
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
|
||||
if (!reference) return;
|
||||
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
|
||||
alert('Pull failed: ' + err.message);
|
||||
P.openFormModal({
|
||||
title: 'Pull Image',
|
||||
submitLabel: 'Pull',
|
||||
fields: [
|
||||
{ name: 'reference', label: 'Image reference', required: true, placeholder: 'docker.io/library/postgres:16' },
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('images', 'pull', { reference: values.reference }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</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">🗑</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">🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -43,16 +43,120 @@
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('networks-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New network name:');
|
||||
if (!name) return;
|
||||
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
|
||||
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
});
|
||||
// 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;
|
||||
|
||||
@@ -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>→</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">×</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">⋮</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 });
|
||||
})();
|
||||
|
||||
@@ -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">↑</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">↓</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">🗑</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();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* javascript/templates.js
|
||||
*
|
||||
* Templates panel: reusable container configs saved as XML (Unraid
|
||||
* Docker-template-compatible schema — see ajax/templates.php's header
|
||||
* comment for why). "Use template" hands off to containers.js's Create
|
||||
* Container modal, pre-filled; templates are themselves created from
|
||||
* that same modal's "Save as template" checkbox, not from here.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let allTemplates = [];
|
||||
|
||||
function iconHtml(t) {
|
||||
if (t.icon) {
|
||||
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
|
||||
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
|
||||
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
|
||||
}
|
||||
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
||||
}
|
||||
|
||||
function cardHtml(t) {
|
||||
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
||||
return '' +
|
||||
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
|
||||
iconHtml(t) +
|
||||
'<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>' +
|
||||
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="podman-template-actions">' +
|
||||
'<button class="podman-btn podman-btn-primary" data-action="use">Use</button>' +
|
||||
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
|
||||
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const grid = P.el('templates-grid');
|
||||
grid.innerHTML = allTemplates.length
|
||||
? allTemplates.map(cardHtml).join('')
|
||||
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, or import an XML template.</div>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const container = P.el('podman-panel-templates');
|
||||
if (!P.el('templates-grid')) {
|
||||
container.innerHTML = '' +
|
||||
'<div class="podman-card">' +
|
||||
'<div class="podman-toolbar">' +
|
||||
'<strong style="flex:1;">Reusable container configs</strong>' +
|
||||
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn">⬆ Import Template</button>' +
|
||||
'</div>' +
|
||||
'<div class="podman-template-grid" id="templates-grid"></div>' +
|
||||
'</div>';
|
||||
P.el('templates-import-btn').addEventListener('click', openImportModal);
|
||||
P.el('templates-grid').addEventListener('click', handleCardClick);
|
||||
}
|
||||
return P.get('templates', 'list').then(function (data) {
|
||||
allTemplates = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function handleCardClick(e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const name = btn.closest('.podman-template-card').dataset.name;
|
||||
|
||||
if (btn.dataset.action === 'use') {
|
||||
btn.disabled = true;
|
||||
P.get('templates', 'get', { name: name }).then(function (config) {
|
||||
btn.disabled = false;
|
||||
P.openCreateContainerModal(config);
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Could not load template: ' + err.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.dataset.action === 'export') {
|
||||
btn.disabled = true;
|
||||
P.get('templates', 'export', { name: name }).then(function (data) {
|
||||
btn.disabled = false;
|
||||
const blob = new Blob([data.xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name + '.xml';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Export failed: ' + err.message);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn.dataset.action === 'delete') {
|
||||
if (!confirm('Delete template "' + name + '"? This does not affect any running containers.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Delete failed: ' + err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openImportModal() {
|
||||
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>Import Template</h3></div>' +
|
||||
'<form class="podman-modal-body">' +
|
||||
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
|
||||
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
|
||||
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
|
||||
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></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">Import pasted XML</button>' +
|
||||
'</div></div>';
|
||||
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
|
||||
let localTemplates = [];
|
||||
function renderLocalList(filter) {
|
||||
const list = backdrop.querySelector('#ti-local-list');
|
||||
const visible = filter
|
||||
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
|
||||
: localTemplates;
|
||||
if (!visible.length) {
|
||||
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = visible.map(function (t) {
|
||||
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
|
||||
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
|
||||
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
P.get('templates', 'list_local').then(function (data) {
|
||||
localTemplates = data;
|
||||
renderLocalList('');
|
||||
}).catch(function () {
|
||||
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
|
||||
});
|
||||
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
|
||||
renderLocalList(e.target.value.trim().toLowerCase());
|
||||
});
|
||||
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-action="import-local"]');
|
||||
if (!btn) return;
|
||||
const file = btn.closest('.podman-local-template-item').dataset.file;
|
||||
btn.disabled = true;
|
||||
P.post('templates', 'import_local', { file: file }).then(function () {
|
||||
close();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
showError(err.message);
|
||||
});
|
||||
});
|
||||
|
||||
backdrop.querySelector('#ti-xml').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 xml = backdrop.querySelector('#ti-xml').value.trim();
|
||||
if (!xml) {
|
||||
showError('Paste a template XML first.');
|
||||
return;
|
||||
}
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
P.post('templates', 'import', { xml: xml }).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); }
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('templates', { init: load, refresh: load });
|
||||
})();
|
||||
@@ -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,8 +2,12 @@
|
||||
* javascript/volumes.js
|
||||
*
|
||||
* Volumes panel: named-volume table + create/remove, backed by
|
||||
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
|
||||
* that file's header comment.
|
||||
* ajax/volumes.php. Container-level bind mounts (e.g. appdata under
|
||||
* /mnt/user/appdata/...) are deliberately not shown here, since they
|
||||
* aren't a libpod-managed resource at all — see that file's header
|
||||
* comment. A named volume created here WITH a host path (v.hostPath) IS
|
||||
* still a real, listed podman volume, just backed by that path instead
|
||||
* of podman's own internal storage — see PodmanClient::createVolume().
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -11,14 +15,17 @@
|
||||
let volumes = [];
|
||||
|
||||
function rowHtml(v) {
|
||||
const pathCell = v.hostPath
|
||||
? P.escapeHtml(v.hostPath) + ' <span class="podman-chip podman-chip-neutral" title="Bind-mounted to this host path">bind</span>'
|
||||
: P.escapeHtml(v.mountpoint);
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
||||
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</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"') + '>🗑</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"') + '>🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -42,10 +49,20 @@
|
||||
|
||||
function init() {
|
||||
P.el('volumes-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New volume name:');
|
||||
if (!name) return;
|
||||
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
P.openFormModal({
|
||||
title: 'New Volume',
|
||||
submitLabel: 'Create',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Volume name', required: true, placeholder: 'my-volume' },
|
||||
{
|
||||
name: 'path', label: 'Host path (optional)', placeholder: '/mnt/cache/appdata/my-volume',
|
||||
hint: 'Leave empty for a podman-managed volume. Set this to bind the volume ' +
|
||||
'directly to an existing directory on disk (e.g. a cache pool path) instead.',
|
||||
},
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('volumes', 'create', { name: values.name, path: values.path || undefined }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,19 +76,87 @@
|
||||
.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); }
|
||||
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
|
||||
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
|
||||
/*
|
||||
* !important here for the same reason as .podman-btn-ghost below: Unraid's
|
||||
* own webGUI theme applies a default border/hover treatment to every
|
||||
* <button> that otherwise silently wins over this rule at rest — verified
|
||||
* live via a screen recording: without !important, "Create" only ever
|
||||
* looked filled while under the mouse (Unraid's generic hover glow, applied
|
||||
* to literally any button), never at rest, making it indistinguishable
|
||||
* from Cancel except by coincidence of cursor position.
|
||||
*/
|
||||
.podman-btn-primary {
|
||||
background: var(--accent) !important; border-color: var(--accent) !important; color: var(--accent-contrast) !important;
|
||||
}
|
||||
.podman-btn-primary:hover {
|
||||
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important;
|
||||
}
|
||||
.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
|
||||
* the same bordered/accent-colored treatment as Create, so nothing in a
|
||||
* form stood out as THE primary action (found from a live screenshot AND
|
||||
* a screen recording: Cancel/Create/+Add/× all read as equally weighted,
|
||||
* with the exact same hover glow even). Unraid's own webGUI applies a
|
||||
* site-wide default border+hover-gradient to every <button>, at higher
|
||||
* effective priority than a plain single-class selector here — verified
|
||||
* live: a bare `.podman-btn-ghost { border-color: transparent }` was
|
||||
* silently losing to it, on both the rest AND hover state. !important is
|
||||
* the only reliable way to guarantee this specific, deliberate style
|
||||
* wins regardless of what Unraid's base theme does elsewhere.
|
||||
*/
|
||||
.podman-btn-ghost {
|
||||
background: transparent !important; border-color: transparent !important;
|
||||
color: var(--text-dim) !important; box-shadow: none !important;
|
||||
}
|
||||
.podman-btn-ghost:hover {
|
||||
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 (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);
|
||||
@@ -145,12 +213,54 @@
|
||||
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
||||
.podman-table-wrap { overflow-x: auto; }
|
||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.podman-row-name-btn {
|
||||
/* !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); }
|
||||
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
|
||||
.podman-row-name .ico {
|
||||
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
||||
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; }
|
||||
@@ -158,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; }
|
||||
@@ -175,7 +298,44 @@
|
||||
.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; }
|
||||
|
||||
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
|
||||
.podman-template-grid .podman-empty-note { grid-column: 1 / -1; }
|
||||
.podman-template-card {
|
||||
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.podman-template-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--surface-2); }
|
||||
.podman-template-icon-fallback {
|
||||
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px;
|
||||
color: var(--accent); border: 1px solid var(--border);
|
||||
}
|
||||
.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: 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 {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.podman-local-template-item:last-child { border-bottom: none; }
|
||||
.podman-local-template-name { font-weight: 600; font-size: 12.5px; white-space: nowrap; }
|
||||
.podman-local-template-item .podman-row-sub { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.podman-local-template-item .podman-btn { flex: none; padding: 5px 10px; font-size: 11.5px; }
|
||||
|
||||
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
|
||||
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
|
||||
@@ -185,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; } }
|
||||
@@ -206,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; }
|
||||
@@ -216,10 +405,179 @@
|
||||
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); }
|
||||
|
||||
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
|
||||
|
||||
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
|
||||
|
||||
/**
|
||||
* Modal form dialog — replaces browser-native prompt()/confirm() for any
|
||||
* action that needs more than a single yes/no (e.g. "New Volume" needs a
|
||||
* name AND an optional host path together, which prompt() can't express
|
||||
* as one coherent form). See app.js's openFormModal().
|
||||
*/
|
||||
.podman-modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(15, 17, 20, .55); z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.podman-modal {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: var(--shadow); width: 100%; max-width: 420px; max-height: calc(100vh - 40px);
|
||||
overflow-y: auto; color: var(--text); font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.podman-modal-head h3 { font-size: 15px; }
|
||||
.podman-modal-body { padding: 16px 20px; display: grid; gap: 14px; }
|
||||
.podman-modal-field label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 6px; }
|
||||
.podman-modal-field input[type="text"] {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text); width: 100%; font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-field .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; }
|
||||
.podman-modal-field select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text); font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-checkbox label { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 12.5px; margin-bottom: 0; }
|
||||
.podman-modal-error { font-size: 12.5px; color: var(--bad); background: var(--bad-bg); border-radius: 7px; padding: 8px 10px; }
|
||||
.podman-modal-actions { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.podman-error { color: var(--bad); }
|
||||
|
||||
/* Wider variant + repeatable row groups, for forms with more than 1-2 fields (e.g. Create Container). */
|
||||
.podman-modal-wide { max-width: 640px; }
|
||||
.podman-row-group { display: grid; gap: 8px; margin-bottom: 8px; }
|
||||
.podman-row-group-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.podman-row-group-item input[type="text"] {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
|
||||
font-size: 12.5px; color: var(--text); font-family: var(--font-mono); flex: 1; min-width: 0;
|
||||
}
|
||||
.podman-row-group-item select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
|
||||
font-size: 12.5px; color: var(--text); font-family: var(--font-ui);
|
||||
/* flex:none alone wasn't enough — its auto flex-basis still let the
|
||||
select stretch to fill the row (verified live: "TCP"/"Volume"
|
||||
dropdowns spanned almost the entire row width). An explicit width
|
||||
pins it to content-appropriate size regardless. */
|
||||
flex: none; width: 110px;
|
||||
}
|
||||
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
|
||||
/*
|
||||
* Port number fields share the row with a select + remove button, unlike
|
||||
* the wide source/path/key/value fields elsewhere in these row groups —
|
||||
* left on flex:1 like everything else, both port inputs fought the fixed-
|
||||
* width select/button for space and got squeezed down to a few pixels
|
||||
* (found live: they rendered as near-invisible slivers). Fixed width,
|
||||
* not flex-grown.
|
||||
*/
|
||||
.podman-row-group-item input.podman-input-narrow { flex: none; width: 90px; }
|
||||
/* Row-remove (x) reads as a normal button like everything else at full
|
||||
.podman-btn weight — muted/borderless by default, only turning
|
||||
"danger" red on hover, so it registers as a quiet per-row affordance
|
||||
rather than competing with the form's actual actions. */
|
||||
.podman-row-group-item .podman-row-remove-btn {
|
||||
background: transparent; border-color: transparent; color: var(--text-faint); flex: none;
|
||||
}
|
||||
.podman-row-group-item .podman-row-remove-btn:hover { background: var(--bad-bg); border-color: transparent; color: var(--bad); }
|
||||
|
||||
/* Anchored dropdown context menu — see app.js openContextMenu(). */
|
||||
.podman-context-menu {
|
||||
/*
|
||||
* "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 {
|
||||
appearance: none; border: none; background: none; text-align: left; padding: 8px 10px;
|
||||
font-size: 12.5px; font-weight: 600; color: var(--text); border-radius: 6px; cursor: pointer;
|
||||
font-family: var(--font-ui); width: 100%;
|
||||
}
|
||||
.podman-context-menu button:hover { background: var(--surface-2); }
|
||||
.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; }
|
||||
|
||||
/* Container detail modal — see containers.js openDetailModal(). */
|
||||
.podman-modal-xwide { max-width: 760px; }
|
||||
.podman-detail-tabs {
|
||||
display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); overflow-x: auto;
|
||||
}
|
||||
.podman-detail-tabs button {
|
||||
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
|
||||
color: var(--text-dim); padding: 10px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||
white-space: nowrap; font-family: var(--font-ui);
|
||||
}
|
||||
.podman-detail-tabs button:hover { color: var(--text); }
|
||||
.podman-detail-tabs button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
|
||||
.podman-detail-body { padding: 16px 20px; max-height: 50vh; overflow-y: auto; }
|
||||
.podman-detail-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.podman-detail-table td { padding: 6px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.podman-detail-table td:first-child { color: var(--text-faint); font-weight: 600; width: 160px; padding-right: 12px; }
|
||||
.podman-detail-table td.mono { font-family: var(--font-mono); word-break: break-all; }
|
||||
.podman-detail-json {
|
||||
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 11.8px;
|
||||
padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6;
|
||||
}
|
||||
.podman-detail-empty { color: var(--text-faint); font-size: 12.5px; padding: 8px 0; }
|
||||
|
||||
Reference in New Issue
Block a user