<?phpnamespace App\Xapi;use JumPHP\Async\Task;/** * The XAPI client: JSON-RPC 2.0 on POST /jsonrpc (what XenCenter does with XML-RPC), a session made by * session.login_with_password, inserted as the first parameter of every other call. * * $client->login('root', $password)->then(fn (string $session) => ...); * $client->call('VM.get_all_records')->then(fn (array $records) => ...); * $client->call('Async.VM.start', $vmRef, false, false)->then(fn (string $taskRef) => ...); * $client->events(['vm', 'host'], $token, 30.0)->then(fn (array $batch) => ...); // event.from: what changed since $token * $client->rrdUpdates(time() - 600, true, null)->then(fn (Rrd $rrd) => ...); */final class XapiClient{ public const API_VERSION = '2.21'; public const ORIGINATOR = 'XenCenter-JumPHP'; public ?string $session = null; private int $id = 0; public function __construct(public readonly Transport $transport) { } public function login(string $user, string $password): Task { $task = new Task(); $this->raw('session.login_with_password', [$user, $password, self::API_VERSION, self::ORIGINATOR])->then( function (mixed $session) use ($task): void { $this->session = (string)$session; $task->settle($this->session); }, fn (\Throwable $e) => $task->settle(null, $e), ); return $task; } public function logout(): void { if ($this->session !== null) { $this->raw('session.logout', [$this->session]); $this->session = null; } } /** A call with the session first: call('VM.clean_shutdown', $ref). */ public function call(string $method, mixed ...$params): Task { if ($this->session === null) { $task = new Task(); $task->settle(null, new XapiError(['SESSION_INVALID'])); return $task; } return $this->raw($method, [$this->session, ...$params]); } /** @param list<string> $classes */ public function events(array $classes, string $token, float $timeout): Task { return $this->raw('event.from', [(string)$this->session, $classes, $token, $timeout], 'events'); } /** /rrd_updates: the performance figures of the last seconds (host and its VMs, or one VM). */ public function rrdUpdates(int $start, bool $host, ?string $vmUuid = null, int $interval = 5): Task { $task = new Task(); $query = http_build_query(array_filter(['session_id' => $this->session, 'start' => $start, 'cf' => 'AVERAGE', 'interval' => $interval, 'host' => $host ? 'true' : null, 'vm_uuid' => $vmUuid], fn ($v) => $v !== null)); $this->transport->request('GET', '/rrd_updates?' . $query, '', [], 'rrd')->then( fn (HttpReply $reply) => $reply->ok() ? $task->settle(Rrd::parse($reply->body)) : $task->settle(null, XapiError::transport("rrd_updates answered HTTP {$reply->status}")), fn (\Throwable $e) => $task->settle(null, $e), ); return $task; } /** @param list<mixed> $params */ public function raw(string $method, array $params, string $channel = 'calls'): Task { $task = new Task(); $body = json_encode(['jsonrpc' => '2.0', 'method' => $method, 'params' => $params, 'id' => ++$this->id], JSON_UNESCAPED_SLASHES); $this->transport->request('POST', '/jsonrpc', (string)$body, ['Content-Type' => 'application/json'], $channel)->then( function (HttpReply $reply) use ($task, $method): void { $task->settle(...self::decode($reply, $method)); }, fn (\Throwable $e) => $task->settle(null, $e instanceof XapiError ? $e : XapiError::transport($e->getMessage())), ); return $task; } /** @return array{0: mixed, 1: ?\Throwable} */ public static function decode(HttpReply $reply, string $method): array { if ($reply->status === 404 || $reply->status === 405) { return [null, XapiError::transport("This server has no JSON-RPC API (HTTP {$reply->status}): XAPI 1.290 / XenServer 7.3 or newer is needed.")]; } $data = json_decode($reply->body, true); if (!is_array($data)) { return [null, XapiError::transport("Unexpected answer to $method (HTTP {$reply->status}).")]; } if (isset($data['error'])) { $error = $data['error']; $failure = is_array($error['data'] ?? null) ? array_map('strval', array_values($error['data'])) : [(string)($error['message'] ?? 'INTERNAL_ERROR')]; return [null, new XapiError($failure)]; } return [$data['result'] ?? null, null]; }}