<?phpnamespace Tests;use App\Feature\Updates\Updates;use App\Form\AlertsForm;use App\Form\ConfirmForm;use App\Form\ExportForm;use App\Form\ImportForm;use App\Form\MainForm;use App\Form\PropertiesForm;use App\Model\Connection;use App\Model\LogEntry;use App\Xapi\HttpReply;use App\Xapi\HttpStreamTransfer;use App\Xapi\Perfmon;use App\Xapi\Xva;use Evenement\EventEmitter;use JumPHP\Kernel\Engine;use JumPHP\Testing\GameTestCase;/** XCP-ng updates, XVA export / import, configurable alerts: through the window, against the demo pool (FakeXapi). */final class UpdatesXvaAlertsTest extends GameTestCase{ private MainForm $form; /** @var list<string> files to remove */ private array $files = []; protected function tearDown(): void { foreach ($this->files as $file) { @unlink($file); } parent::tearDown(); } private function connectDemo(): Connection { Engine::get()->storage->delete('servers.json'); $this->game->start(MainForm::class); /** @var MainForm $form */ $form = $this->game->scene(); $form->session->reset(); $this->game->start(MainForm::class, ['connectDemo' => true]); $this->form = $form; $this->game->run(10); $c = $form->session->connections[0]; self::assertTrue($c->isConnected(), 'the demo pool connects: ' . $c->error); return $c; } private function ref(Connection $c, string $class, string $name): string { foreach ($c->cache->all($class) as $ref => $r) { if ($r['name_label'] === $name) { return (string)$ref; } } self::fail("no $class $name"); } private function until(callable $condition, int $frames = 900): void { for ($i = 0; $i < $frames && !$condition(); $i++) { $this->game->run(1); } self::assertTrue((bool)$condition(), 'still waiting after ' . $frames . ' frames'); } private function temp(string $name): string { $file = sys_get_temp_dir() . '/xcp-test-' . getmypid() . '-' . $name; @unlink($file); $this->files[] = $file; return $file; } /** @return list<string> the first cell of each line of the list shown */ private function gridTexts(int $cell = 0): array { return array_map(fn ($row) => is_array($row['cells'][$cell]) ? (string)$row['cells'][$cell]['text'] : (string)$row['cells'][$cell], $this->form->grid->rows); } private function entry(string $start): ?LogEntry { foreach (array_reverse($this->form->session->log) as $e) { if (str_starts_with($e->title, $start)) { return $e; } } return null; } // updates ----------------------------------------------------------------------------------------------------------------- public function testTheUpdatesTabListsThePackagesOfEachServerAndTheTreeMarksThem(): void { $c = $this->connectDemo(); $one = $this->ref($c, 'host', 'xcp-ng-01'); $two = $this->ref($c, 'host', 'xcp-ng-02'); $this->form->navigate((string)$c->cache->poolRef()); $this->form->tabs->selectTab('Updates'); $this->until(fn () => Updates::of($c, $one)->packages !== null && Updates::of($c, $two)->packages !== null); $this->game->run(20); self::assertSame(9, Updates::pending($c, $one)); self::assertSame(5, Updates::pending($c, $two)); $texts = $this->gridTexts(); self::assertSame('xcp-ng-01 (pool master)', $texts[0], 'the master first'); self::assertStringStartsWith('9 updates', $this->gridTexts(1)[0]); $xapi = array_search(' xapi-core', $texts, true); self::assertNotFalse($xapi); self::assertSame('24.19.1-1.4.xcpng8.3 → 24.19.2-1.6.xcpng8.3', $this->gridTexts(1)[$xapi], 'installed → new'); self::assertStringContainsString('(9 updates)', (string)$this->form->tree->node($one)['suffix']); self::assertStringContainsString('(5 updates)', (string)$this->form->tree->node($two)['suffix']); self::assertContains('host.call_plugin', array_map(fn ($m) => preg_replace('/^Async\./', '', $m), $c->fake()?->calls ?? [])); $this->game->shot('updates'); } public function testInstallingTheUpdatesOfAServerAsksThenSaysARebootIsRequiredUntilItReboots(): void { $c = $this->connectDemo(); $two = $this->ref($c, 'host', 'xcp-ng-02'); $this->form->navigate($two); $this->form->tabs->selectTab('Updates'); $this->until(fn () => Updates::pending($c, $two) === 5); $this->form->command('updates.install'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ConfirmForm::class, $dialog); self::assertStringContainsString("Install 5 updates on server 'xcp-ng-02'", $dialog->message->text); self::assertStringContainsString('needs a reboot', $dialog->message->text); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => Updates::of($c, $two)->packages === [] && !Updates::of($c, $two)->installing); $this->game->run(20); self::assertTrue(Updates::of($c, $two)->rebootRequired($c, $two)); self::assertStringContainsString('reboot required', $this->gridTexts(1)[0]); self::assertStringContainsString('(reboot required)', (string)$this->form->tree->node($two)['suffix']); self::assertSame(LogEntry::DONE, $this->entry('Installing 5 updates')?->status); $this->game->shot('reboot-required'); // the server rebooted (its VMs moved away first): no more reboot to do $this->form->command('host.maintenance', $two, true); $this->until(fn () => $c->cache->vmsOn($two) === [], 1200); $this->form->command('host.reboot', $two, true); $this->until(fn () => !Updates::of($c, $two)->rebootRequired($c, $two), 1200); } public function testARollingPoolUpdateGoesThroughEveryServerMasterFirstAndTheVmsKeepRunning(): void { $c = $this->connectDemo(); $one = $this->ref($c, 'host', 'xcp-ng-01'); $two = $this->ref($c, 'host', 'xcp-ng-02'); $running = count(array_filter($c->cache->vms(), fn ($vm) => $vm['power_state'] === 'Running')); $boots = [$c->cache->field($one, 'other_config')['boot_time'], $c->cache->field($two, 'other_config')['boot_time']]; $this->form->navigate((string)$c->cache->poolRef()); $this->form->command('updates.check'); $this->until(fn () => Updates::pending($c, $one) > 0 && Updates::pending($c, $two) > 0); $this->form->tabs->selectTab('Updates'); $this->game->run(2); $this->form->command('updates.rolling'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ConfirmForm::class, $dialog); self::assertStringContainsString('xcp-ng-01, xcp-ng-02', $dialog->message->text, 'the master first'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => ($e = $this->entry('Rolling pool update')) !== null && $e->status !== LogEntry::RUNNING, 4000); self::assertSame(LogEntry::DONE, $this->entry('Rolling pool update')?->status, (string)$this->entry('Rolling pool update')?->detail); $this->game->run(20); $titles = array_map(fn (LogEntry $e) => $e->title, $this->form->session->log); self::assertLessThan(array_search("Entering maintenance mode on 'xcp-ng-02'", $titles, true), array_search("Entering maintenance mode on 'xcp-ng-01'", $titles, true)); self::assertContains("Rebooting server 'xcp-ng-01'", $titles); self::assertContains("Rebooting server 'xcp-ng-02'", $titles); foreach ([$one, $two] as $i => $host) { self::assertTrue((bool)$c->cache->field($host, 'enabled'), 'out of maintenance mode'); self::assertSame(0, Updates::pending($c, $host)); self::assertNotSame($boots[$i], $c->cache->field($host, 'other_config')['boot_time'], 'rebooted'); self::assertFalse(Updates::of($c, $host)->rebootRequired($c, $host)); self::assertStringNotContainsString('update', (string)($this->form->tree->node($host)['suffix'] ?? ''), 'the tree has nothing to say any more'); } self::assertSame($running, count(array_filter($c->cache->vms(), fn ($vm) => $vm['power_state'] === 'Running')), 'no VM stopped'); $this->game->shot('rolling-update'); } // XVA --------------------------------------------------------------------------------------------------------------------- public function testAHaltedVmIsExportedAsAnXvaFileThenImportedBack(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'backup-srv'); $this->form->navigate($vm); $this->game->run(2); $items = array_column(array_filter(\App\View\Commands::vmItems($c, $vm), 'is_array'), 'enabled', 'text'); self::assertTrue($items['Export…']); // uncompressed: a Content-Length, then compressed: read until the server closes $plain = $this->temp('backup-srv.xva'); $packed = $this->temp('backup-srv.xva.gz'); foreach ([[$plain, false], [$packed, true]] as [$file, $compress]) { $this->form->command('vm.export'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ExportForm::class, $dialog); self::assertTrue($dialog->export->enabled); $dialog->path->text = $file; $dialog->compress->checked = $compress; if (!$compress) { $this->game->run(1); $this->game->shot('export-dialog'); } $dialog->click($dialog->export); $this->game->run(1); self::assertSame($this->form, $this->game->scene()); $this->until(fn () => ($e = $this->entry("Exporting VM 'backup-srv' to " . basename($file))) !== null && $e->status !== LogEntry::RUNNING); self::assertSame(LogEntry::DONE, $this->entry("Exporting VM 'backup-srv' to " . basename($file))?->status, (string)$this->entry("Exporting VM 'backup-srv'")?->detail); } self::assertGreaterThan(2 * 1048576, filesize($plain), 'ova.xml and the 2 MiB of its disk'); $info = Xva::readHeader(Xva::headOf($plain)); self::assertSame('backup-srv', $info['name_label'] ?? null); self::assertSame(4, $info['VCPUs_max']); if (function_exists('gzencode')) { self::assertLessThan(filesize($plain), filesize($packed)); self::assertSame('backup-srv', Xva::readHeader(Xva::headOf($packed))['name_label'] ?? null, 'ova.xml read from the gzip stream'); } // and back, onto the local storage of the second server $before = count($c->cache->vms()); $this->form->command('file.import'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ImportForm::class, $dialog); $dialog->path->text = $packed; $dialog->onFileChange($dialog->path, $packed); self::assertStringContainsString("VM 'backup-srv': 4 vCPUs, 8.0 GB of memory, disks backup-srv 0 (120 GB)", $dialog->info->text); $dialog->home->selected = (int)array_search('xcp-ng-02', $dialog->home->items, true); $dialog->sr->selected = (int)array_search(true, array_map(fn ($n) => str_starts_with($n, 'NFS storage'), $dialog->sr->items), true); $this->game->run(1); $this->game->shot('import-dialog'); $dialog->click($dialog->import); $this->game->run(1); $this->until(fn () => ($e = $this->entry("Importing 'backup-srv'")) !== null && $e->status !== LogEntry::RUNNING); self::assertSame(LogEntry::DONE, $this->entry("Importing 'backup-srv'")?->status, (string)$this->entry("Importing 'backup-srv'")?->detail); $this->game->run(20); self::assertCount($before + 1, $c->cache->vms()); $copies = array_filter($c->cache->vms(), fn ($r) => $r['name_label'] === 'backup-srv'); self::assertCount(2, $copies); $new = (string)array_key_last(array_filter($copies, fn ($r, $ref) => $ref !== $vm, ARRAY_FILTER_USE_BOTH)); self::assertSame('Halted', $c->cache->field($new, 'power_state')); self::assertSame($this->ref($c, 'host', 'xcp-ng-02'), $c->cache->field($new, 'affinity'), 'its home server'); self::assertSame([], $c->fake()?->db['task'] ? array_filter($c->fake()->db['task'], fn ($t) => $t['status'] === 'pending') : [], 'no task left behind'); $this->form->navigate((string)$c->cache->poolRef()); $this->form->tabs->selectTab('Logs'); $this->game->run(2); $this->game->shot('xva-logs'); } public function testARunningVmCannotBeExportedAndABadFileIsNotImported(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'web-01'); $this->form->navigate($vm); $this->form->command('vm.export'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ExportForm::class, $dialog); self::assertFalse($dialog->export->enabled); self::assertStringContainsString('Shut the VM down first', $dialog->error->text); $dialog->click($dialog->cancel); $this->game->run(1); $bad = $this->temp('not-an.xva'); file_put_contents($bad, str_repeat('x', 4000)); $this->form->command('file.import'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(ImportForm::class, $dialog); $dialog->path->text = $bad; $dialog->click($dialog->import); self::assertSame('This file is not an XVA export.', $dialog->error->text); self::assertInstanceOf(ImportForm::class, $this->game->scene(), 'the dialog stays'); } public function testTheStreamTransferDecodesChunksAsTheyComeAndUploadsAFewChunksAFrame(): void { $stream = new class extends EventEmitter { public string $written = ''; public function write(string $b): bool { $this->written .= $b; return true; } public function close(): void { $this->emit('close', [null]); $this->removeAllListeners(); } }; $file = $this->temp('chunked.bin'); $t = HttpStreamTransfer::download(fn () => $stream, 'host', '/export?ref=x', $file); $stream->emit('open'); self::assertStringStartsWith("GET /export?ref=x HTTP/1.1\r\nHost: host\r\n", $stream->written); $answer = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n7;x=1\r\n, world\r\n0\r\n\r\n"; foreach (str_split($answer, 3) as $piece) { $stream->emit('data', [$piece]); // cut anywhere: a size line, the CRLF after a chunk... } self::assertTrue($t->isDone()); self::assertSame('hello, world', file_get_contents($file)); // upload: never more than the budget a frame $up = new class extends EventEmitter { public float $bytes = 0; public string $head = ''; public function write(string $b): bool { $this->head === '' ? $this->head = $b : $this->bytes += strlen($b); return true; } public function close(): void { $this->emit('close', [null]); $this->removeAllListeners(); } }; $source = $this->temp('upload.bin'); file_put_contents($source, str_repeat('z', 1000000)); $u = HttpStreamTransfer::upload(fn () => $up, 'host', '/import?sr_id=y', $source); $u->budget = 262144; $up->emit('open'); self::assertStringContainsString("Content-Length: 1000000\r\n", $up->head); $u->poll(0.016); self::assertSame(262144.0, $up->bytes, 'four chunks of 64 KiB the first frame'); self::assertEqualsWithDelta(0.262, $u->progress(), 0.001); for ($i = 0; $i < 4; $i++) { $u->poll(0.016); } self::assertSame(1000000.0, $up->bytes); self::assertSame('waiting', $u->state); $reply = null; $u->task()->then(function (HttpReply $r) use (&$reply): void { $reply = $r; }); $up->emit('data', ["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]); self::assertSame(200, $reply?->status); self::assertSame('ok', $reply?->body); } // alerts ---------------------------------------------------------------------------------------------------------------- public function testAnAlertConfiguredOnAVmRaisesAnAlarmThatShowsInItsLogs(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'web-01'); $this->form->navigate($vm); $this->form->onTreeMenu($this->form->tree, 300, 300); $items = array_column(array_filter($this->form->tree->popup->items, 'is_array'), 'enabled', 'text'); self::assertTrue($items['Alerts…'], 'in the right click menu'); $this->form->tree->close(); $this->form->command('object.alerts'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(AlertsForm::class, $dialog); self::assertTrue($dialog->cpuRow->visible && $dialog->netRow->visible && $dialog->diskRow->visible); self::assertFalse($dialog->memRow->visible || $dialog->srUseRow->visible || $dialog->mailPanel->visible); $dialog->cpuOn->checked = true; $dialog->cpuLevel->text = '1'; $dialog->cpuMinutes->text = '1'; $dialog->netOn->checked = true; $dialog->netLevel->text = '50000'; $dialog->inhibit->text = '30'; $this->game->run(1); $this->game->shot('alerts-dialog'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => str_contains((string)($c->cache->field($vm, 'other_config', [])['perfmon'] ?? ''), 'cpu_usage')); $alarms = Perfmon::parse((string)$c->cache->field($vm, 'other_config')['perfmon']); self::assertEquals(['cpu_usage' => ['level' => 0.01, 'period' => 60, 'inhibit' => 1800], 'network_usage' => ['level' => 51200000.0, 'period' => 300, 'inhibit' => 1800]], $alarms); // a minute and more of the pool's time: perfmon sees the CPU over 1 % and writes its alarm $c->fake()?->poll(40.0); $c->fake()?->poll(40.0); $this->until(fn () => array_filter($c->cache->messages(), fn ($m) => $m['name'] === 'ALARM' && $c->cache->objectOfMessage($m) === $vm) !== []); $this->form->tabs->selectTab('Logs'); $this->game->run(20); $line = array_values(array_filter($this->gridTexts(), fn ($t) => str_starts_with($t, 'CPU usage at'))); self::assertNotEmpty($line, implode(' | ', $this->gridTexts())); self::assertStringEndsWith('(alarm above 1% for 60 s)', $line[0]); $this->game->shot('alarm'); // and no new one during the inhibit period $count = count(array_filter($c->cache->messages(), fn ($m) => $m['name'] === 'ALARM' && $c->cache->objectOfMessage($m) === $vm)); $c->fake()?->poll(70.0); $this->game->run(20); self::assertCount($count, array_filter($c->cache->messages(), fn ($m) => $m['name'] === 'ALARM' && $c->cache->objectOfMessage($m) === $vm)); } public function testThePropertiesOpenTheAlertsAndThePoolSavesItsEmailSettings(): void { $c = $this->connectDemo(); $pool = (string)$c->cache->poolRef(); $this->form->navigate($pool); $this->form->command('object.properties'); $this->game->run(1); $props = $this->game->scene(); self::assertInstanceOf(PropertiesForm::class, $props); self::assertTrue($props->alerts->visible); $props->click($props->alerts); $this->game->run(2); $dialog = $this->game->scene(); self::assertInstanceOf(AlertsForm::class, $dialog); self::assertTrue($dialog->mailPanel->visible); self::assertFalse($dialog->cpuRow->visible); $dialog->mailOn->checked = true; $dialog->mailTo->text = 'not an address'; $dialog->mailHub->text = 'smtp.example.com'; $dialog->click($dialog->ok); self::assertStringContainsString('address to send to', $dialog->error->text); $dialog->mailTo->text = '[email protected]'; $dialog->mailPort->text = '587'; $this->game->run(1); $this->game->shot('mail-dialog'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => ($c->cache->field($pool, 'other_config', [])['ssmtp-mailhub'] ?? '') === 'smtp.example.com:587'); self::assertSame('[email protected]', $c->cache->field($pool, 'other_config')['mail-destination']); // the storage's own alerts $sr = $this->ref($c, 'sr', 'NFS storage'); $this->form->command('object.alerts', $sr); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(AlertsForm::class, $dialog); self::assertTrue($dialog->srUseRow->visible && $dialog->srIoRow->visible); $dialog->srUseOn->checked = true; $dialog->srUseLevel->text = '80'; $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => isset(Perfmon::parse((string)($c->cache->field($sr, 'other_config', [])['perfmon'] ?? ''))['physical_utilisation'])); self::assertEqualsWithDelta(0.8, Perfmon::parse((string)$c->cache->field($sr, 'other_config')['perfmon'])['physical_utilisation']['level'], 0.0001); } public function testPerfmonXmlIsWhatXapiReads(): void { $xml = Perfmon::build(['cpu_usage' => ['level' => 0.9, 'period' => 60, 'inhibit' => 3600], 'memory_free_kib' => ['level' => 1048576.0, 'period' => 300, 'inhibit' => 3600]]); self::assertSame('<config><variable><name value="cpu_usage"/><alarm_trigger_level value="0.9"/><alarm_trigger_period value="60"/><alarm_auto_inhibit_period value="3600"/></variable>' . '<variable><name value="memory_free_kib"/><alarm_trigger_level value="1048576"/><alarm_trigger_period value="300"/><alarm_auto_inhibit_period value="3600"/>' . '<alarm_trigger_sense value="low"/></variable></config>', $xml); self::assertSame(0.9, Perfmon::parse($xml)['cpu_usage']['level']); self::assertTrue(Perfmon::fires('memory_free_kib', 1000.0, 1048576.0), 'free memory alarms go below'); self::assertSame("Free memory at 1000 KB (alarm below 1.0 GB for 300 s)", \App\View\Pages::messageText('ALARM', Perfmon::alarmBody('memory_free_kib', 1000.0, ['level' => 1048576.0, 'period' => 300, 'inhibit' => 3600]))); }}