Terminal panel now opens a genuinely interactive shell (ttyd bound to a unix socket, proxied through Unraid's own /logterminal/ nginx location — the same mechanism Unraid's own Docker "Console" button uses) instead of one-shot exec calls, shown inline with a Disconnect action; bash is the default shell. Container/shell selectors and action buttons are now correctly bottom-aligned (root cause: Unraid's theme puts a 10px margin on every <button>, never reset before). Destructive actions (Disconnect, Compose/Template Delete, Volumes/Images/ Networks Remove) get a consistent, solid red treatment at rest instead of only tinting on hover, via new --bad-strong/--bad-contrast tokens. Settings panel restructured: a real save toolbar instead of a button buried in an empty-label row, card subtitles, a toggle switch instead of a bare checkbox, and installed-package versions shown as chips. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
535 lines
22 KiB
PHP
535 lines
22 KiB
PHP
<?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']);
|
|
}
|
|
|
|
/**
|
|
* POST /containers/create — takes a libpod SpecGenerator body. Field
|
|
* names/shapes below (image, name, command, env, portmappings,
|
|
* netns, networks, mounts, volumes, restart_policy, privileged) were
|
|
* verified live against a real podman system service, not assumed
|
|
* from docs — see ajax/containers.php's create action, which builds
|
|
* this array from the WebUI's Create Container form.
|
|
*
|
|
* @param array<string,mixed> $spec
|
|
* @return string the new container's ID
|
|
*/
|
|
public function createContainer(array $spec): string
|
|
{
|
|
// Longer than this client's normal 15s operation timeout as cheap
|
|
// insurance: creating a container involves setting up its mounts
|
|
// (often onto Unraid array/spinning-disk shares, not the cache
|
|
// pool) and network namespace, which can occasionally run past 15s
|
|
// even with the image already pulled — found live via a real
|
|
// "Operation timed out after 15001 milliseconds" error creating a
|
|
// container. Same 600s ceiling as pullImage(), safely under
|
|
// nginx's 640s fastcgi_read_timeout.
|
|
$result = $this->request('POST', '/containers/create', [], false, $spec, 600);
|
|
return (string) ($result['Id'] ?? '');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public function pauseContainer(string $id): void
|
|
{
|
|
$this->request('POST', '/containers/' . rawurlencode($id) . '/pause', [], true);
|
|
}
|
|
|
|
public function unpauseContainer(string $id): void
|
|
{
|
|
$this->request('POST', '/containers/' . rawurlencode($id) . '/unpause', [], true);
|
|
}
|
|
|
|
/** $signal accepts both a name ("SIGKILL") and a bare number, matching libpod's own `kill?signal=` parsing. */
|
|
public function killContainer(string $id, string $signal = 'SIGKILL'): void
|
|
{
|
|
$this->request('POST', '/containers/' . rawurlencode($id) . '/kill', ['signal' => $signal], true);
|
|
}
|
|
|
|
public function renameContainer(string $id, string $newName): void
|
|
{
|
|
$this->request('POST', '/containers/' . rawurlencode($id) . '/rename', ['name' => $newName], 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).
|
|
// -------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------
|
|
// 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');
|
|
}
|
|
|
|
/**
|
|
* POST /pods/create — takes a body of {name, portmappings, ...}.
|
|
* Verified live against a real podman system service: {"name":"...",
|
|
* "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]}
|
|
* creates a pod with a shared infra container whose port bindings apply
|
|
* to every member container — see ajax/pods.php's build_pod_spec().
|
|
*
|
|
* @param array<string,mixed> $spec
|
|
* @return string the new pod's ID
|
|
*/
|
|
public function createPod(array $spec): string
|
|
{
|
|
$result = $this->request('POST', '/pods/create', [], false, $spec);
|
|
return (string) ($result['Id'] ?? '');
|
|
}
|
|
|
|
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 restartPod(string $name, int $timeoutSeconds = 10): void
|
|
{
|
|
$this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['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".
|
|
*
|
|
* Unlike virtually every other libpod endpoint, a successful pull's
|
|
* response body is NOT one JSON document — it's newline-delimited
|
|
* JSON, one progress object per line (verified live:
|
|
* `{"status":"pulling","stream":"..."}` repeated, then a final
|
|
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
|
|
* that whole blob through the normal single-document request() here
|
|
* made json_decode() fail on every successful pull with "Expected a
|
|
* JSON object/array response from /images/pull" — found by
|
|
* live-testing a real pull through the WebUI's Images panel, not
|
|
* from reading libpod's docs. An error that happens before any
|
|
* image data is found (e.g. unknown reference) is unaffected: libpod
|
|
* sends that as a normal single-JSON-object 4xx response, which
|
|
* requestRaw()/request()'s existing status>=400 handling already
|
|
* covers correctly.
|
|
*/
|
|
public function pullImage(string $reference): array
|
|
{
|
|
// A real image (e.g. a Plex/media-server image, easily several
|
|
// hundred MB) routinely takes far longer than this client's normal
|
|
// 15s operation timeout to download — found live: a pull aborted
|
|
// mid-stream with "Operation timed out after 15001 milliseconds"
|
|
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
|
|
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
|
|
// plugin requests, so 600s here stays safely under that.
|
|
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
|
|
|
|
$last = null;
|
|
foreach (explode("\n", trim($raw)) as $line) {
|
|
$line = trim($line);
|
|
if ($line === '') {
|
|
continue;
|
|
}
|
|
$decoded = json_decode($line, true);
|
|
if (!is_array($decoded)) {
|
|
continue;
|
|
}
|
|
// A mid-stream error (pull started, then failed — e.g. the
|
|
// connection dropped partway through a layer) is reported as
|
|
// an {"error": "..."} line rather than an HTTP error status,
|
|
// since headers/status are already committed by the time
|
|
// libpod knows the pull failed.
|
|
if (isset($decoded['error'])) {
|
|
throw new PodmanApiException((string) $decoded['error'], 502);
|
|
}
|
|
$last = $decoded;
|
|
}
|
|
|
|
if ($last === null) {
|
|
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
|
|
}
|
|
return $last;
|
|
}
|
|
|
|
public function removeImage(string $id, bool $force = false): void
|
|
{
|
|
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
|
}
|
|
|
|
/**
|
|
* POST /images/prune?all=true — removes every image with zero containers
|
|
* (running or stopped) referencing it, matching this app's own "Used By"
|
|
* column — not just dangling/untagged images. Verified live: a tagged
|
|
* but unused image IS removed with all=true (found the hard way: it
|
|
* also removed every image on a host with no containers at all, which
|
|
* is correct behavior, just aggressive — see ajax/images.php's prune
|
|
* action for the confirmation-copy this justifies).
|
|
*
|
|
* @return array<int,array{Id:string,Size:int}> one entry per removed image
|
|
*/
|
|
public function pruneImages(): array
|
|
{
|
|
return $this->request('POST', '/images/prune', ['all' => 'true']);
|
|
}
|
|
|
|
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
|
|
public function tagImage(string $id, string $repo, string $tag): void
|
|
{
|
|
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Volumes
|
|
// -------------------------------------------------------------------
|
|
|
|
public function listVolumes(): array
|
|
{
|
|
return $this->request('GET', '/volumes/json');
|
|
}
|
|
|
|
/**
|
|
* $hostPath, if given, binds the volume directly to an existing host
|
|
* directory instead of a podman-managed one — the local driver's
|
|
* `type=none,o=bind,device=<path>` option trio (same mechanism
|
|
* `podman volume create --opt type=none --opt o=bind --opt device=...`
|
|
* uses on the CLI). Verified live: a container mounting such a volume
|
|
* reads/writes the host path directly, not an internal copy.
|
|
*/
|
|
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
|
|
{
|
|
$body = ['Name' => $name, 'Driver' => $driver];
|
|
if ($hostPath !== null && $hostPath !== '') {
|
|
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
|
|
}
|
|
return $this->request('POST', '/volumes/create', [], false, $body);
|
|
}
|
|
|
|
public function removeVolume(string $name, bool $force = false): void
|
|
{
|
|
$this->request('DELETE', '/volumes/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Networks
|
|
// -------------------------------------------------------------------
|
|
|
|
public function listNetworks(): array
|
|
{
|
|
return $this->request('GET', '/networks/json');
|
|
}
|
|
|
|
/**
|
|
* $parentInterface (only meaningful for driver="macvlan") attaches the
|
|
* network directly to an existing host bridge/VLAN interface (e.g.
|
|
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
|
|
* libpod's "network_interface" field — verified live: containers on
|
|
* such a network get a real address on that LAN/VLAN's own subnet,
|
|
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
|
|
* network type. See ajax/networks.php's macvlan_parent_interfaces()
|
|
* for where the interface list itself comes from.
|
|
*/
|
|
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
|
|
{
|
|
$body = ['name' => $name, 'driver' => $driver];
|
|
if ($subnet !== null) {
|
|
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
|
|
}
|
|
if ($parentInterface !== null && $parentInterface !== '') {
|
|
$body['network_interface'] = $parentInterface;
|
|
}
|
|
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, ?int $timeoutSeconds = null): array
|
|
{
|
|
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
|
|
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, ?int $timeoutSeconds = 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 => $timeoutSeconds ?? $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;
|
|
}
|
|
}
|