UK
games/xencenter-xenadmin-xcp-jumphp / tests / UpdatesXvaAlertsTest.php · commit fb1b7c9
<?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])));    }}