<?phpnamespace App\Feature\Updates;use App\Model\Chain;use App\Model\Connection;use JumPHP\Async\Task;/** * XCP-ng's updates, per server: XCP-ng 8.x gives yum's updates through the host plugin updater.py, * host.call_plugin($host, 'updater.py', 'check_update', {}) -> a JSON list of packages {name, version, release, description, changelog…} * host.call_plugin($host, 'updater.py', 'update', {}) -> installs them (long: Async.host.call_plugin, a task in Events) * After a new kernel, Xen or XAPI the server needs a reboot: "Reboot required" until its boot_time changes. What was found is kept * here (per connection, per host), the Updates tab and the tree show it. */final class Updates{ /** packages whose update only takes effect after a reboot of the server */ public const REBOOT_PACKAGES = '/^(kernel|xen-|xapi|microcode|linux-firmware)/'; /** @var \WeakMap<Connection, array<string, HostUpdates>>|null */ private static ?\WeakMap $state = null; public static function of(Connection $c, string $host): HostUpdates { self::$state ??= new \WeakMap(); $hosts = self::$state[$c] ?? []; $hosts[$host] ??= new HostUpdates(); self::$state[$c] = $hosts; return $hosts[$host]; } /** Asks every server of the connection ($host: one), one after the other (a failure of one does not stop the others). */ public static function check(Connection $c, ?string $host = null): Task { $steps = []; foreach ($host !== null ? [$host] : array_keys($c->cache->hosts()) as $h) { $state = self::of($c, $h); $state->checking = true; $state->error = ''; $steps[] = function () use ($c, $h): Task { $out = new Task(); self::checkOne($c, $h)->then(fn () => $out->settle(true), fn () => $out->settle(true)); return $out; }; } return Chain::run($steps); } private static function checkOne(Connection $c, string $host): Task { $done = new Task(); $state = self::of($c, $host); $state->checking = true; $c->run("Checking for updates on '" . $c->cache->name($host) . "'", 'host.call_plugin', [$host, 'updater.py', 'check_update', []], $host)->then( function (mixed $result) use ($state, $done, $c): void { $state->checking = false; $state->packages = self::parse((string)$result); $state->checkedAt = $c->cache->version; $done->settle(count($state->packages)); }, function (\Throwable $e) use ($state, $done): void { $state->checking = false; $state->error = str_contains($e->getMessage(), 'XENAPI_MISSING_PLUGIN') ? 'This server has no updater.py plugin (it is not XCP-ng 8.x): update it with its own tools.' : $e->getMessage(); $done->settle(null, $e); }, ); return $done; } /** Installs the updates of one server; Task of true when it now needs a reboot. */ public static function install(Connection $c, string $host): Task { $done = new Task(); $state = self::of($c, $host); $packages = $state->packages ?? []; $state->installing = true; $c->run("Installing " . count($packages) . " updates on '" . $c->cache->name($host) . "'", 'host.call_plugin', [$host, 'updater.py', 'update', []], $host)->then( function () use ($c, $host, $state, $packages, $done): void { $state->installing = false; $reboot = self::needsReboot($packages); if ($reboot) { $state->rebootBoot = (string)($c->cache->field($host, 'other_config', [])['boot_time'] ?? '0'); $state->rebootFor = array_values(array_filter(array_column($packages, 'name'), fn ($n) => preg_match(self::REBOOT_PACKAGES, (string)$n))); } $state->packages = null; self::checkOne($c, $host)->then(fn () => null, fn () => null); // what is left (usually nothing) $done->settle($reboot); }, function (\Throwable $e) use ($state, $done): void { $state->installing = false; $state->error = $e->getMessage(); $done->settle(null, $e); }, ); return $done; } /** @param list<array<string, mixed>> $packages */ public static function needsReboot(array $packages): bool { foreach ($packages as $p) { if (preg_match(self::REBOOT_PACKAGES, (string)($p['name'] ?? ''))) { return true; } } return false; } /** * The answer of check_update: a JSON list (or a map name => package, as some versions give). As a task's result it comes wrapped * as an XML-RPC value (<value>…</value>, XML-escaped). * * @return list<array<string, mixed>> */ public static function parse(string $result): array { $text = trim($result); if (preg_match('#^<value>(?:<string>)?(.*?)(?:</string>)?</value>$#s', $text, $m)) { $text = html_entity_decode($m[1], ENT_QUOTES | ENT_XML1); } $data = json_decode($text, true); if (!is_array($data)) { return []; } $out = []; foreach ($data as $key => $p) { if (!is_array($p)) { continue; } $p['name'] = (string)($p['name'] ?? (is_string($key) ? $key : '')); if ($p['name'] !== '') { $out[] = $p; } } usort($out, fn ($a, $b) => strcmp($a['name'], $b['name'])); return $out; } /** "1.249.32-1.1.xcpng8.3": version and release. @param array<string, mixed> $p */ public static function version(array $p, string $prefix = ''): string { $v = (string)($p[$prefix . 'version'] ?? ''); $r = (string)($p[$prefix . 'release'] ?? ''); return $v === '' ? '' : $v . ($r !== '' ? '-' . $r : ''); } /** The packages to update on a server (0: none, or never asked). */ public static function pending(Connection $c, string $host): int { return count(self::of($c, $host)->packages ?? []); } /** Servers with updates waiting or a reboot to do get a word after their name in the tree. @param list<array<string, mixed>> $nodes */ public static function markTree(array $nodes, \App\Model\Session $session): array { $marks = []; foreach ($session->connections as $c) { foreach (self::$state[$c] ?? [] as $host => $state) { if ($state->rebootRequired($c, $host)) { $marks[$host] = ' (reboot required)'; } elseif (($n = count($state->packages ?? [])) > 0) { $marks[$host] = " ($n " . ($n === 1 ? 'update' : 'updates') . ')'; } } } if ($marks === []) { return $nodes; } $walk = function (array $nodes) use (&$walk, $marks): array { foreach ($nodes as &$node) { if (isset($marks[$node['id'] ?? ''])) { $node['suffix'] = ($node['suffix'] ?? '') . $marks[$node['id']]; } if (!empty($node['children'])) { $node['children'] = $walk($node['children']); } } return $nodes; }; return $walk($nodes); } /** Forgets everything (tests). */ public static function reset(): void { self::$state = null; }}