Files
unraid-podman/webui/plugins/podman/include/PodmanClient.php
T
maggesandClaude Sonnet 5 993e187f59 Add container pause/resume/kill/rename + reusable context-menu component
First increment of the big WebUI feature-parity spec (see task list) —
row-level actions were about to run out of icon-button space, so this adds
a small anchored dropdown menu (app.js openContextMenu) for secondary
per-container actions instead of cramming more buttons into every row.

All four new actions verified live against the real podman API before
wiring up the UI (same discipline as the CSRF/pull/compose bugs found
earlier — field names and response shapes checked against the running
socket, not assumed from docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:56:46 +00:00

499 lines
19 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
{
$result = $this->request('POST', '/containers/create', [], false, $spec);
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).
// -------------------------------------------------------------------
/**
* 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".
*
* 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
{
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
$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);
}
// -------------------------------------------------------------------
// 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');
}
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;
}
}