登录
ZH
games/xencenter-xenadmin-xcp-jumphp / tests / StorageNetworkTest.php · commit fb1b7c9
<?phpnamespace Tests;use App\Form\ConfirmForm;use App\Form\CrossPoolMigrateForm;use App\Form\DiskForm;use App\Form\MainForm;use App\Form\MoveDiskForm;use App\Form\NetworkPropertiesForm;use App\Form\NewNetworkForm;use App\Form\NewSrForm;use App\Form\StrongConfirmForm;use App\Model\Connection;use App\Model\LogEntry;use JumPHP\Kernel\Engine;use JumPHP\Testing\GameTestCase;/** * New storage, new networks, disk moves and cross-pool migration, used through the window against the demo pools (FakeXapi): * each test opens the dialog a person would, fills it, and checks what XAPI was asked and what the pool became. */final class StorageNetworkTest extends GameTestCase{    private MainForm $form;    private function fresh(array $params = []): MainForm    {        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, $params);        $this->form = $form;        return $form;    }    private function connectDemo(): Connection    {        $form = $this->fresh(['connectDemo' => true]);        $this->game->run(10);        $c = $form->session->connections[0];        self::assertTrue($c->isConnected(), 'the demo pool connects: ' . $c->error);        $this->until(fn () => $this->form->tree->node((string)$c->cache->master()) !== null, 60);        return $c;    }    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; last events: ' . implode(' | ',            array_map(fn (LogEntry $e) => $e->title . ' ' . $e->status . ' ' . $e->detail, array_slice($this->form->session->log, -4))));    }    private function ref(Connection $c, string $class, string $name): string    {        foreach ($c->cache->all($class) as $ref => $r) {            if (($r['name_label'] ?? '') === $name || $c->cache->name($ref) === $name) {                return $ref;            }        }        self::fail("no $class '$name'");    }    private function has(Connection $c, string $class, string $name): bool    {        foreach ($c->cache->all($class) as $r) {            if (($r['name_label'] ?? '') === $name) {                return true;            }        }        return false;    }    /** @return list<string> the calls the demo pool received */    private function calls(Connection $c): array    {        return $c->fake()->calls ?? [];    }    /** The dialog just opened (a command pushes it, it shows in the next frame). */    private function dialog(string $class): object    {        $this->game->run(1);        $dialog = $this->game->scene();        self::assertInstanceOf($class, $dialog);        return $dialog;    }    private function button(string $id): \JumPHP\Gui\Widget    {        $button = $this->form->widget('action:' . $id);        self::assertNotNull($button, "a button $id over the page");        return $button;    }    /** An NFS SR made with the wizard. */    private function newNfs(Connection $c, string $name, string $path = '/export/backup'): string    {        $this->form->command('sr.new');        /** @var NewSrForm $dialog */        $dialog = $this->dialog(NewSrForm::class);        $dialog->name->text = $name;        $dialog->nfsServer->text = '192.168.10.21';        $dialog->nfsPath->text = $path;        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'sr', $name));        $this->game->run(20);        return $this->ref($c, 'sr', $name);    }    // storage repositories ---------------------------------------------------------------------------------------------    public function testANewNfsStorageIsCreatedFromTheToolbarAndSharedByEveryServer(): void    {        $c = $this->connectDemo();        self::assertTrue($this->form->newStorage->enabled, 'the toolbar button, a pool selected');        $this->form->click($this->form->newStorage);        /** @var NewSrForm $dialog */        $dialog = $this->dialog(NewSrForm::class);        self::assertSame('NFS virtual disk storage', $dialog->name->text, 'the name XenCenter proposes');        $dialog->click($dialog->create);        self::assertStringContainsString('export path', $dialog->error->text, 'nothing filled: the dialog says what is missing');        $dialog->name->text = 'Backups (NFS)';        $dialog->nfsServer->text = '192.168.10.21';        $dialog->click($dialog->nfsScan);        $this->until(fn () => str_starts_with($dialog->nfsFound->text, 'Exports'), 120);        self::assertSame('/export/xcp', $dialog->nfsPath->text, 'SR.probe filled the first export');        $dialog->nfsPath->text = '/export/backup';        $dialog->nfsVersion->select(1);        $this->game->shot('new-sr-nfs');        $dialog->click($dialog->create);        $this->game->run(1);        self::assertSame($this->form, $this->game->scene());        $this->until(fn () => $this->has($c, 'sr', 'Backups (NFS)'));        $this->game->run(20);        $sr = $this->ref($c, 'sr', 'Backups (NFS)');        self::assertContains('Async.SR.create', $this->calls($c));        self::assertSame('nfs', $c->cache->field($sr, 'type'));        self::assertTrue($c->cache->field($sr, 'shared'));        self::assertCount(count($c->cache->hosts()), (array)$c->cache->field($sr, 'PBDs'), 'one PBD per server, plugged');        $pbd = (string)((array)$c->cache->field($sr, 'PBDs'))[0];        self::assertSame(['server' => '192.168.10.21', 'serverpath' => '/export/backup', 'nfsversion' => '4'], $c->cache->field($pbd, 'device_config'));        self::assertContains($sr, $this->form->tree->visibleIds(), 'under the pool in the tree');        $this->form->navigate($sr);        $this->game->run(2);        self::assertSame('OK', $this->form->sheet->value('Status', 'State:'));        $this->game->shot('new-sr-done');    }    public function testAnIscsiStorageIsScannedForItsLunsThenCreated(): void    {        $c = $this->connectDemo();        $this->form->command('sr.new');        /** @var NewSrForm $dialog */        $dialog = $this->dialog(NewSrForm::class);        $dialog->kind->select(array_search('iscsi', array_keys(NewSrForm::KINDS), true));        $this->game->run(1);           // the panel shown is laid out        self::assertTrue($dialog->iscsiPanel->visible);        self::assertFalse($dialog->nfsPanel->visible);        $dialog->iscsiTarget->text = '10.0.10.30';        $dialog->click($dialog->iscsiScan);        $this->until(fn () => count($dialog->luns) === 2, 240);        self::assertSame('iqn.2024-01.local.demo:san', $dialog->iscsiIqn->text, "the target's IQN, found by the probe");        self::assertCount(2, $dialog->iscsiLun->items);        $dialog->iscsiLun->select(1);        $this->game->shot('new-sr-iscsi');        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'sr', 'iSCSI virtual disk storage'));        $sr = $this->ref($c, 'sr', 'iSCSI virtual disk storage');        self::assertSame('lvmoiscsi', $c->cache->field($sr, 'type'));        self::assertEqualsWithDelta(2048 * 1073741824.0, (float)$c->cache->field($sr, 'physical_size'), 1, 'the size of LUN 1');        $calls = $this->calls($c);        self::assertLessThan(array_search('Async.SR.create', $calls, true), array_search('SR.probe', $calls, true), 'probed, then created');    }    public function testAnSmbIsoLibraryAndALocalExtStorageOnOneServer(): void    {        $c = $this->connectDemo();        $host = $this->ref($c, 'host', 'xcp-ng-02');        $this->form->navigate($host);        $this->game->run(2);        $this->form->command('sr.new', $host);        /** @var NewSrForm $dialog */        $dialog = $this->dialog(NewSrForm::class);        $dialog->kind->select(array_search('iso-smb', array_keys(NewSrForm::KINDS), true));        $dialog->smbServer->text = '\\\\nas\\isos';        $dialog->smbUser->text = 'xcp';        $dialog->smbPassword->text = 'secret';        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'sr', 'SMB ISO library'));        $iso = $this->ref($c, 'sr', 'SMB ISO library');        self::assertSame(['iso', 'iso'], [$c->cache->field($iso, 'type'), $c->cache->field($iso, 'content_type')]);        self::assertSame(['iso_type' => 'cifs'], $c->cache->field($iso, 'sm_config'));        self::assertSame('//nas/isos', $c->cache->field((string)$c->cache->field($iso, 'PBDs')[0], 'device_config')['location'], 'the share written //server/share');        // a local EXT SR: on the server it was opened on, not shared; a device in use is refused by XAPI        $this->form->command('sr.new', $host);        $dialog = $this->dialog(NewSrForm::class);        self::assertSame('xcp-ng-02', $dialog->localHost->value(), 'the server right-clicked');        $dialog->kind->select(array_search('ext', array_keys(NewSrForm::KINDS), true));        $dialog->localDevice->text = '/dev/sdb';        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'sr', 'Local EXT storage'));        $ext = $this->ref($c, 'sr', 'Local EXT storage');        self::assertFalse($c->cache->field($ext, 'shared'));        self::assertSame([$host], array_map(fn ($p) => $c->cache->field((string)$p, 'host'), (array)$c->cache->field($ext, 'PBDs')));        $this->form->command('sr.new', $host);        $dialog = $this->dialog(NewSrForm::class);        $dialog->kind->select(array_search('lvm', array_keys(NewSrForm::KINDS), true));        $dialog->localDevice->text = '/dev/sdb';        $dialog->click($dialog->create);        $this->until(fn () => ($e = end($this->form->session->log)) && $e->status === LogEntry::FAILED);        self::assertStringContainsString('in use', end($this->form->session->log)->detail);    }    public function testDetachingAStorageInUseIsRefusedAndAnIdleOneDetachesAndReattaches(): void    {        $c = $this->connectDemo();        $nfs = $this->ref($c, 'sr', 'NFS storage');        $this->form->navigate($nfs);        $this->game->run(2);        $this->form->click($this->button('sr.detach'));        /** @var ConfirmForm $confirm */        $confirm = $this->dialog(ConfirmForm::class);        self::assertStringContainsString("Detach the storage repository 'NFS storage'", $confirm->message->text);        $confirm->click($confirm->ok);        $this->until(fn () => end($this->form->session->log)->status === LogEntry::FAILED);        self::assertStringContainsString('VDI_IN_USE', end($this->form->session->log)->detail, 'running VMs use its disks');        self::assertSame(count($c->cache->hosts()), \App\Feature\Storage\Menus::attached($c->cache, $nfs));        $iso = $this->ref($c, 'sr', 'ISO library');        $this->form->navigate($iso);        $this->game->run(2);        $this->form->click($this->button('sr.detach'));        $confirm = $this->dialog(ConfirmForm::class);        $confirm->click($confirm->ok);        $this->until(fn () => \App\Feature\Storage\Menus::attached($c->cache, $iso) === 0);        $this->game->run(20);        self::assertSame('Detached', $this->form->sheet->value('Status', 'State:'));        self::assertStringContainsString('Unplugged', (string)$this->form->sheet->value('Status', 'Connected to:'));        self::assertFalse($this->button('sr.detach')->props['enabled']);        self::assertTrue($this->button('sr.reattach')->props['enabled']);        $this->game->shot('sr-detached');        $this->form->click($this->button('sr.reattach'));        $this->until(fn () => \App\Feature\Storage\Menus::attached($c->cache, $iso) === count($c->cache->hosts()));        self::assertSame(2, count(array_keys($this->calls($c), 'Async.PBD.plug')));    }    public function testSetAsDefaultMakesItThePoolsDefaultStorage(): void    {        $c = $this->connectDemo();        $local = $this->ref($c, 'sr', 'Local storage on xcp-ng-01');        $this->form->navigate($local);        $this->game->run(2);        $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['Set as Default']);        self::assertTrue($items['Detach…']);        $this->form->tree->close();        $this->form->command('sr.setDefault');        $pool = (string)$c->cache->poolRef();        $this->until(fn () => $c->cache->field($pool, 'default_SR') === $local && $c->cache->field($pool, 'crash_dump_SR') === $local);        self::assertSame($local, $c->cache->field($pool, 'suspend_image_SR'));        $this->game->run(20);        $this->form->navigate($pool);        $this->game->run(2);        self::assertSame('Local storage on xcp-ng-01', $this->form->sheet->value('General', 'Default storage:'));    }    public function testForgetAndDestroyAskForAStrongConfirmation(): void    {        $c = $this->connectDemo();        $sr = $this->newNfs($c, 'Old exports');        $this->form->navigate($sr);        $this->game->run(2);        $this->form->click($this->button('sr.forget'));        /** @var StrongConfirmForm $confirm */        $confirm = $this->dialog(StrongConfirmForm::class);        self::assertFalse($confirm->ok->enabled, 'the box first');        $confirm->click($confirm->ok);        $this->game->run(1);        self::assertSame($confirm, $this->game->scene(), 'nothing happens without the box ticked');        $confirm->click($confirm->understood);        self::assertTrue($confirm->ok->enabled);        $this->game->shot('sr-forget');        $confirm->click($confirm->ok);        $this->until(fn () => $c->cache->get($sr) === null);        $this->game->run(20);        self::assertSame((string)$c->cache->poolRef(), $this->form->selected, 'back to the pool');        $calls = $this->calls($c);        self::assertContains('Async.PBD.unplug', $calls, 'detached first');        self::assertContains('Async.SR.forget', $calls);        $sr = $this->newNfs($c, 'Scratch', '/export/scratch');        $this->form->navigate($sr);        $this->game->run(2);        $this->form->command('sr.destroy');        $confirm = $this->dialog(StrongConfirmForm::class);        self::assertStringContainsString('DELETED', $confirm->message->text);        self::assertSame('Destroy', $confirm->ok->text);        $confirm->click($confirm->understood);        $confirm->click($confirm->ok);        $this->until(fn () => $c->cache->get($sr) === null);        self::assertContains('Async.SR.destroy', $this->calls($c));    }    // networks --------------------------------------------------------------------------------------------------------------    public function testAnExternalNetworkGetsItsVlanOnEveryServer(): void    {        $c = $this->connectDemo();        $this->form->tabs->selectTab('Networking');        $this->game->run(2);        $this->form->click($this->button('network.new'));        /** @var NewNetworkForm $dialog */        $dialog = $this->dialog(NewNetworkForm::class);        self::assertTrue($dialog->externalPanel->visible);        $dialog->name->text = 'Backup VLAN 30';        $dialog->nic->select(1);        self::assertStringStartsWith('eth1', (string)$dialog->nic->value());        $dialog->vlan->text = '5000';        $dialog->click($dialog->create);        self::assertStringContainsString('0 to 4094', $dialog->error->text);        $dialog->vlan->text = '30';        $dialog->mtu->text = '9000';        $this->game->shot('new-network');        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'network', 'Backup VLAN 30') && count((array)$c->cache->field($this->ref($c, 'network', 'Backup VLAN 30'), 'PIFs', [])) === 2);        $net = $this->ref($c, 'network', 'Backup VLAN 30');        self::assertSame(9000, (int)$c->cache->field($net, 'MTU'));        foreach ((array)$c->cache->field($net, 'PIFs') as $pif) {            self::assertSame([30, 'eth1'], [(int)$c->cache->field((string)$pif, 'VLAN'), $c->cache->field((string)$pif, 'device')]);        }        self::assertContains('Async.pool.create_VLAN', $this->calls($c));        $this->game->run(20);        self::assertContains($net, array_column($this->form->grid->rows, 'id'), 'in the Networking tab of the pool');    }    public function testAPrivateNetworkItsPropertiesThenItsRemoval(): void    {        $c = $this->connectDemo();        $this->form->command('network.new');        /** @var NewNetworkForm $dialog */        $dialog = $this->dialog(NewNetworkForm::class);        $dialog->kind->select(1);        self::assertTrue($dialog->privatePanel->visible);        $dialog->name->text = 'Lab private';        $dialog->click($dialog->create);        $this->until(fn () => $this->has($c, 'network', 'Lab private'));        $net = $this->ref($c, 'network', 'Lab private');        self::assertSame([], (array)$c->cache->field($net, 'PIFs'), 'no NIC');        self::assertNotContains('Async.pool.create_VLAN', $this->calls($c));        $this->game->run(20);        $this->form->tabs->selectTab('Networking');        $this->game->run(2);        $this->form->grid->select($net);        $this->game->run(1);        self::assertTrue($this->button('network.remove')->props['enabled'], 'no VM uses it');        $this->form->click($this->button('network.properties'));        /** @var NetworkPropertiesForm $props */        $props = $this->dialog(NetworkPropertiesForm::class);        self::assertSame('1500', $props->mtu->text);        $props->name->text = 'Lab private (jumbo)';        $props->mtu->text = '9000';        $props->click($props->ok);        $this->until(fn () => (int)$c->cache->field($net, 'MTU') === 9000 && $c->cache->field($net, 'name_label') === 'Lab private (jumbo)');        $this->game->run(20);        $this->form->grid->select($net);        $this->game->run(1);        $this->form->click($this->button('network.remove'));        $confirm = $this->dialog(ConfirmForm::class);        $confirm->click($confirm->ok);        $this->until(fn () => $c->cache->get($net) === null);        self::assertContains('Async.network.destroy', $this->calls($c));    }    public function testABondOfTwoNicsIsMadeOnEachServerAndRemovedWithItsNetwork(): void    {        $c = $this->connectDemo();        $this->form->command('network.new');        /** @var NewNetworkForm $dialog */        $dialog = $this->dialog(NewNetworkForm::class);        $dialog->kind->select(2);        $this->game->run(1);        self::assertFalse($dialog->widget('bond:eth0')->props['enabled'], 'the management NIC is not offered');        $dialog->name->text = 'Bond eth2+eth3';        $dialog->click($dialog->widget('bond:eth2'));        $dialog->click($dialog->create);        self::assertStringContainsString('two NICs', $dialog->error->text);        $dialog->click($dialog->widget('bond:eth3'));        self::assertSame(['eth2', 'eth3'], $dialog->bondMembers());        $dialog->bondMode->select(1);        $this->game->shot('new-bond');        $dialog->click($dialog->create);        $this->until(fn () => count($c->cache->all('bond')) === 2);        $net = $this->ref($c, 'network', 'Bond eth2+eth3');        foreach ($c->cache->all('bond') as $bond) {            self::assertSame('active-backup', $bond['mode']);            self::assertCount(2, $bond['slaves']);            self::assertSame('bond0', $c->cache->field((string)$bond['master'], 'device'));            self::assertSame($net, $c->cache->field((string)$bond['master'], 'network'));            foreach ($bond['slaves'] as $pif) {                self::assertNotSame('OpaqueRef:NULL', $c->cache->field((string)$pif, 'bond_slave_of'));            }        }        $this->game->run(20);        $this->form->navigate((string)$c->cache->master());        $this->form->tabs->selectTab('Networking');        $this->game->run(2);        self::assertContains('bond0', array_map(fn ($r) => $r['cells'][2], $this->form->grid->rows), 'the bond in the Networking tab of the server');        $this->game->shot('bond');        $this->form->command('network.remove', $net, true);        $this->until(fn () => $c->cache->get($net) === null && $c->cache->all('bond') === []);        self::assertSame(2, count(array_keys($this->calls($c), 'Async.Bond.destroy')));    }    public function testANetworkVmsUseCannotBeRemoved(): void    {        $c = $this->connectDemo();        $dmz = $this->ref($c, 'network', 'DMZ (VLAN 20)');        $mgmt = $this->ref($c, 'network', 'Pool-wide network associated with eth0');        self::assertFalse(\App\Feature\Storage\Menus::removable($c->cache, $dmz), 'VMs use it');        self::assertFalse(\App\Feature\Storage\Menus::removable($c->cache, $mgmt), 'a physical NIC carries it');        $this->form->tabs->selectTab('Networking');        $this->game->run(2);        $this->form->grid->select($dmz);        $this->game->run(1);        self::assertFalse($this->button('network.remove')->props['enabled']);        // asked anyway (another window, a script): XAPI refuses, and the VLANs stay        $pifs = (array)$c->cache->field($dmz, 'PIFs');        $this->form->command('network.remove', $dmz, true);        $this->until(fn () => end($this->form->session->log)->status === LogEntry::FAILED);        self::assertStringContainsString('NETWORK_CONTAINS_VIF', end($this->form->session->log)->detail);        self::assertSame($pifs, (array)$c->cache->field($dmz, 'PIFs'));    }    // virtual disks ---------------------------------------------------------------------------------------------------------    public function testADiskAddedToARunningVmIsPluggedAtOnce(): void    {        $c = $this->connectDemo();        $vm = $this->ref($c, 'vm', 'web-01');        $this->form->navigate($vm);        $this->form->tabs->selectTab('Storage');        $this->game->run(2);        $before = count($this->form->grid->rows);        $this->form->click($this->button('disk.add'));        /** @var DiskForm $dialog */        $dialog = $this->dialog(DiskForm::class);        $dialog->name->text = 'web-01 data';        $dialog->size->text = '50';        $this->game->shot('add-disk');        $dialog->click($dialog->ok);        $this->until(function () use ($c, $vm): bool {            foreach ($c->cache->disksOf($vm) as $d) {                if (($d['vdi']['name_label'] ?? '') === 'web-01 data' && !empty($d['vbd']['currently_attached'])) {                    return true;                }            }            return false;        });        $disk = array_values(array_filter($c->cache->disksOf($vm), fn ($d) => ($d['vdi']['name_label'] ?? '') === 'web-01 data'))[0];        self::assertSame('1', $disk['vbd']['userdevice'], 'the first free position');        self::assertSame(50 * 1073741824.0, (float)$disk['vdi']['virtual_size']);        self::assertSame(['Async.VDI.create', 'Async.VBD.create', 'Async.VBD.plug'], array_values(array_intersect($this->calls($c), ['Async.VDI.create', 'Async.VBD.create', 'Async.VBD.plug'])));        $this->game->run(20);        self::assertCount($before + 1, $this->form->grid->rows);    }    public function testADiskIsDetachedAttachedAgainThenDeleted(): void    {        $c = $this->connectDemo();        $vm = $this->ref($c, 'vm', 'backup-srv');        $vbd = $c->cache->disksOf($vm)[0]['ref'];        $vdi = (string)$c->cache->field($vbd, 'VDI');        $this->form->navigate($vm);        $this->form->tabs->selectTab('Storage');        $this->game->run(2);        self::assertFalse($this->button('disk.move')->props['enabled'], 'a line first');        $this->form->grid->select($vbd);        $this->game->run(1);        self::assertTrue($this->button('disk.detach')->props['enabled']);        $this->form->click($this->button('disk.detach'));        $confirm = $this->dialog(ConfirmForm::class);        $confirm->click($confirm->ok);        $this->until(fn () => $c->cache->get($vbd) === null);        self::assertNotNull($c->cache->get($vdi), 'the disk stays on its SR');        $this->game->run(20);        $this->form->click($this->button('disk.attach'));        /** @var DiskForm $dialog */        $dialog = $this->dialog(DiskForm::class);        self::assertTrue($dialog->attachPanel->visible);        $dialog->disk->selected = array_search($vdi, array_column(DiskForm::paramsFor($c, $vm, 'attach')['free'], 'ref'), true);        $dialog->click($dialog->ok);        $this->until(fn () => count($c->cache->disksOf($vm)) === 1);        $vbd = $c->cache->disksOf($vm)[0]['ref'];        self::assertSame($vdi, $c->cache->field($vbd, 'VDI'));        $this->game->run(20);        $this->form->grid->select($vbd);        $this->game->run(1);        $this->form->click($this->button('disk.delete'));        $confirm = $this->dialog(ConfirmForm::class);        self::assertSame('Delete', $confirm->ok->text);        $confirm->click($confirm->ok);        $this->until(fn () => $c->cache->get($vdi) === null);        self::assertSame([], $c->cache->disksOf($vm));    }    public function testMovingTheDiskOfAHaltedVmCopiesItAndGivesTheVmTheCopy(): void    {        $c = $this->connectDemo();        $vm = $this->ref($c, 'vm', 'backup-srv');        $nfs = $this->ref($c, 'sr', 'NFS storage');        $old = $c->cache->disksOf($vm)[0];        self::assertNotSame($nfs, $old['vdi']['SR'], 'on local storage');        $this->form->navigate($vm);        $this->form->tabs->selectTab('Storage');        $this->game->run(2);        $this->form->grid->select($old['ref']);        $this->game->run(1);        $this->form->click($this->button('disk.move'));        /** @var MoveDiskForm $dialog */        $dialog = $this->dialog(MoveDiskForm::class);        self::assertStringContainsString('copied', $dialog->how->text);        $dialog->sr->selected = (int)array_search('NFS storage', array_map(fn ($s) => explode(' (', $s)[0], $dialog->sr->items), true);        $this->game->shot('move-disk');        $dialog->click($dialog->move);        $this->until(fn () => $c->cache->get((string)$old['vbd']['VDI']) === null && count($c->cache->disksOf($vm)) === 1, 1500);        $new = $c->cache->disksOf($vm)[0];        self::assertSame($nfs, $new['vdi']['SR']);        self::assertSame(['0', true, 'RW'], [$new['vbd']['userdevice'], $new['vbd']['bootable'], $new['vbd']['mode']], 'same position, still the boot disk');        self::assertSame($old['vdi']['virtual_size'], $new['vdi']['virtual_size']);        foreach (['Async.VDI.copy', 'Async.VBD.destroy', 'Async.VBD.create', 'Async.VDI.destroy'] as $call) {            self::assertContains($call, $this->calls($c));        }        self::assertNotContains('Async.VDI.pool_migrate', $this->calls($c));    }    public function testMovingTheDiskOfARunningVmMigratesItLiveFromTheSrStorageTab(): void    {        $c = $this->connectDemo();        $vm = $this->ref($c, 'vm', 'db-primary');        $old = $c->cache->disksOf($vm)[0];        $from = (string)$old['vdi']['SR'];        $nfs = $this->ref($c, 'sr', 'NFS storage');        $this->form->navigate($from);        $this->form->tabs->selectTab('Storage');        $this->game->run(2);        $this->form->grid->select((string)$old['vbd']['VDI']);        $this->game->run(1);        $this->form->click($this->button('disk.move'));        /** @var MoveDiskForm $dialog */        $dialog = $this->dialog(MoveDiskForm::class);        self::assertStringContainsString('live migration', $dialog->how->text);        $dialog->sr->selected = (int)array_search('NFS storage', array_map(fn ($s) => explode(' (', $s)[0], $dialog->sr->items), true);        $dialog->click($dialog->move);        $this->until(fn () => ($c->cache->disksOf($vm)[0]['vdi']['SR'] ?? '') === $nfs);        $new = $c->cache->disksOf($vm)[0];        self::assertSame($old['ref'], $new['ref'], 'the same VBD: the VM never stopped');        self::assertTrue($new['vbd']['currently_attached']);        self::assertSame('Running', $c->cache->field($vm, 'power_state'));        self::assertContains('Async.VDI.pool_migrate', $this->calls($c));        self::assertNotContains('Async.VDI.copy', $this->calls($c));    }    // cross-pool migration -----------------------------------------------------------------------------------------------------    private function twoPools(): array    {        $c = $this->connectDemo();        $this->form->command('server.demo2');        $this->until(fn () => count($this->form->session->connections) === 2 && $this->form->session->connections[1]->isConnected(), 120);        $d = $this->form->session->connections[1];        $this->game->run(20);        self::assertSame('Demo pool 2', $d->name());        self::assertSame([], array_intersect(array_keys($c->cache->all('vm')), array_keys($d->cache->all('vm'))), 'refs of its own');        return [$c, $d];    }    public function testARunningVmMigratesLiveToTheSecondDemoPool(): void    {        $c = $this->connectDemo();        $web = $this->ref($c, 'vm', 'web-01');        $this->form->navigate($web);        $this->game->run(2);        $this->form->onTreeMenu($this->form->tree, 300, 300);        $items = array_column(array_filter($this->form->tree->popup->items, 'is_array'), 'enabled', 'text');        self::assertFalse($items['Migrate to Another Pool…'], 'one pool only');        $this->form->tree->close();        [$c, $d] = $this->twoPools();        self::assertContains((string)$d->cache->poolRef(), $this->form->tree->visibleIds());        $web = $this->ref($c, 'vm', 'web-01');        $uuid = (string)$c->cache->field($web, 'uuid');        $this->form->navigate($web);        $this->game->run(2);        $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['Migrate to Another Pool…']);        $this->form->tree->close();        $this->form->command('vm.crossPoolMigrate');        /** @var CrossPoolMigrateForm $dialog */        $dialog = $this->dialog(CrossPoolMigrateForm::class);        self::assertSame(['Demo pool 2'], $dialog->pool->items);        self::assertStringStartsWith('xcp-lab-01', (string)$dialog->host->value());        self::assertStringStartsWith('Lab NFS', (string)$dialog->widget('disk:0')->value(), 'the default SR of the other pool');        self::assertSame('Pool-wide network associated with eth0', $dialog->widget('vif:0')->value(), 'the network of the same name');        $this->game->shot('cross-pool');        $dialog->click($dialog->migrate);        $this->until(fn () => $c->cache->get($web) === null && $d->cache->byUuid('vm', $uuid) !== null);        $this->game->run(30);        $moved = (string)$d->cache->byUuid('vm', $uuid);        self::assertSame('web-01', $d->cache->field($moved, 'name_label'));        self::assertSame('Running', $d->cache->field($moved, 'power_state'));        self::assertSame($d->cache->master(), $d->cache->field($moved, 'resident_on'));        self::assertSame('Lab NFS', $d->cache->name((string)$d->cache->disksOf($moved)[0]['vdi']['SR']));        self::assertCount(2, $d->cache->vifsOf($moved), 'its two interfaces');        self::assertContains('host.migrate_receive', $this->calls($d), 'asked on the destination');        $calls = $this->calls($c);        self::assertLessThan(array_search('Async.VM.migrate_send', $calls, true), array_search('VM.assert_can_migrate', $calls, true));        self::assertContains($moved, $this->form->tree->visibleIds(), 'under the other pool in the tree');        $this->game->shot('cross-pool-done');    }    public function testAHaltedVmComesBackFromTheSecondPool(): void    {        [$c, $d] = $this->twoPools();        $ci = $this->ref($d, 'vm', 'lab-ci');        $this->form->navigate($ci);        $this->game->run(2);        $this->form->command('vm.crossPoolMigrate');        /** @var CrossPoolMigrateForm $dialog */        $dialog = $this->dialog(CrossPoolMigrateForm::class);        self::assertSame(['Demo pool'], $dialog->pool->items);        self::assertStringContainsString('halted', $dialog->how->text);        $dialog->click($dialog->migrate);        $this->until(fn () => $d->cache->get($ci) === null && $this->has($c, 'vm', 'lab-ci'));        $vm = $this->ref($c, 'vm', 'lab-ci');        self::assertSame('Halted', $c->cache->field($vm, 'power_state'));        self::assertSame('NFS storage', $c->cache->name((string)$c->cache->disksOf($vm)[0]['vdi']['SR']), 'the default SR of the demo pool');    }}