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 $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); } /** * 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=` 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 $query * @param bool $expectEmptyBody set true for endpoints that reply 204/200 with no/irrelevant body * @param array|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one * @return array */ 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 $query * @param array|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; } }