feat: VM provisioning, and reboot_host on ESXi
create_vm_from_cloud_init takes the same qcow2/raw cloud images netOrk offers for Proxmox. ESXi can neither boot nor download them, so the driver does both: download with checksum check and one retry, convert with qemu-img to a streamOptimized VMDK (cached by URL), import through a minimal OVF descriptor over NFC, pin requested MACs, grow the disk, and attach a NoCloud seed ISO placed next to the VM's files. NoCloud rather than guestinfo because it needs nothing in the guest; the user-data installs open-vm-tools, which the driver declares as its guest agent. A failure after the import removes the VM again. Placement is a pure decision over inventory rows: a connected host outside maintenance mode that sees the datastore and every port group, with the resource pool and VM folder found by walking up to the datacenter -- one path for a standalone host and for a vCenter. Two faults vcsim surfaced and the tests now pin: a chunked upload body next to a Content-Length is refused with 500, so the disk goes up as a sized file object that also reports lease progress; and a device edit replaces the device as sent, so disk and NIC edits start from the live objects, backing included (vcsim panicked on a disk without one). destroy_vm, get_vm_status, get_network_targets (port groups with their fixed VLAN) and get_image_storages complete the contract. reboot_host on ESXi uses RebootHost_Task and refuses outside maintenance mode: force=True would cut power to running VMs. Tested against vcsim in ESXi and vCenter mode, end to end.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"""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 "<rasd:VirtualQuantity>2048</rasd:VirtualQuantity>" in descriptor
|
||||
assert descriptor.count("<rasd:ResourceType>10</rasd:ResourceType>") == 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_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)
|
||||
Reference in New Issue
Block a user