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 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'); } }