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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:55:14 +00:00

290 lines
11 KiB
PHP

<?php
/**
* ajax/disks.php
*
* Backs the Settings panel's "Format a Disk for Podman Storage" flow — for
* a single-disk system with no cache pool set up yet, where podman's own
* storage path (see include/Config.php's $storagePath default,
* /mnt/cache/system/podman) has nowhere real to live, and podman never
* fully starts as a result (found live: a fresh install on a system with
* no cache pool failed with "cannot reach the Podman API socket" because
* podman-storage.sh's `create` step refuses to run against a path that
* isn't an actual mounted filesystem — see that script's header comment
* for why /mnt/user/... FUSE paths don't work either).
*
* This is the one place in the plugin that formats a physical disk —
* deliberately narrow and defensive: every candidate the "format" action
* is asked to touch is re-derived server-side from the same safe-listing
* logic "list_candidates" uses, never trusted from the request alone, and
* only ever a whole, currently-unmounted, non-array disk with no existing
* partitions is eligible in the first place.
*
* Actions (?action=...):
* list_candidates GET -> [{device, sizeBytes, sizeFormatted, model, hasData}, ...]
* format POST {"device": "/dev/sdc"} -> {"mountPath": "/mnt/disks/podman-storage"}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const MOUNT_PATH = '/mnt/disks/podman-storage';
const MANAGED_DISK_CFG = '/boot/config/plugins/podman/managed-disk.cfg';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list_candidates':
podman_json_response(list_candidate_disks());
break;
case 'format':
$body = podman_read_json_body();
$device = (string) ($body['device'] ?? '');
podman_json_response(format_disk($device));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Runs a command and returns [exitCode, stdout+stderr combined]. Every
* caller here passes a fixed argv array (never a shell string built from
* request input), so there is no injection surface even before the
* device-path validation in format_disk() below.
*
* @param array<int,string> $argv
* @return array{0:int,1:string}
*/
function run(array $argv, int $timeoutSeconds = 30): array
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes);
if (!is_resource($process)) {
return [127, "could not start {$argv[0]}"];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$out = (stream_get_contents($pipes[1]) ?: '') . (stream_get_contents($pipes[2]) ?: '');
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, trim($out)];
}
/**
* Every whole disk (not partition) libpod's host isn't already using —
* mounted anywhere (itself or any partition), part of Unraid's own
* array/cache pool (cross-checked against /var/local/emhttp/disks.ini,
* the same state file Unraid's own array management writes — the boot
* flash device is covered by this same check, since Unraid lists it there
* too), or the disk backing the currently-booted root/flash filesystem.
*
* @return array<int,array<string,mixed>>
*/
function list_candidate_disks(): array
{
// Nested partitions come back automatically as a "children" array in
// -J's JSON tree — CHILDREN is not a real -o column (lsblk itself
// rejects it: "unknown column: CHILDREN"; verified live against a
// real host's lsblk). LABEL is included specifically to catch the
// Unraid boot flash drive, which FAT-labels itself "UNRAID" — see the
// safety note below for why that check exists at all.
[$code, $out] = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,LABEL'], 10);
if ($code !== 0) {
podman_json_error('Could not list block devices: ' . $out, 500);
}
$tree = json_decode($out, true);
if (!is_array($tree) || !isset($tree['blockdevices'])) {
podman_json_error('Unexpected lsblk output', 500);
}
$arrayDevices = unraid_array_device_names();
$candidates = [];
foreach ($tree['blockdevices'] as $dev) {
if (($dev['type'] ?? '') !== 'disk') {
continue;
}
$name = (string) ($dev['name'] ?? '');
$baseName = basename($name);
if (in_array($baseName, $arrayDevices, true)) {
continue;
}
// zram devices report TYPE "disk" too (RAM-backed, not persistent
// storage — pointless and misleading to offer for this) and an
// empty card-reader slot with no card inserted reports as a real
// "disk" at 0 bytes; both are excluded outright rather than left
// for the size-based hasData check below.
if (str_starts_with($baseName, 'zram') || (int) ($dev['size'] ?? 0) <= 0) {
continue;
}
if (device_or_children_mounted($dev)) {
continue;
}
if (device_or_children_labeled_unraid($dev)) {
continue;
}
// Deliberately excludes (not just warns about) ANY disk that
// already has a filesystem, partition, or other signature on it
// or any of its partitions — not just ones lsblk reports as
// currently mounted. Found live: a real host's cache pool disks
// showed up here as "safe" with only a soft warning, because
// they're ZFS pool members (fstype "zfs_member"/partition
// present) rather than plain mounts, so the earlier
// mounted-only check missed them entirely — the same blind spot
// that also let the Unraid boot USB stick's own partition
// through (FAT, not reported as "mounted" by lsblk either). A
// disk being reused for podman storage must be genuinely blank;
// asking a user to wipe it themselves first is a small price for
// this being impossible to get wrong.
if (!empty($dev['fstype']) || !empty($dev['children'])) {
continue;
}
$candidates[] = [
'device' => $name,
'sizeBytes' => (int) ($dev['size'] ?? 0),
'sizeFormatted' => podman_format_bytes((int) ($dev['size'] ?? 0)),
'model' => trim((string) ($dev['model'] ?? '')) ?: null,
];
}
return $candidates;
}
function device_or_children_mounted(array $dev): bool
{
if (!empty($dev['mountpoint'])) {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_mounted($child)) {
return true;
}
}
return false;
}
/**
* Catches the Unraid boot flash drive specifically: it FAT-labels itself
* "UNRAID" (verified live: `blkid` on a real host's boot partition shows
* LABEL_FATBOOT="UNRAID" LABEL="UNRAID") and — on at least one real,
* modern Unraid setup — /boot is actually backed by a ZFS dataset
* ("flash/boot"), not a direct mount of that partition, so it does NOT
* show up as "mounted" via lsblk's own MOUNTPOINT column at all. This
* label check is a second, independent layer specifically because that
* gap meant the boot drive briefly passed every other check here during
* development — never rely on a single signal for something this
* destructive.
*/
function device_or_children_labeled_unraid(array $dev): bool
{
if (strtoupper(trim((string) ($dev['label'] ?? ''))) === 'UNRAID') {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_labeled_unraid($child)) {
return true;
}
}
return false;
}
/**
* @return array<int,string> bare device names ("sda", "nvme0n1", ...) Unraid
* itself has assigned to the array or a cache pool, read from the same
* /var/local/emhttp/disks.ini Unraid's own array management writes —
* NOT parsed/guessed, so this stays correct across whatever array
* layout a given host actually has.
*/
function unraid_array_device_names(): array
{
$path = '/var/local/emhttp/disks.ini';
if (!is_readable($path)) {
return [];
}
$ini = @parse_ini_file($path, true);
if (!is_array($ini)) {
return [];
}
$names = [];
foreach ($ini as $section) {
if (is_array($section) && !empty($section['device'])) {
$names[] = basename((string) $section['device']);
}
}
return $names;
}
/** @return array<string,mixed> */
function format_disk(string $device): array
{
if (!preg_match('#^/dev/(sd[a-z]+|nvme\d+n\d+|vd[a-z]+)$#', $device)) {
podman_json_error('Invalid or unsupported device path', 400);
}
$allowed = array_column(list_candidate_disks(), 'device');
if (!in_array($device, $allowed, true)) {
podman_json_error("{$device} is not a currently-eligible disk (already in use, part of the array, or not found) — refusing to format it.", 400);
}
$partDevice = preg_match('#nvme\d+n\d+$#', $device) ? "{$device}p1" : "{$device}1";
[$code, $out] = run(['wipefs', '-a', $device], 30);
if ($code !== 0) {
podman_json_error("wipefs failed: {$out}", 500);
}
[$code, $out] = run(['parted', '-s', $device, 'mklabel', 'gpt', 'mkpart', 'primary', '1MiB', '100%'], 30);
if ($code !== 0) {
podman_json_error("parted failed: {$out}", 500);
}
run(['partprobe', $device], 10);
// Partition device nodes can take a moment to appear after partprobe.
for ($i = 0; $i < 20 && !file_exists($partDevice); $i++) {
usleep(250000);
}
if (!file_exists($partDevice)) {
podman_json_error("Partition {$partDevice} did not appear after partitioning {$device}", 500);
}
[$code, $out] = run(['mkfs.xfs', '-f', '-n', 'ftype=1', '-L', 'podmanstorage', $partDevice], 60);
if ($code !== 0) {
podman_json_error("mkfs.xfs failed: {$out}", 500);
}
[$code, $uuid] = run(['blkid', '-s', 'UUID', '-o', 'value', $partDevice], 10);
$uuid = trim($uuid);
if ($code !== 0 || $uuid === '') {
podman_json_error('Could not determine filesystem UUID after formatting', 500);
}
if (!is_dir(MOUNT_PATH) && !mkdir(MOUNT_PATH, 0755, true) && !is_dir(MOUNT_PATH)) {
podman_json_error('Could not create ' . MOUNT_PATH, 500);
}
[$code, $out] = run(['mount', 'UUID=' . $uuid, MOUNT_PATH], 15);
if ($code !== 0) {
podman_json_error("mount failed: {$out}", 500);
}
// Persisted so plugin/sbin/podman-mount-managed-disk.sh (called from
// the disks_mounted event hook, before rc.podman start) can remount
// this same disk by UUID on every future boot — nothing else on the
// system knows about this disk, since it's deliberately outside
// Unraid's own array/cache pool management.
$cfgDir = dirname(MANAGED_DISK_CFG);
if (!is_dir($cfgDir) && !mkdir($cfgDir, 0755, true) && !is_dir($cfgDir)) {
podman_json_error('Could not create ' . $cfgDir, 500);
}
$cfgContent = "# Written by the unraid-podman WebUI (ajax/disks.php) — do not edit by hand.\n"
. 'UUID="' . $uuid . "\"\n"
. 'MOUNTPOINT="' . MOUNT_PATH . "\"\n";
if (file_put_contents(MANAGED_DISK_CFG, $cfgContent, LOCK_EX) === false) {
podman_json_error('Formatted and mounted, but could not persist ' . MANAGED_DISK_CFG . ' for future boots', 500);
}
return ['mountPath' => MOUNT_PATH];
}