Add reproducible build system, native Unraid plugin, and WebUI
Build Packages / Build .txz packages (push) Failing after 9s
Lint / ShellCheck (push) Failing after 43s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Failing after 6s

- versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt,
  and fuse-overlayfs to verified upstream source checksums; SlackBuild
  recipes, scripts/build-packages.sh, checksums.sh, release.sh, and
  update-versions.sh implement the reproducible pipeline; GitHub Actions
  workflows build in a Slackware container and publish releases without
  committing any binaries.

- plugin/podman.plg installs/updates/removes all eight packages (the
  seven components plus the plugin's own unraid-podman scaffolding
  package) via upgradepkg, using the official Unraid array-event hook
  mechanism (event/disks_mounted, event/stopping) instead of editing
  /boot/config/go. rc.podman and the sbin/ helper scripts implement
  storage creation, config seeding/sync, preflight checks, autostart
  with per-container Safe-Mode, and package verify/update/rollback.

- webui/plugins/podman implements the Dashboard, Containers, Pods,
  Images, Volumes, Networks, Logs, Terminal, Compose, and Settings
  panels against the approved mockup (webui/mockups/prototype.html),
  talking to podman system service exclusively via PodmanClient.php
  (libpod REST API over the Unix socket), with two documented
  exceptions: Terminal's one-shot exec model and Compose's use of the
  podman compose CLI, since libpod has no REST equivalent for either.

- docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions
  and honest current status (syntax-checked, unit- and
  integration-tested against fake sockets/servers; not yet run against
  a real Unraid/Podman/Slackware system).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:51:14 +00:00
co-authored by Claude Sonnet 5
parent 58ffc0c226
commit e2fefcdf9c
124 changed files with 9611 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* Config.php
*
* Reads unraid-podman's own settings so the WebUI and the shell-based
* plugin scripts under plugin/sbin/ agree on where everything lives —
* this file is the PHP-side counterpart of plugin/sbin/podman-common.sh's
* podman_load_cfg() function, and intentionally mirrors its defaults and
* paths exactly (see that script for the shell equivalent).
*/
declare(strict_types=1);
final class PodmanConfig
{
public string $storagePath;
public int $storageImageSizeGb;
public bool $enabled;
public int $stopTimeoutSeconds;
public string $socketPath;
public string $bootDir;
public string $autostartFile;
public string $autostartDelayFile;
private function __construct()
{
// Defaults mirror podman-common.sh's podman_load_cfg() defaults.
$this->bootDir = '/boot/config/plugins/podman';
$this->storagePath = '/mnt/cache/system/podman';
$this->storageImageSizeGb = 20;
$this->enabled = true;
$this->stopTimeoutSeconds = 10;
$this->socketPath = '/var/run/podman/podman.sock';
$this->autostartFile = $this->bootDir . '/autostart';
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
}
public static function load(): self
{
$cfg = new self();
$cfgFile = $cfg->bootDir . '/podman.cfg';
if (is_readable($cfgFile)) {
$values = self::parseShellStyleFile($cfgFile);
if (isset($values['STORAGE_PATH'])) {
$cfg->storagePath = $values['STORAGE_PATH'];
}
if (isset($values['STORAGE_IMAGE_SIZE_GB'])) {
$cfg->storageImageSizeGb = (int) $values['STORAGE_IMAGE_SIZE_GB'];
}
if (isset($values['PODMAN_ENABLED'])) {
$cfg->enabled = strtolower($values['PODMAN_ENABLED']) === 'yes';
}
if (isset($values['STOP_TIMEOUT'])) {
$cfg->stopTimeoutSeconds = (int) $values['STOP_TIMEOUT'];
}
}
return $cfg;
}
/**
* Parses the simple `KEY="value"` / `KEY=value` shell-sourceable format
* used by podman.cfg (see config/podman.cfg.example) WITHOUT executing
* it as shell — this file is read by an unprivileged PHP-FPM worker,
* so treating it as data rather than sourcing it is a deliberate
* safety boundary, not just a convenience.
*
* @return array<string,string>
*/
private static function parseShellStyleFile(string $path): array
{
$result = [];
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return $result;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!preg_match('/^([A-Z_][A-Z0-9_]*)=(.*)$/', $line, $m)) {
continue;
}
[$_, $key, $value] = $m;
$value = trim($value);
// Strip one layer of matching quotes, if present.
if (strlen($value) >= 2 && (
($value[0] === '"' && str_ends_with($value, '"')) ||
($value[0] === "'" && str_ends_with($value, "'"))
)) {
$value = substr($value, 1, -1);
}
$result[$key] = $value;
}
return $result;
}
public function newClient(): PodmanClient
{
return new PodmanClient($this->socketPath);
}
}
@@ -0,0 +1,404 @@
<?php
/**
* PodmanClient.php
*
* Thin PHP client for the Podman libpod REST API, spoken exclusively over
* the local Unix socket that plugin/rc.d/rc.podman starts
* (`podman system service`, see docs/ARCHITECTURE.md section 18.1). Every
* AJAX endpoint under webui/plugins/podman/ajax/ goes through this class —
* none of them shell out to the `podman` binary. The one deliberate,
* documented exception to "talk to the API, not the CLI" is Compose
* support (see ajax/compose.php), because compose has no REST equivalent
* in libpod at all, not because it was more convenient to shell out.
*
* Design notes:
* - One cURL handle per request (kept simple; this is a low-traffic
* admin UI, not a high-throughput proxy — a persistent handle pool
* would be premature).
* - Every method returns a plain PHP array/bool decoded from the API's
* JSON response, or throws PodmanApiException on a non-2xx response
* or transport failure, so callers can use a single try/catch instead
* of checking a mixed-shape return value after every call.
* - No business logic lives here — this class only knows how to speak
* the libpod API. Formatting, filtering, and aggregation belong in the
* ajax/*.php endpoints that use it (see e.g. ajax/system.php for
* dashboard aggregation).
*/
declare(strict_types=1);
final class PodmanApiException extends \RuntimeException
{
public int $httpStatus;
public function __construct(string $message, int $httpStatus = 0, ?\Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
$this->httpStatus = $httpStatus;
}
}
final class PodmanClient
{
/** libpod REST API version this client targets. */
private const API_VERSION = 'v4.0.0';
private string $socketPath;
private int $timeoutSeconds;
public function __construct(string $socketPath, int $timeoutSeconds = 15)
{
$this->socketPath = $socketPath;
$this->timeoutSeconds = $timeoutSeconds;
}
// -------------------------------------------------------------------
// System
// -------------------------------------------------------------------
/** GET /info — engine + host information (used by the Dashboard/Settings panels). */
public function info(): array
{
return $this->request('GET', '/info');
}
/** GET /system/df — image/container/volume disk usage summary. */
public function systemDf(): array
{
return $this->request('GET', '/system/df');
}
/** Quick reachability check — used by ajax/system.php's status endpoint. */
public function ping(): bool
{
try {
$this->request('GET', '/_ping', [], true);
return true;
} catch (PodmanApiException $e) {
return false;
}
}
// -------------------------------------------------------------------
// Containers
// -------------------------------------------------------------------
/** GET /containers/json — list containers. $all=true includes stopped ones. */
public function listContainers(bool $all = true): array
{
return $this->request('GET', '/containers/json', ['all' => $all ? 'true' : 'false']);
}
/** GET /containers/{id}/json — full inspect data for one container. */
public function inspectContainer(string $id): array
{
return $this->request('GET', '/containers/' . rawurlencode($id) . '/json');
}
/** GET /containers/{id}/stats?stream=false — one-shot CPU/memory snapshot. */
public function containerStats(string $id): array
{
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
}
public function stopContainer(string $id, int $timeoutSeconds = 10): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartContainer(string $id, int $timeoutSeconds = 10): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removeContainer(string $id, bool $force = false): void
{
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
* where possible) log text. Podman's non-TTY log stream uses the same
* 8-byte-frame-header multiplexing as `attach`; we strip those frame
* headers in demuxStream() so callers just get plain text lines.
*/
public function containerLogs(string $id, int $tail = 200, bool $timestamps = true): string
{
$query = [
'stdout' => 'true',
'stderr' => 'true',
'tail' => (string) $tail,
'timestamps' => $timestamps ? 'true' : 'false',
];
$raw = $this->requestRaw('GET', '/containers/' . rawurlencode($id) . '/logs', $query);
return self::demuxStream($raw);
}
// -------------------------------------------------------------------
// Exec — see webui/plugins/podman/ajax/exec.php for the important
// caveat: this implements one-shot "run a command, return its output"
// semantics over the exec API, not a true interactive PTY (which would
// require a persistent bidirectional connection this stack doesn't
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
public function listPods(): array
{
return $this->request('GET', '/pods/json');
}
public function inspectPod(string $name): array
{
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
}
public function stopPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Images
// -------------------------------------------------------------------
public function listImages(): array
{
return $this->request('GET', '/images/json');
}
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
public function pullImage(string $reference): array
{
return $this->request('POST', '/images/pull', ['reference' => $reference]);
}
public function removeImage(string $id, bool $force = false): void
{
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
public function listVolumes(): array
{
return $this->request('GET', '/volumes/json');
}
public function createVolume(string $name, string $driver = 'local'): array
{
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
}
public function removeVolume(string $name, bool $force = false): void
{
$this->request('DELETE', '/volumes/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Networks
// -------------------------------------------------------------------
public function listNetworks(): array
{
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
return $this->request('POST', '/networks/create', [], false, $body);
}
public function removeNetwork(string $name, bool $force = false): void
{
$this->request('DELETE', '/networks/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------
/**
* Issues a request and JSON-decodes the response body.
*
* @param array<string,string> $query
* @param bool $expectEmptyBody set true for endpoints that reply 204/200 with no/irrelevant body
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new PodmanApiException('Expected a JSON object/array response from ' . $path);
}
return $decoded;
}
/**
* Issues a request and returns the raw response body as a string,
* without JSON decoding — used for endpoints whose response isn't
* JSON (logs, exec start) and internally by request().
*
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_UNIX_SOCKET_PATH => $this->socketPath,
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
if ($jsonBody !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonBody));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json']);
}
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
throw new PodmanApiException(
"Could not reach podman API socket ({$this->socketPath}): {$error}. Is rc.podman running?",
0
);
}
if ($status >= 400) {
$detail = self::extractErrorMessage($body ?: '');
throw new PodmanApiException("Podman API {$method} {$path} failed ({$status}): {$detail}", $status);
}
return $body === false ? '' : $body;
}
/** Best-effort extraction of libpod's {"cause":...,"message":...} error body shape. */
private static function extractErrorMessage(string $body): string
{
$decoded = json_decode($body, true);
if (is_array($decoded) && isset($decoded['message']) && is_string($decoded['message'])) {
return $decoded['message'];
}
return $body !== '' ? $body : '(no response body)';
}
/**
* Strips Docker/Podman's attach-stream frame headers from a
* non-TTY multiplexed stdout/stderr stream. Each frame is an 8-byte
* header — [stream type (1 byte), 0, 0, 0, big-endian uint32 length]
* — followed by that many bytes of payload. Stream type 1 = stdout,
* 2 = stderr; both are concatenated here since the UI just needs
* readable log text, not separated channels.
*/
private static function demuxStream(string $raw): string
{
if ($raw === '') {
return '';
}
// If the stream doesn't start with a recognizable frame header,
// assume it's already plain text (e.g. a TTY-attached container's
// logs, which libpod does not frame) and return it as-is.
$firstByte = ord($raw[0]);
if ($firstByte > 2) {
return $raw;
}
$out = '';
$offset = 0;
$len = strlen($raw);
while ($offset + 8 <= $len) {
$header = substr($raw, $offset, 8);
$unpacked = unpack('Ctype/C3pad/Nsize', $header);
if ($unpacked === false) {
break;
}
$frameLen = $unpacked['size'];
$offset += 8;
if ($offset + $frameLen > $len) {
// Truncated final frame — take what's left and stop.
$out .= substr($raw, $offset);
break;
}
$out .= substr($raw, $offset, $frameLen);
$offset += $frameLen;
}
return $out;
}
}
@@ -0,0 +1,37 @@
<?php
/**
* bootstrap.php
*
* Required by every file under webui/plugins/podman/ajax/ as its very
* first line. Centralizes the three things every endpoint would otherwise
* repeat: loading the other include/ modules, turning any PodmanApiException
* into the same JSON error envelope helpers.php's podman_json_error()
* produces (so the frontend never has to special-case "the socket was
* unreachable" vs. "podman returned a 404" vs. "something else threw"),
* and constructing a ready-to-use PodmanClient from the on-disk config.
*
* Usage, at the top of an ajax/*.php file:
* require __DIR__ . '/../include/bootstrap.php';
* // $client (PodmanClient) and $podmanConfig (PodmanConfig) are now set.
*/
declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
// A 0 status means "couldn't even reach the socket" (transport
// failure) rather than an HTTP error podman itself returned —
// surfaced as 503 (Service Unavailable) since that's the more
// accurate signal to the frontend than a generic 500.
$status = $e->httpStatus > 0 ? $e->httpStatus : 503;
podman_json_error($e->getMessage(), $status);
}
podman_json_error('Internal error: ' . $e->getMessage(), 500);
});
$podmanConfig = PodmanConfig::load();
$client = $podmanConfig->newClient();
+107
View File
@@ -0,0 +1,107 @@
<?php
/**
* helpers.php
*
* Small, stateless formatting helpers shared by the ajax/*.php endpoints.
* Kept separate from PodmanClient (which only knows the API) and from the
* endpoints themselves (which only know their one resource), so the same
* "format 1610612736 bytes as 1.5 GB" logic isn't duplicated across
* images.php, volumes.php, and system.php.
*/
declare(strict_types=1);
function podman_format_bytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = (int) floor(log($bytes, 1024));
$i = min($i, count($units) - 1);
$value = $bytes / (1024 ** $i);
return sprintf($value >= 100 || $i === 0 ? '%.0f %s' : '%.1f %s', $value, $units[$i]);
}
/** Formats a duration in seconds as a compact "14d 6h" / "6h 12m" / "38m" style string. */
function podman_format_duration(int $seconds): string
{
if ($seconds < 60) {
return $seconds . 's';
}
$days = intdiv($seconds, 86400);
$hours = intdiv($seconds % 86400, 3600);
$minutes = intdiv($seconds % 3600, 60);
if ($days > 0) {
return "{$days}d {$hours}h";
}
if ($hours > 0) {
return "{$hours}h {$minutes}m";
}
return "{$minutes}m";
}
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
function podman_parse_time(?string $rfc3339): ?int
{
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
return null;
}
$ts = strtotime($rfc3339);
return $ts === false ? null : $ts;
}
/**
* Shortens a full image/container ID to the 12-character form Docker/
* Podman CLIs conventionally display, matching what users expect to see
* (and copy-paste into `podman inspect <id>`, which accepts short IDs).
*/
function podman_short_id(string $id): string
{
// Some APIs prefix with "sha256:" for image IDs.
$id = str_starts_with($id, 'sha256:') ? substr($id, 7) : $id;
return substr($id, 0, 12);
}
/**
* Sends a JSON response and terminates the request — every ajax/*.php
* endpoint's single exit point, so response shape (envelope with "ok" and
* either "data" or "error") is consistent for the frontend's shared AJAX
* helper (javascript/app.js's request() function) to rely on.
*/
function podman_json_response(mixed $data, int $httpStatus = 200): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['ok' => $httpStatus < 400, 'data' => $data], JSON_UNESCAPED_SLASHES);
exit;
}
function podman_json_error(string $message, int $httpStatus = 500): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Reads and JSON-decodes the request body for POST/DELETE actions that
* take parameters (e.g. {"id": "..."}), with a friendly error on
* malformed input instead of a fatal error deep inside an endpoint.
*
* @return array<string,mixed>
*/
function podman_read_json_body(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
podman_json_error('Request body must be a JSON object', 400);
}
return $decoded;
}