Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall backend), docker-compose (external compose provider for `podman compose`) — all vendored prebuilt binaries, versions.env pinned, propagated through build-packages.sh/release.sh/podman.plg/verify+update-packages.sh. - Fix WebUI: every POST action was silently failing (empty response body) because Unraid's own CSRF protection was never satisfied — app.js now sends the page's csrf_token as X-CSRF-Token. - Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but /images/pull actually streams newline-delimited JSON — every successful pull was throwing "Expected a JSON object/array response". - Fix WebUI: compose.php's up/down status detection had the same single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was corrupting the parse. - Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a redeployed JS/CSS fix isn't served stale from browser cache. - Add a reusable modal dialog (app.js openFormModal) replacing prompt()/alert() for New Volume/Network/Pull Image. - Add host-path (bind-mount) support when creating a named volume. - Add Create Container (image, name, network mode incl. custom networks, ports, volumes, env, restart policy, privileged, start-after-create), auto-pulling the image on first use since /containers/create doesn't. All fixes verified live against a real podman system service and, where reachable, via the actual WebUI over the real socket — not just unit-level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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, docker-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: ""
|
||||
|
||||
@@ -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 |
|
||||
| `docker-compose` (CLI-Plugin) | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein `docker-compose`-Binary in festen CLI-Plugin-Pfaden; ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl |
|
||||
|
||||
### 5.2 Build-Strategie
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -0,0 +1,31 @@
|
||||
# packages/docker-compose/
|
||||
|
||||
Pinned version: see `DOCKER_COMPOSE_VERSION` in [versions.env](../../versions.env).
|
||||
|
||||
Not built from source — `docker-compose.SlackBuild` fetches and repackages
|
||||
upstream's own prebuilt static x86_64 release binary (`docker/compose`,
|
||||
the Go-based Compose v2 CLI plugin — a different project from the older
|
||||
Python `podman-compose`). It's a small, purely static ELF with zero
|
||||
runtime library dependencies (verified: `ldd` reports "not a dynamic
|
||||
executable"), so there's nothing meaningful to gain from a from-source
|
||||
build.
|
||||
|
||||
`podman compose` (backing the WebUI's Compose panel, see
|
||||
`webui/plugins/podman/ajax/compose.php`) has no compose implementation of
|
||||
its own — it shells out to an "external compose provider" it discovers by
|
||||
searching a fixed list of CLI-plugin directories for a binary named
|
||||
`docker-compose`. Without one present, every Compose panel action fails
|
||||
outright. Installed to `/usr/local/lib/docker/cli-plugins/docker-compose`
|
||||
— one of podman's own search paths (extracted from the pinned podman
|
||||
binary: `strings /usr/bin/podman | grep cli-plugins`), chosen specifically
|
||||
under `/usr/local/` rather than `/usr/lib/docker/...` so this package
|
||||
never collides with (or gets silently shadowed by) a genuine Docker
|
||||
installation's own compose plugin on hosts that also run Unraid's
|
||||
built-in Docker support.
|
||||
|
||||
Found by live-testing the Compose panel end-to-end against a real Unraid
|
||||
install: it happened to work only because that particular host already
|
||||
had Docker's own `docker-compose` plugin installed from an unrelated,
|
||||
pre-existing Docker setup — a clean Unraid install has no compose
|
||||
provider at all without this package. See
|
||||
[docs/ARCHITECTURE.md, section 5.1](../../docs/ARCHITECTURE.md#51-zu-paketierende-komponenten).
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# packages/docker-compose/docker-compose.SlackBuild
|
||||
#
|
||||
# Packages the official prebuilt docker/compose v2 release binary — not
|
||||
# built from source, see versions.env's DOCKER_COMPOSE_* block for why.
|
||||
# `podman compose` (backing webui/plugins/podman/ajax/compose.php) has no
|
||||
# compose implementation of its own; it shells out to an "external compose
|
||||
# provider" it discovers by searching a fixed list of CLI-plugin
|
||||
# directories for a binary literally named `docker-compose` (paths
|
||||
# extracted from the pinned podman binary itself:
|
||||
# `strings /usr/bin/podman | grep cli-plugins`). Without one present,
|
||||
# every Compose panel action fails outright (found by live-testing against
|
||||
# a real Unraid install — it only worked there because that host happened
|
||||
# to already have Docker's own compose plugin from an unrelated setup).
|
||||
#
|
||||
# Installed to /usr/local/lib/docker/cli-plugins/docker-compose — one of
|
||||
# podman's search paths, deliberately the /usr/local/ one rather than
|
||||
# /usr/lib/docker/cli-plugins so this package never collides with (or
|
||||
# gets silently shadowed by) a genuine Docker installation's own compose
|
||||
# plugin on hosts that also run Unraid's built-in Docker support.
|
||||
# =============================================================================
|
||||
|
||||
set -eu
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
|
||||
# shellcheck source=/dev/null
|
||||
. "$REPO_ROOT/versions.env"
|
||||
|
||||
VERSION="$DOCKER_COMPOSE_VERSION"
|
||||
ARCH="$PKG_ARCH"
|
||||
BUILD="$PKG_BUILD"
|
||||
TAG="$PKG_TAG"
|
||||
|
||||
sb_init "docker-compose"
|
||||
|
||||
binary=$(sb_fetch_and_verify "$DOCKER_COMPOSE_SRC_URL" "$DOCKER_COMPOSE_SRC_SHA256" "docker-compose-$VERSION")
|
||||
|
||||
install -D -m 0755 "$binary" "$PKG/usr/local/lib/docker/cli-plugins/docker-compose"
|
||||
|
||||
# The release only ships the raw binary (+ checksum/signature files, no
|
||||
# LICENSE/README asset) — write minimal doc metadata by hand instead of
|
||||
# using sb_install_docs, which expects real files to copy from disk.
|
||||
docdir="$PKG/usr/doc/docker-compose-$VERSION"
|
||||
mkdir -p "$docdir"
|
||||
{
|
||||
echo "docker-compose (docker/compose v2 CLI plugin) $VERSION"
|
||||
echo "https://github.com/docker/compose"
|
||||
echo "Prebuilt static binary, packaged as-is by unraid-podman — see"
|
||||
echo "versions.env for the pinned release URL and SHA256."
|
||||
} > "$docdir/README"
|
||||
{
|
||||
echo "Built by unraid-podman from upstream's prebuilt release binary."
|
||||
echo "Package: docker-compose $VERSION"
|
||||
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
} > "$docdir/unraid-podman.build-info"
|
||||
|
||||
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
|
||||
@@ -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------------------------------------------------|
|
||||
docker-compose: docker-compose (external Compose provider for podman compose)
|
||||
docker-compose:
|
||||
docker-compose: The docker/compose v2 CLI-plugin binary, installed as a
|
||||
docker-compose: CLI-plugin so `podman compose` can find it. Required by the
|
||||
docker-compose: WebUI's Compose panel; packaged from upstream's prebuilt
|
||||
docker-compose: static binary release, not built from source.
|
||||
docker-compose:
|
||||
docker-compose: Homepage: https://github.com/docker/compose
|
||||
docker-compose:
|
||||
docker-compose:
|
||||
docker-compose:
|
||||
@@ -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"
|
||||
@@ -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>,
|
||||
|
||||
+66
-8
@@ -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/
|
||||
docker-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.
|
||||
@@ -65,8 +67,8 @@
|
||||
|
||||
<!-- 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">
|
||||
@@ -106,6 +108,27 @@
|
||||
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
|
||||
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
|
||||
|
||||
<!-- 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.0.0">
|
||||
<!ENTITY catatonit_txz_file "catatonit-&catatonit_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
|
||||
<!ENTITY catatonit_txz_md5 "00000000000000000000000000000000">
|
||||
|
||||
<!ENTITY nftables_txz_version "0.0.0">
|
||||
<!ENTITY nftables_txz_file "nftables-&nftables_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
|
||||
<!ENTITY nftables_txz_md5 "00000000000000000000000000000000">
|
||||
|
||||
<!-- docker-compose is the external Compose provider `podman compose`
|
||||
shells out to (see packages/docker-compose/README.md) — without it
|
||||
every Compose panel action fails outright on a clean install. -->
|
||||
<!ENTITY docker_compose_txz_version "0.0.0">
|
||||
<!ENTITY docker_compose_txz_file "docker-compose-&docker_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
|
||||
<!ENTITY docker_compose_txz_md5 "00000000000000000000000000000000">
|
||||
|
||||
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
|
||||
sbin/ scripts, event/ hooks, config templates — see
|
||||
packages/unraid-podman/README.md), not an upstream component. Its
|
||||
@@ -145,7 +168,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 +180,9 @@ fi
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
The seven upstream component packages. Each is downloaded straight into
|
||||
The seven upstream component packages, plus catatonit, nftables, and
|
||||
docker-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
|
||||
@@ -232,6 +257,33 @@ fi
|
||||
</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;/&docker_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||
<URL>
|
||||
&baseURL;/&docker_compose_txz_file;
|
||||
</URL>
|
||||
<MD5>
|
||||
&docker_compose_txz_md5;
|
||||
</MD5>
|
||||
</FILE>
|
||||
|
||||
<!--
|
||||
The plugin's own scaffolding package — rc.podman, sbin/ helper scripts,
|
||||
the event/ hooks below, and default config templates. See
|
||||
@@ -247,7 +299,7 @@ fi
|
||||
</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
|
||||
@@ -291,6 +343,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 "DOCKER_COMPOSE_INSTALLED_VERSION=\"&docker_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 +406,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 &docker_compose_txz_file;
|
||||
removepkg &unraid_podman_txz_file;
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -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 docker-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 docker-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>.
|
||||
|
||||
+15
-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, docker-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,17 @@ 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 docker-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 docker-compose are listed first since they're
|
||||
# all a plain fetch-and-repackage of an already-built upstream artifact
|
||||
# (see their own README.md for why) — no compiler, fastest possible
|
||||
# signal if their pinned URL/checksum in versions.env ever goes stale.
|
||||
# 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+7
-5
@@ -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).
|
||||
@@ -54,10 +54,12 @@ RELEASE_BASE_URL="https://github.com/$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 docker-compose are vendored runtime
|
||||
# dependencies (not built from source, see their own packages/*/README.md)
|
||||
# and unraid-podman is the plugin's own scaffolding package (see
|
||||
# packages/unraid-podman/README.md), not an upstream component, but all
|
||||
# four are released and entity-updated exactly like the seven upstream ones.
|
||||
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman)
|
||||
|
||||
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
|
||||
|
||||
|
||||
@@ -93,6 +93,50 @@ 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"
|
||||
|
||||
# --- docker-compose ----------------------------------------------------------
|
||||
# https://github.com/docker/compose — the Compose v2 CLI-plugin binary
|
||||
# (Go, not the older Python podman-compose). `podman compose` (backing
|
||||
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
|
||||
# compose implementation of its own — it searches a fixed set of
|
||||
# CLI-plugin directories for a binary named exactly "docker-compose" and
|
||||
# shells out to it. Without one present, every Compose panel action fails
|
||||
# outright (found by live-testing: it only worked on the test host because
|
||||
# that host happened to already have Docker's own compose plugin
|
||||
# installed from an unrelated, pre-existing Docker setup — a clean Unraid
|
||||
# install has none). Upstream publishes a prebuilt static x86_64 binary
|
||||
# release asset (verified via `ldd`: "not a dynamic executable") plus a
|
||||
# matching .sha256 sidecar — no from-source build needed.
|
||||
DOCKER_COMPOSE_VERSION="5.3.1"
|
||||
DOCKER_COMPOSE_SRC_URL="https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64"
|
||||
DOCKER_COMPOSE_SRC_SHA256="f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959"
|
||||
|
||||
# =============================================================================
|
||||
# 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):
|
||||
|
||||
@@ -16,8 +16,23 @@ 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 — verified live: a
|
||||
* bugfix to app.js's CSRF handling silently kept failing in a real
|
||||
* browser after redeploy until this was added, even though the deployed
|
||||
* file on disk was byte-for-byte correct.
|
||||
*/
|
||||
function podman_asset_version(string $relPath): string
|
||||
{
|
||||
$full = __DIR__ . $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">
|
||||
|
||||
@@ -73,6 +88,7 @@ Icon="podman"
|
||||
<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-create-btn">+ New Container</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
@@ -243,14 +259,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', '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";
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -121,8 +121,19 @@ 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';
|
||||
}
|
||||
|
||||
function compose_read(string $composeDir, string $project): string
|
||||
@@ -155,7 +166,7 @@ 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
|
||||
{
|
||||
@@ -165,7 +176,7 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
||||
$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 +186,9 @@ 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) for human-facing success/error messages, where seeing
|
||||
// podman's own stderr banner/warnings is actually useful context.
|
||||
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
* restart POST {"id": "...", "timeout": 10}
|
||||
* remove POST {"id": "...", "force": false}
|
||||
* logs GET (&id=...&tail=200) -> plain text
|
||||
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
||||
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
||||
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
||||
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
|
||||
* "privileged": false, "startAfterCreate": true}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -68,10 +73,129 @@ switch ($action) {
|
||||
podman_json_response(['status' => 'removed']);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 !== '') {
|
||||
$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 !== '') {
|
||||
$spec['networks'] = [$networkMode => new \stdClass()];
|
||||
}
|
||||
|
||||
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
|
||||
$spec['restart_policy'] = (string) $body['restartPolicy'];
|
||||
}
|
||||
if ($body['privileged'] ?? false) {
|
||||
$spec['privileged'] = true;
|
||||
}
|
||||
|
||||
return $spec;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_id(array $body): string
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,23 @@ 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
|
||||
{
|
||||
$result = $this->request('POST', '/containers/create', [], false, $spec);
|
||||
return (string) ($result['Id'] ?? '');
|
||||
}
|
||||
|
||||
public function startContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
|
||||
@@ -220,10 +237,54 @@ 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]);
|
||||
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
|
||||
|
||||
$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
|
||||
@@ -240,9 +301,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
|
||||
|
||||
@@ -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,109 @@ 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" 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
@@ -180,6 +294,7 @@ window.Podman = (function () {
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
openFormModal: openFormModal,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
|
||||
@@ -80,6 +80,180 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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" data-field="hostPort" placeholder="Host port">' +
|
||||
'<span>→</span>' +
|
||||
'<input type="text" class="mono" 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" 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" 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" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function addRow(groupEl, rowHtmlFn) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = rowHtmlFn();
|
||||
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 openCreateContainerModal() {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
|
||||
'<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>' +
|
||||
'<div class="podman-modal-field"><label>Network</label>' +
|
||||
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
||||
'<option value="host">Host</option><option value="none">None</option></select></div>' +
|
||||
'<div class="podman-modal-field"><label>Port mappings</label>' +
|
||||
'<div class="podman-row-group" id="cc-ports"></div>' +
|
||||
'<button type="button" class="podman-btn" data-add="port">+ Add port</button></div>' +
|
||||
'<div class="podman-modal-field"><label>Volumes</label>' +
|
||||
'<div class="podman-row-group" id="cc-volumes"></div>' +
|
||||
'<button type="button" class="podman-btn" 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" 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 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>' +
|
||||
'</form>' +
|
||||
'<div class="podman-modal-actions">' +
|
||||
'<button type="button" class="podman-btn" 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('#cc-ports');
|
||||
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
||||
const envGroup = backdrop.querySelector('#cc-env');
|
||||
addRow(portsGroup, portRowHtml);
|
||||
addRow(volumesGroup, volumeRowHtml);
|
||||
addRow(envGroup, envRowHtml);
|
||||
|
||||
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;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}).catch(function () { /* built-in modes still usable */ });
|
||||
|
||||
backdrop.querySelector('#cc-image').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 image = backdrop.querySelector('#cc-image').value.trim();
|
||||
if (!image) {
|
||||
showError('"Image" is required.');
|
||||
return;
|
||||
}
|
||||
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
|
||||
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
||||
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
||||
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
P.post('containers', 'create', {
|
||||
image: image,
|
||||
name: backdrop.querySelector('#cc-name').value.trim(),
|
||||
networkMode: backdrop.querySelector('#cc-network').value,
|
||||
ports: ports,
|
||||
volumes: volumes,
|
||||
env: env,
|
||||
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
||||
privileged: backdrop.querySelector('#cc-privileged').checked,
|
||||
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
||||
}).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 handleAction(id, action, btn) {
|
||||
const doIt = function (extra) {
|
||||
btn.disabled = true;
|
||||
@@ -97,6 +271,8 @@
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -43,10 +43,15 @@
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,11 +45,16 @@
|
||||
|
||||
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);
|
||||
P.openFormModal({
|
||||
title: 'New Network',
|
||||
submitLabel: 'Create',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
|
||||
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,11 +15,14 @@
|
||||
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>' +
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -222,4 +222,52 @@
|
||||
.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;
|
||||
}
|
||||
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
|
||||
|
||||
Reference in New Issue
Block a user