<?phpnamespace App\Xapi\Fake;use App\Xapi\FakeXapi;use App\Xapi\XapiError;/** * Pools in the demo (used by FakeXapi): another demo address than the demo pool's is a standalone host of its own * ('xcp-demo-2.local' = xcp-ng-03, 'xcp-demo-3.local' = xcp-ng-04...), and the pool calls behave as XAPI's: * - pool.join(master_address, user, password), called ON THE JOINING HOST: the host, its local storage and its VMs move into the * master's database (its networks mapped onto the pool's by device, PBDs made for the pool's shared SRs); then the joining host * answers HOST_IS_SLAVE <master> to a login and its sessions end, as a real one whose xapi restarted as a pool member; * - pool.eject(host) on the master: the host leaves with its local storage and the VMs on it (it reboots as a fresh standalone host); * - pool.designate_new_master(host): the pool's master changes. * The instances know each other through a registry by address (two FakeXapi are separate objects, as two servers are). */trait PoolOps{ /** @var array<string, FakeXapi> address (lower case) => the fake server answering there */ private static array $fakes = []; /** this host joined a pool: the master's address (login answers HOST_IS_SLAVE) */ private ?string $slaveOf = null; private float $dropIn = -1; /** @var array<string, string> host ref => the address of the demo server it came from (joined hosts) */ private array $joinedFrom = []; /** The fake server at an address: a host that joined a pool stays (it answers HOST_IS_SLAVE), anything else starts afresh. */ public static function open(string $address): FakeXapi { $known = self::$fakes[strtolower($address)] ?? null; return $known !== null && $known->slaveOf !== null ? $known : new FakeXapi($address); } /** Forgets every demo server (tests). */ public static function forget(): void { self::$fakes = []; } public function isSlave(): bool { return $this->slaveOf !== null; } /** The demo pool at its own address, "Demo pool 2" at 'xcp-lab.local', a standalone host anywhere else; registered under its address. */ private function buildFor(): void { if (self::isMainDemo($this->address)) { $this->buildMainDemo(); // MigrationOps } elseif (self::isSecondDemo($this->address)) { $this->buildSecondDemo(); // "Demo pool 2" (MigrationOps) } else { $this->buildStandalone(); } self::$fakes[strtolower($this->address)] = $this; } private static function findByAddress(string $address): ?FakeXapi { $key = strtolower($address); if (isset(self::$fakes[$key]) && self::$fakes[$key]->slaveOf === null) { return self::$fakes[$key]; } foreach (self::$fakes as $fake) { foreach ($fake->db['host'] ?? [] as $host) { if ($fake->slaveOf === null && strtolower((string)$host['address']) === $key) { return $fake; } } } return null; } private function masterRef(): string { $pool = array_key_first($this->db['pool'] ?? []); return $pool === null ? '' : (string)$this->db['pool'][$pool]['master']; } private function refuseIfSlave(): void { if ($this->slaveOf !== null) { throw new XapiError(['HOST_IS_SLAVE', $this->slaveOf]); } } private function tickPool(float $dt): void { if ($this->dropIn > 0) { $this->dropIn -= $dt; if ($this->dropIn <= 0) { // xapi restarted as a pool member: the sessions of this host are gone, its event.from calls cut $this->sessions = []; $this->close(); } } } /** /rrd_updates answers for the host it is asked on (the master by default), as each XAPI host only has its own figures. */ private function rrdAnswersFor(array $host, ?string $hostUuid): bool { $want = $hostUuid ?? (string)($this->db['host'][$this->masterRef()]['uuid'] ?? ''); return (string)$host['uuid'] === $want; } // a standalone host ------------------------------------------------------------------------------------------------ private function buildStandalone(): void { $n = preg_match('/(\d+)/', $this->address, $m) ? max(2, (int)$m[1] % 90) : 2; // its refs and uuids differ from the demo pool's (they meet in one database after a join) $this->nextRef = 0x100000 * $n + 1; $this->seed = 7 + 7919 * $n; $name = sprintf('xcp-ng-%02d', $n + 1); $ip = '192.168.1.' . (11 + $n); $net0 = $this->add('network', ['name_label' => 'Pool-wide network associated with eth0', 'name_description' => '', 'bridge' => 'xenbr0', 'MTU' => 1500, 'PIFs' => [], 'VIFs' => []]); $this->add('network', ['name_label' => 'Host internal management network', 'name_description' => 'Network on which guests will be assigned a private link-local IP address', 'bridge' => 'xenapi', 'MTU' => 1500, 'PIFs' => [], 'VIFs' => [], 'other_config' => ['is_host_internal_management_network' => 'true']]); $metrics = $this->add('host_metrics', ['memory_total' => 64 * self::GIB, 'memory_free' => 58 * self::GIB, 'live' => true, 'last_updated' => $this->date()]); $host = $this->add('host', ['name_label' => $name, 'name_description' => 'Rack B, unit 3', 'hostname' => $name, 'address' => $ip, 'enabled' => true, 'metrics' => $metrics, 'resident_VMs' => [], 'PIFs' => [], 'PBDs' => [], 'memory_overhead' => 380 * self::MIB, 'API_version_major' => 2, 'API_version_minor' => 21, 'cpu_info' => ['cpu_count' => '12', 'socket_count' => '1', 'vendor' => 'GenuineIntel', 'speed' => '2394.454', 'modelname' => 'Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz', 'family' => '6', 'model' => '85', 'stepping' => '7', 'flags' => 'fpu vme de pse tsc msr pae mce cx8 apic sep mtrr', 'features' => '', 'features_pv' => '', 'features_hvm' => ''], 'software_version' => ['product_brand' => 'XCP-ng', 'product_version' => '8.3.0', 'product_version_text' => '8.3', 'platform_name' => 'XCP', 'platform_version' => '3.4.0', 'build_number' => 'release/yangtze/master/58', 'xapi' => '24.19', 'xen' => '4.17.4-3', 'linux' => '4.19.0+1', 'hostname' => $name, 'date' => '2024-09-30'], 'bios_strings' => ['bios-vendor' => 'Dell Inc.', 'system-manufacturer' => 'Dell Inc.', 'system-product-name' => 'PowerEdge R440', 'system-serial-number' => 'CN8' . (4100 + $n)], 'other_config' => ['iscsi_iqn' => "iqn.2024-01.local.demo:$name", 'agent_start_time' => (string)($this->now - 86400 * 3), 'boot_time' => (string)($this->now - 86400 * 9)], 'iscsi_iqn' => "iqn.2024-01.local.demo:$name", 'license_params' => ['sku_type' => 'free'], 'edition' => 'xcp-ng', 'logging' => [], 'suspend_image_sr' => 'OpaqueRef:NULL', 'crash_dump_sr' => 'OpaqueRef:NULL', 'external_auth_type' => '', 'ssl_legacy' => false, 'tls_verification_enabled' => true, 'chipset_info' => ['iommu' => 'true'], 'control_domain' => '']); $pm = $this->add('pif_metrics', ['speed' => 1000, 'duplex' => true, 'carrier' => true, 'vendor_name' => 'Intel Corporation', 'device_name' => 'I350 Gigabit Network Connection', 'pci_bus_path' => '0000:01:00.0']); $pif = $this->add('pif', ['device' => 'eth0', 'host' => $host, 'network' => $net0, 'MAC' => sprintf('00:1b:21:3a:%02x:10', $n), 'MTU' => 1500, 'VLAN' => -1, 'IP' => $ip, 'netmask' => '255.255.255.0', 'gateway' => '192.168.1.1', 'DNS' => '192.168.1.1', 'ip_configuration_mode' => 'Static', 'management' => true, 'currently_attached' => true, 'physical' => true, 'metrics' => $pm, 'bond_master_of' => [], 'bond_slave_of' => 'OpaqueRef:NULL']); $this->append('host', $host, 'PIFs', $pif); $this->append('network', $net0, 'PIFs', $pif); $lsr = $this->add('sr', ['name_label' => 'Local storage', 'name_description' => "Local storage on $name", 'type' => 'ext', 'content_type' => 'user', 'shared' => false, 'physical_size' => 900 * self::GIB, 'physical_utilisation' => 130 * self::GIB, 'virtual_allocation' => 160 * self::GIB, 'PBDs' => [], 'VDIs' => [], 'other_config' => ['i18n-key' => 'local-storage']]); $pbd = $this->add('pbd', ['host' => $host, 'SR' => $lsr, 'currently_attached' => true, 'device_config' => ['device' => '/dev/sda3']]); $this->append('sr', $lsr, 'PBDs', $pbd); $this->append('host', $host, 'PBDs', $pbd); $this->addVm(['name_label' => "Control domain on host: $name", 'is_control_domain' => true, 'power_state' => 'Running', 'resident_on' => $host, 'memory' => 4 * self::GIB, 'vcpus' => 8, 'os' => 'XCP-ng 8.3']); foreach (['Debian Bookworm 12', 'Other install media'] as $t) { $this->addVm(['name_label' => $t, 'is_a_template' => true, 'default_template' => true, 'power_state' => 'Halted', 'memory' => 2 * self::GIB, 'vcpus' => 2, 'description' => "Template for $t", 'disk' => 10]); } foreach ([['staging-web', 'Staging web server', 4, 2, 40], ['build-agent', 'CI build agent', 8, 4, 80]] as $i => [$vmName, $desc, $memG, $vcpus, $disk]) { $this->addVm(['name_label' => $vmName, 'description' => $desc, 'power_state' => 'Halted', 'resident_on' => 'OpaqueRef:NULL', 'affinity' => $host, 'memory' => $memG * self::GIB, 'vcpus' => $vcpus, 'os' => '', 'disk' => $disk, 'sr' => $lsr, 'networks' => [$net0], 'index' => 30 + 10 * $n + $i, 'tags' => ['staging']]); } $this->add('pool', ['name_label' => '', 'name_description' => '', 'master' => $host, 'default_SR' => $lsr, 'suspend_image_SR' => $lsr, 'crash_dump_SR' => $lsr, 'ha_enabled' => false, 'ha_host_failures_to_tolerate' => 0, 'other_config' => [], 'tags' => [], 'wlb_enabled' => false, 'gui_config' => [], 'cpu_info' => ['cpu_count' => '12', 'socket_count' => '1', 'vendor' => 'GenuineIntel']]); $this->log = []; $this->eventId = 100; } // the calls ------------------------------------------------------------------------------------------------------------ /** @param list<mixed> $p @return array{0: float, 1: \Closure, 2: string, 3: string}|null */ private function poolOperation(string $method, array $p): ?array { $pool = (string)array_key_first($this->db['pool'] ?? []); if ($method === 'join') { return [3.0, function () use ($p): string { $this->join((string)($p[0] ?? ''), (string)($p[1] ?? ''), (string)($p[2] ?? '')); return ''; }, 'Joining a pool', $this->masterRef()]; } if ($method === 'eject' || $method === 'designate_new_master') { $host = (string)($p[0] ?? ''); $this->record('host', $host); return $method === 'eject' ? [3.0, function () use ($host): string { $this->eject($host); return ''; }, 'Ejecting a server from the pool', $host] : [2.0, function () use ($host, $pool): string { $this->set('pool', $pool, ['master' => $host]); return ''; }, 'Designating a new pool master', $host]; } return null; } private function join(string $masterAddress, string $user, string $password): void { $master = self::findByAddress($masterAddress); if ($master === null || $master === $this) { throw new XapiError(['POOL_JOINING_HOST_CONNECTION_FAILED']); } if ($user !== self::USER || $password !== self::PASSWORD) { throw new XapiError(['SESSION_AUTHENTICATION_FAILED', $user, 'Authentication failure']); } if (count($this->db['host'] ?? []) > 1) { throw new XapiError(['JOINING_HOST_CANNOT_BE_MASTER_OF_OTHER_HOSTS']); } foreach ($this->db['vm'] ?? [] as $vm) { if (\App\Model\Cache::isRealVm($vm) && in_array($vm['power_state'], ['Running', 'Paused', 'Suspended'], true)) { throw new XapiError(['JOINING_HOST_CANNOT_HAVE_RUNNING_OR_SUSPENDED_VMS']); } } $master->adoptHost($this); $this->slaveOf = $master->address; $this->dropIn = 1.5; // the task's end still reaches the client, then the sessions go } /** The host of a standalone server becomes a member of this pool, with its local storage and its VMs. */ private function adoptHost(FakeXapi $from): void { $host = $from->masterRef(); $this->joinedFrom[$host] = strtolower($from->address); // the networks of the new host are the pool's ones of the same device (eth0 -> the pool's eth0 network), as XAPI does $poolNet = []; foreach ($this->db['pif'] ?? [] as $pif) { if ((int)$pif['VLAN'] === -1) { $poolNet[(string)$pif['device']] ??= (string)$pif['network']; } } foreach ($this->db['network'] ?? [] as $ref => $net) { $poolNet['bridge:' . $net['bridge']] ??= $ref; } $netMap = []; foreach ($from->db['network'] ?? [] as $ref => $net) { $device = null; foreach ($from->db['pif'] ?? [] as $pif) { if ($pif['network'] === $ref && (int)$pif['VLAN'] === -1) { $device = (string)$pif['device']; } } $netMap[$ref] = ($device !== null ? $poolNet[$device] ?? null : null) ?? $poolNet['bridge:' . $net['bridge']] ?? null; if ($netMap[$ref] === null) { $this->add('network', ['PIFs' => [], 'VIFs' => []] + $net, $ref); $netMap[$ref] = $ref; } } $templates = []; foreach ($from->db['vm'] ?? [] as $ref => $vm) { if (!empty($vm['is_a_template'])) { $templates[$ref] = true; // the pool has its own templates } } foreach (['host_metrics', 'host', 'pif_metrics', 'pif', 'sr', 'pbd', 'vdi', 'vm_metrics', 'vm_guest_metrics', 'vm', 'vbd', 'vif', 'console'] as $class) { foreach ($from->db[$class] ?? [] as $ref => $record) { if (($class === 'vm' && isset($templates[$ref])) || ($class === 'console' && isset($templates[(string)$record['VM']]))) { continue; } if ($class === 'pif' || $class === 'vif') { $record['network'] = $netMap[(string)$record['network']] ?? $record['network']; } $this->add($class, $record, $ref); if ($class === 'pif' || $class === 'vif') { $this->append('network', (string)$record['network'], $class === 'pif' ? 'PIFs' : 'VIFs', $ref); } } } // the pool's shared storage is plugged on the new host too foreach ($this->db['sr'] ?? [] as $sr => $record) { if (!empty($record['shared'])) { $pbd = $this->add('pbd', ['host' => $host, 'SR' => $sr, 'currently_attached' => true, 'device_config' => []]); $this->append('sr', $sr, 'PBDs', $pbd); $this->append('host', $host, 'PBDs', $pbd); } } } private function eject(string $host): void { if ($host === $this->masterRef()) { throw new XapiError(['OPERATION_NOT_ALLOWED', 'The pool master cannot be ejected: designate another master first.']); } foreach ($this->db['host'][$host]['resident_VMs'] as $vm) { if (empty($this->db['vm'][$vm]['is_control_domain'])) { throw new XapiError(['HOST_IN_USE', $host, 'VM', $vm]); } } $record = $this->db['host'][$host]; // its local storage goes, with the VMs whose disks are there $localSrs = []; foreach ($record['PBDs'] as $pbd) { $sr = (string)($this->db['pbd'][$pbd]['SR'] ?? ''); if (isset($this->db['sr'][$sr]) && empty($this->db['sr'][$sr]['shared'])) { $localSrs[$sr] = true; } elseif (isset($this->db['sr'][$sr])) { $this->removeFrom('sr', $sr, 'PBDs', $pbd); } $this->delete('pbd', $pbd); } foreach ($this->db['vm'] ?? [] as $vm => $v) { $onLocal = !empty($v['is_control_domain']) && $v['resident_on'] === $host; foreach ($v['VBDs'] as $vbd) { $onLocal = $onLocal || isset($localSrs[(string)($this->db['vdi'][(string)($this->db['vbd'][$vbd]['VDI'] ?? '')]['SR'] ?? '')]); } if ($onLocal) { foreach ($v['VBDs'] as $vbd) { $this->delete('vbd', $vbd); } foreach ($v['VIFs'] as $vif) { $this->removeFrom('network', (string)$this->db['vif'][$vif]['network'], 'VIFs', $vif); $this->delete('vif', $vif); } foreach ((array)($v['consoles'] ?? []) as $console) { $this->delete('console', (string)$console); } $this->delete('vm', $vm); } elseif ($v['affinity'] === $host) { $this->set('vm', $vm, ['affinity' => 'OpaqueRef:NULL']); } } foreach (array_keys($localSrs) as $sr) { foreach ($this->db['sr'][$sr]['VDIs'] as $vdi) { $this->delete('vdi', $vdi); } $this->delete('sr', $sr); } foreach ($record['PIFs'] as $pif) { $this->removeFrom('network', (string)$this->db['pif'][$pif]['network'], 'PIFs', $pif); $this->delete('pif_metrics', (string)$this->db['pif'][$pif]['metrics']); $this->delete('pif', $pif); } $this->delete('host_metrics', (string)$record['metrics']); $this->delete('host', $host); // it reboots as a standalone host with an empty database: a new server at its old demo address $origin = $this->joinedFrom[$host] ?? null; if ($origin !== null) { unset(self::$fakes[$origin], $this->joinedFrom[$host]); new FakeXapi($origin); } }}