"""The HypervisorDriver provisioning methods, around the live import (#307).""" from __future__ import annotations import io from unittest.mock import MagicMock, patch import pycdlib import pytest import yaml from napalm_vmware import VmwareEsxiDriver from napalm_vmware import provisioning as prov from napalm_vmware.provision.placement import Placement from tests.conftest import FakeInventory PLACE = Placement( host="ha-host", host_name="esx01", datastore="datastore-1", datastore_name="LocalDS_0", resource_pool="ha-root-pool", folder="ha-folder-vm", datacenter_name="ha-datacenter", networks=["HaNetwork-VM Network"], ) @pytest.fixture def driver(esxi_data): d = VmwareEsxiDriver("esx01", "root", "secret", optional_args={"image_cache_dir": "/tmp/x"}) d._si = MagicMock() d._inventory = FakeInventory(esxi_data) d.mo = MagicMock(name="mo") d._mo = MagicMock(return_value=d.mo) return d class TestTargets: def test_image_storages(self, driver): (ds,) = driver.get_image_storages() assert ds["name"] == "LocalDS_0" assert ds["available_gb"] <= ds["total_gb"] def test_inaccessible_datastore_is_not_offered(self, driver, esxi_data): esxi_data["objects"]["Datastore"][0]["summary"]["accessible"] = False assert driver.get_image_storages() == [] def test_network_targets_are_port_groups_with_a_fixed_vlan(self, driver, esxi_host): esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 30 targets = {t["name"]: t for t in driver.get_network_targets()} assert targets["VM Network"] == { "name": "VM Network", "kind": "portgroup", "vlan_aware": False, "fixed_vlan_tag": 30, } assert targets["Management Network"]["fixed_vlan_tag"] is None class TestCheckNics: @pytest.mark.parametrize( ("nics", "message"), [ ([], "at least one"), ([{"bridge": "Nope"}], "no port group"), ([{"bridge": "VM Network", "trunk_vlan_tags": [10, 20]}], "trunk"), ([{"bridge": "VM Network", "vlan_tag": 40}], "carries VLAN"), ], ) def test_refusals(self, driver, nics, message): with pytest.raises(RuntimeError, match=message): driver._check_nics(nics) def test_matching_vlan_is_fine(self, driver, esxi_host): esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 40 driver._check_nics([{"bridge": "VM Network", "vlan_tag": 40}]) def _create(driver, **overrides): kwargs = dict( image_url="https://img/debian.qcow2", cpu=2, memory=2048, nics=[{"bridge": "VM Network", "dhcp": True}], cloud_init_config={"hostname": "web01"}, ) kwargs.update(overrides) return driver.create_vm_from_cloud_init("web01", **kwargs) @pytest.fixture def staged(driver): """Everything around the live import replaced; returns the mocks.""" cache = MagicMock() cache.return_value.vmdk.return_value = ("/tmp/x/a.vmdk", 2 * 1024**3) with ( patch.object(prov, "ImageCache", cache), patch.object(prov, "choose_placement", return_value=PLACE), patch.object(driver, "_placement_inventory"), patch.object(driver, "_import_ovf", return_value="vm-99") as import_ovf, patch.object(driver, "_finish_vm") as finish, patch.object(driver, "_run_task") as run_task, patch.object(driver, "_discard") as discard, ): driver._inventory.properties = MagicMock(return_value={"config.instanceUuid": "uuid-99"}) yield MagicMock( cache=cache, import_ovf=import_ovf, finish=finish, run_task=run_task, discard=discard ) class TestCreate: def test_result_names_vm_and_host(self, driver, staged): assert _create(driver) == {"vmid": "uuid-99", "name": "web01", "node": "esx01"} def test_image_cache_is_used_with_the_download_timeout(self, driver, staged): _create(driver, image_checksum="sha256:ab", download_timeout=42) staged.cache.return_value.vmdk.assert_called_once_with( "https://img/debian.qcow2", "sha256:ab", 42 ) def test_descriptor_describes_the_request(self, driver, staged): _create(driver, nics=[{"bridge": "VM Network"}, {"bridge": "Management Network"}]) descriptor = staged.import_ovf.call_args.args[1] assert "2048" in descriptor assert descriptor.count("10") == 2 def test_ssh_keys_reach_the_user_data(self, driver, staged): _create(driver, ssh_public_keys=["ssh-ed25519 AAA k"]) user = staged.finish.call_args.args[4] assert yaml.safe_load(user)["ssh_authorized_keys"] == ["ssh-ed25519 AAA k"] def test_powered_on_last(self, driver, staged): _create(driver) driver.mo.PowerOnVM_Task.assert_not_called() # handed to _run_task, not called assert staged.run_task.call_args.args[0] is driver.mo.PowerOnVM_Task def test_failure_after_import_removes_the_vm(self, driver, staged): staged.finish.side_effect = OSError("upload refused") with pytest.raises(RuntimeError, match="upload refused"): _create(driver) staged.discard.assert_called_once_with("vm-99") def test_no_cpu_type_is_the_normal_case(self, driver, staged): assert _create(driver, cpu_type=None)["vmid"] == "uuid-99" def test_a_cpu_type_is_refused_before_anything_happens(self, driver, staged): """VMware sets CPU compatibility per cluster (EVC), not per VM, so a requested model cannot be honoured -- and is not silently dropped.""" with pytest.raises(ValueError, match="CPU type"): _create(driver, cpu_type="host") staged.cache.return_value.vmdk.assert_not_called() staged.import_ovf.assert_not_called() def test_placement_refusal_is_a_runtime_error(self, driver, staged): with patch.object(prov, "choose_placement", side_effect=ValueError("no host")): with pytest.raises(RuntimeError, match="no host"): _create(driver) staged.import_ovf.assert_not_called() MAC = "00:0c:29:39:31:62" def _live_devices(): """What vSphere hands back: full device objects, backings included.""" from pyVmomi import vim disk = vim.vm.device.VirtualDisk( key=2000, controllerKey=1000, unitNumber=0, capacityInKB=2 * 1024 * 1024, backing=vim.vm.device.VirtualDisk.FlatVer2BackingInfo( fileName="[LocalDS_0] web01/disk.vmdk", diskMode="persistent" ), deviceInfo=vim.Description(label="Hard disk 1", summary=""), ) nic = vim.vm.device.VirtualVmxnet3( key=4000, macAddress=MAC, addressType="generated", backing=vim.vm.device.VirtualEthernetCard.NetworkBackingInfo(deviceName="VM Network"), deviceInfo=vim.Description(label="Network adapter 1", summary=""), ) return [disk, nic] class TestFinishVm: def _finish(self, driver, esxi_data, nics, disk_gb=None): driver._inventory.properties = MagicMock( return_value={ "config.hardware.device": _live_devices(), "config.files.vmPathName": "[LocalDS_0] web01/web01.vmx", } ) with ( patch.object(driver, "_upload_seed") as upload, patch.object(driver, "_run_task") as run, ): driver._finish_vm( "vm-99", "web01", PLACE, nics, "#cloud-config\n{}\n", disk_gb=disk_gb, image_size=2 * 1024**3, ) return upload, run.call_args.kwargs["spec"] def test_seed_goes_next_to_the_vm_and_is_attached(self, driver, esxi_data): upload, spec = self._finish(driver, esxi_data, [{"bridge": "VM Network"}]) assert upload.call_args.args[1] == "web01/cidata.iso" cdrom = next( c.device for c in spec.deviceChange if type(c.device).__name__.endswith("VirtualCdrom") ) assert cdrom.backing.fileName == "[LocalDS_0] web01/cidata.iso" def test_seed_network_config_uses_the_real_mac(self, driver, esxi_data): upload, _ = self._finish(driver, esxi_data, [{"bridge": "VM Network"}]) mac = MAC reader = pycdlib.PyCdlib() reader.open_fp(io.BytesIO(upload.call_args.args[2])) out = io.BytesIO() reader.get_file_from_iso_fp(out, rr_path="/network-config") reader.close() cfg = yaml.safe_load(out.getvalue()) assert cfg["ethernets"]["nic0"] == {"match": {"macaddress": mac.lower()}, "dhcp4": True} def test_requested_mac_is_pinned_on_the_whole_device(self, driver, esxi_data): _, spec = self._finish( driver, esxi_data, [{"bridge": "VM Network", "mac": "00:50:56:01:02:03"}] ) nic = next(c.device for c in spec.deviceChange if c.operation == "edit") assert (nic.addressType, nic.macAddress) == ("manual", "00:50:56:01:02:03") assert nic.backing.deviceName == "VM Network" # an edit replaces the device as sent def test_disk_grows_only_when_asked_for_more(self, driver, esxi_data): _, spec = self._finish(driver, esxi_data, [{"bridge": "VM Network"}], disk_gb=20) disk = next( c.device for c in spec.deviceChange if type(c.device).__name__.endswith("VirtualDisk") ) assert disk.capacityInKB == 20 * 1024 * 1024 assert disk.backing.fileName == "[LocalDS_0] web01/disk.vmdk" # vcsim panicked without it _, spec = self._finish(driver, esxi_data, [{"bridge": "VM Network"}], disk_gb=1) assert not any(type(c.device).__name__.endswith("VirtualDisk") for c in spec.deviceChange) class TestDestroyAndStatus: def test_destroy_powers_off_first(self, driver, esxi_data): vm = esxi_data["objects"]["VirtualMachine"][0] with patch.object(driver, "_run_task") as run: driver.destroy_vm(vm["config.instanceUuid"]) assert [c.args[0] for c in run.call_args_list] == [ driver.mo.PowerOffVM_Task, driver.mo.Destroy_Task, ] def test_keep_disks_unregisters(self, driver, esxi_data): vm = esxi_data["objects"]["VirtualMachine"][0] vm["runtime.powerState"] = "poweredOff" driver.destroy_vm(vm["config.instanceUuid"], remove_disk=False) driver.mo.UnregisterVM.assert_called_once_with() def test_destroy_unknown(self, driver): with pytest.raises(RuntimeError): driver.destroy_vm("nope") def test_status(self, driver, esxi_data): vm = esxi_data["objects"]["VirtualMachine"][0] vm["guest.net"][0]["ipAddress"] = ["192.0.2.7"] vm["guest.hostName"] = "web01" status = driver.get_vm_status(vm["config.instanceUuid"]) assert status["status"] == "running" assert status["ip_address"] == "192.0.2.7" assert status["hostname"] == "web01" assert status["mac_address"] def test_waiting_for_an_ip_times_out(self, driver, esxi_data): vm = esxi_data["objects"]["VirtualMachine"][0] clock = iter([0.0, 1.0, 400.0]) with ( patch.object(prov.time, "monotonic", lambda: next(clock)), patch.object(prov.time, "sleep"), ): with pytest.raises(RuntimeError, match="no IP"): driver.get_vm_status(vm["config.instanceUuid"], wait_for_ip=True, timeout=300)