Add reproducible build system, native Unraid plugin, and WebUI
- 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:
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user