From 7f6fcb91663a86d7c758276ff2d5c9c589f4897a Mon Sep 17 00:00:00 2001 From: magges Date: Sun, 12 Jul 2026 22:55:14 +0000 Subject: [PATCH] Fix real STORAGE_PATH bug; add Start Podman + format-disk from the WebUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of a fresh-install "cannot reach the Podman API socket" report (a friend's Unraid box, cache pool present and mounted): unlike Docker-for-Unraid's docker.img path, this plugin never auto-created STORAGE_PATH itself — only podman.img inside it. A perfectly normal, already-mounted cache pool still failed preflight/storage-create with "does not exist", just because its own .../system/podman subdirectory had never been created. Fixed by walking up to the nearest existing ancestor and checking whether it's on a different device than / (real mount vs. nothing mounted at all) — see podman-common.sh's new podman_path_has_real_mount_ancestor(), used by both podman-preflight.sh and podman-storage.sh. Settings gets a "Podman Service" card (status chip + Start/Restart, backed by new ajax/settings.php service_status/start/restart actions that just shell out to rc.podman) so a fresh install that failed to start can be diagnosed and retried without SSH/terminal access at all — exactly what was missing when this was first needed live. Also adds "Format a Disk for Podman Storage" (new ajax/disks.php) for a single-disk system with no cache pool at all. Only ever lists disks with literally no existing partition/filesystem/RAID-or-ZFS-membership signature and that aren't Unraid's boot flash — found live, twice, during development: the boot USB (FAT, labeled "UNRAID") passed the initial mounted-only check because this host's /boot is backed by a ZFS dataset rather than a direct partition mount, and active RAID-member cache disks passed a data-vs-blank *warning* rather than a hard exclusion. Both are now excluded outright, not just flagged — see disks.php's device_or_children_labeled_unraid() and the hasData exclusion in list_candidate_disks(). A disk formatted this way is remounted by UUID on every boot via a new plugin/sbin/podman-mount-managed-disk.sh, called from plugin/event/disks_mounted before rc.podman start. Unrelated fix bundled in: scripts/lib/slackbuild-common.sh now sets SOURCE_DATE_EPOCH (derived from the repo's last commit) before calling makepkg, so two separate builds of the same commit produce byte-identical .txz files — makepkg already supports this (`--clamp-mtime` when $SOURCE_DATE_EPOCH is set, confirmed by reading a real host's /sbin/makepkg) but nothing was setting the variable, so release.yml's "rebuild in CI and verify it matches the committed checksums" step was guaranteed to fail on the first package it checked alphabetically (observed live: aardvark-dns). Co-Authored-By: Claude Sonnet 5 --- plugin/event/disks_mounted | 8 +- plugin/sbin/podman-common.sh | 27 ++ plugin/sbin/podman-mount-managed-disk.sh | 51 ++++ plugin/sbin/podman-preflight.sh | 8 + plugin/sbin/podman-storage.sh | 10 +- scripts/lib/slackbuild-common.sh | 17 ++ webui/plugins/podman/Podman.page | 22 ++ webui/plugins/podman/ajax/disks.php | 289 ++++++++++++++++++++ webui/plugins/podman/ajax/settings.php | 55 ++++ webui/plugins/podman/javascript/settings.js | 118 ++++++++ webui/plugins/podman/styles/podman.css | 3 + 11 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 plugin/sbin/podman-mount-managed-disk.sh create mode 100644 webui/plugins/podman/ajax/disks.php diff --git a/plugin/event/disks_mounted b/plugin/event/disks_mounted index d04025e..4671cb0 100755 --- a/plugin/event/disks_mounted +++ b/plugin/event/disks_mounted @@ -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 diff --git a/plugin/sbin/podman-common.sh b/plugin/sbin/podman-common.sh index 986962d..0295034 100755 --- a/plugin/sbin/podman-common.sh +++ b/plugin/sbin/podman-common.sh @@ -168,6 +168,33 @@ podman_storage_path_is_safe() { return 0 } +# ----------------------------------------------------------------------------- +# podman_path_has_real_mount_ancestor +# +# True if 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 # diff --git a/plugin/sbin/podman-mount-managed-disk.sh b/plugin/sbin/podman-mount-managed-disk.sh new file mode 100644 index 0000000..47c3a18 --- /dev/null +++ b/plugin/sbin/podman-mount-managed-disk.sh @@ -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 diff --git a/plugin/sbin/podman-preflight.sh b/plugin/sbin/podman-preflight.sh index 9111b6e..301e7b4 100755 --- a/plugin/sbin/podman-preflight.sh +++ b/plugin/sbin/podman-preflight.sh @@ -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 diff --git a/plugin/sbin/podman-storage.sh b/plugin/sbin/podman-storage.sh index 21bae7d..8656c5d 100755 --- a/plugin/sbin/podman-storage.sh +++ b/plugin/sbin/podman-storage.sh @@ -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 diff --git a/scripts/lib/slackbuild-common.sh b/scripts/lib/slackbuild-common.sh index d9f9441..f23a037 100755 --- a/scripts/lib/slackbuild-common.sh +++ b/scripts/lib/slackbuild-common.sh @@ -173,6 +173,23 @@ sb_make_package() { find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \ 'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \; + # Reproducible builds: Slackware's own makepkg already sorts its file + # list (LC_COLLATE=C sort) before archiving, so member ORDER is already + # deterministic — but it only clamps file mtimes in the resulting tar + # when $SOURCE_DATE_EPOCH is set (verified by reading a real + # /sbin/makepkg: `if [ -n "${SOURCE_DATE_EPOCH}" ]; then MTIME= + # "--clamp-mtime --mtime=@${SOURCE_DATE_EPOCH}"; fi`). Without it, every + # separate build run stamps freshly-compiled files with its own wall-clock + # time, so two builds of the *same* source produce byte-different .txz + # files — which is exactly what broke release.yml's "rebuild in CI and + # verify it matches the checksums committed in podman.plg" step (found + # live: aardvark-dns's checksum differed between two separate Gitea + # Actions runs of the identical tagged commit). Deriving it from the + # repo's last commit time (not `date`/a random per-build value) keeps it + # stable across any number of rebuilds of the same commit, while still + # changing whenever the source actually does. + export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(cd "$CWD/../.." && git log -1 --format=%ct 2>/dev/null || echo 0)}" + local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz" ( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" ) diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page index 2fdd7f7..4c1548b 100644 --- a/webui/plugins/podman/Podman.page +++ b/webui/plugins/podman/Podman.page @@ -236,6 +236,21 @@ function podman_asset_version(string $relPath): string
+
+
+

Podman Service

Start, restart, or check the podman.sock backend — no terminal needed.
+
+
+
+ Checking… + + + +
+ +
+
+

Storage

Where podman keeps images, containers and volumes on disk.
@@ -247,6 +262,13 @@ function podman_asset_version(string $relPath): string
Cache pool or dedicated disk — never a path under /mnt/user (FUSE).
+
+ +
+ +
Formats an unused disk with XFS and mounts it, for a single-disk system with no cache pool set up yet.
+
+
diff --git a/webui/plugins/podman/ajax/disks.php b/webui/plugins/podman/ajax/disks.php new file mode 100644 index 0000000..6f5940d --- /dev/null +++ b/webui/plugins/podman/ajax/disks.php @@ -0,0 +1,289 @@ + [{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 $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> + */ +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 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 */ +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]; +} diff --git a/webui/plugins/podman/ajax/settings.php b/webui/plugins/podman/ajax/settings.php index 0b48d08..e2792a5 100644 --- a/webui/plugins/podman/ajax/settings.php +++ b/webui/plugins/podman/ajax/settings.php @@ -16,12 +16,17 @@ * 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_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 +50,60 @@ 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_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 — 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 +{ + $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); + + $combined = trim($stdout . $stderr); + // rc.podman status/start both print "service: running (pid ..., socket ...)" + // on success — cheaper and more honest than re-implementing the same + // socket/pid check PHP-side when the shell script already did it. + $running = (bool) preg_match('/service:\s+running/', $combined); + + return ['running' => $running, 'output' => $combined]; +} + /** @return array */ function settings_get(PodmanConfig $config): array { diff --git a/webui/plugins/podman/javascript/settings.js b/webui/plugins/podman/javascript/settings.js index c32c255..f682a1e 100644 --- a/webui/plugins/podman/javascript/settings.js +++ b/webui/plugins/podman/javascript/settings.js @@ -57,6 +57,118 @@ }); } + // --- Podman service status/start/restart ---------------------------------- + + function renderServiceResult(data) { + const chip = P.el('settings-service-chip'); + chip.className = 'podman-chip ' + (data.running ? 'podman-chip-good' : 'podman-chip-bad'); + chip.innerHTML = '' + (data.running ? 'Running' : 'Not running'); + + const log = P.el('settings-service-log'); + if (data.output) { + log.style.display = ''; + log.textContent = data.output; + log.scrollTop = log.scrollHeight; + } + } + + function refreshServiceStatus() { + const buttons = [P.el('settings-service-status-btn'), P.el('settings-service-start-btn'), P.el('settings-service-restart-btn')]; + buttons.forEach(function (b) { b.disabled = true; }); + return P.get('settings', 'service_status').then(renderServiceResult).catch(function (err) { + P.el('settings-service-chip').className = 'podman-chip podman-chip-bad'; + P.el('settings-service-chip').innerHTML = '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; }); + }); + } + + function runServiceAction(action) { + const buttons = [P.el('settings-service-status-btn'), P.el('settings-service-start-btn'), P.el('settings-service-restart-btn')]; + buttons.forEach(function (b) { b.disabled = true; }); + P.el('settings-service-log').style.display = ''; + P.el('settings-service-log').textContent = 'Working…'; + return P.post('settings', action, {}).then(renderServiceResult).catch(function (err) { + P.el('settings-service-log').style.display = ''; + P.el('settings-service-log').textContent = err.message; + }).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 = '' + + ''; + + (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 ''; + }).join('') + : ''; + updateSubmitEnabled(); + }).catch(function (err) { + select.innerHTML = ''; + 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(), @@ -91,6 +203,12 @@ saveAutostart(); }); + P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus); + P.el('settings-service-start-btn').addEventListener('click', function () { runServiceAction('service_start'); }); + P.el('settings-service-restart-btn').addEventListener('click', function () { runServiceAction('service_restart'); }); + P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal); + + refreshServiceStatus(); return load(); } diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css index dbee1f0..e32bef9 100644 --- a/webui/plugins/podman/styles/podman.css +++ b/webui/plugins/podman/styles/podman.css @@ -418,6 +418,9 @@ .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