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:
@@ -132,3 +132,21 @@ class TestSnapshots:
|
||||
def test_unknown_snapshot(self, driver, vm0):
|
||||
with pytest.raises(ValueError, match="no snapshot"):
|
||||
driver.rollback_vm_snapshot(vm0["name"], "nope")
|
||||
|
||||
|
||||
class TestRebootHost:
|
||||
def test_esxi_in_maintenance_mode_is_rebooted(self, driver, esxi_host):
|
||||
esxi_host["runtime.inMaintenanceMode"] = True
|
||||
driver.reboot_host()
|
||||
driver.mo.RebootHost_Task.assert_called_once_with(force=False)
|
||||
|
||||
def test_refused_outside_maintenance_mode(self, driver):
|
||||
"""force=True would power off running VMs without asking them."""
|
||||
with pytest.raises(RuntimeError, match="maintenance mode"):
|
||||
driver.reboot_host()
|
||||
driver.mo.RebootHost_Task.assert_not_called()
|
||||
|
||||
def test_a_vcenter_cannot_reboot_itself(self):
|
||||
from napalm_vmware import VmwareVcenterDriver
|
||||
|
||||
assert not hasattr(VmwareVcenterDriver, "reboot_host")
|
||||
|
||||
+16
-3
@@ -80,9 +80,22 @@ class TestIdentity:
|
||||
assert callable(getattr(cls, method, None))
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_does_not_claim_provisioning(self, cls):
|
||||
"""netOrk offers VM creation wherever this exists; v1 cannot do it."""
|
||||
assert not hasattr(cls, "create_vm_from_cloud_init")
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
"create_vm_from_cloud_init",
|
||||
"destroy_vm",
|
||||
"get_vm_status",
|
||||
"get_network_targets",
|
||||
"get_image_storages",
|
||||
],
|
||||
)
|
||||
def test_implements_provisioning(self, cls, method):
|
||||
assert callable(getattr(cls, method, None))
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_declares_its_guest_agent(self, cls):
|
||||
assert cls.GUEST_AGENT_PACKAGES == ("open-vm-tools",)
|
||||
|
||||
|
||||
class TestOptionalArgs:
|
||||
|
||||
@@ -119,3 +119,15 @@ def test_properties_of_a_single_object(content):
|
||||
def test_properties_of_a_vanished_object(content):
|
||||
content.propertyCollector.RetrievePropertiesEx.return_value = None
|
||||
assert Inventory(content).properties(vim.Task("task-7", None), ["info.state"]) == {}
|
||||
|
||||
|
||||
def test_raw_properties_keep_the_pyvmomi_objects(content):
|
||||
disk = vim.vm.device.VirtualDisk(key=2000, capacityInKB=1024)
|
||||
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
|
||||
objects=[_content(vim.VirtualMachine("vm-1", None), {"config.hardware.device": [disk]})],
|
||||
token=None,
|
||||
)
|
||||
props = Inventory(content).properties(
|
||||
vim.VirtualMachine("vm-1", None), ["config.hardware.device"], raw=True
|
||||
)
|
||||
assert props["config.hardware.device"][0] is disk
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Turning a catalog cloud image (qcow2/raw) into a VMDK vSphere can import."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_vmware.provision import image as image_mod
|
||||
from napalm_vmware.provision.image import ImageCache
|
||||
|
||||
URL = "https://cloud.example.org/debian-13-genericcloud-amd64.qcow2"
|
||||
CONTENT = b"qcow2-bytes"
|
||||
SHA = hashlib.sha256(CONTENT).hexdigest()
|
||||
|
||||
|
||||
class _Fake:
|
||||
def __init__(self, payloads=(CONTENT,)):
|
||||
self.payloads = list(payloads)
|
||||
self.fetched = 0
|
||||
self.converted = 0
|
||||
|
||||
def fetch(self, url, dest, timeout):
|
||||
dest.write_bytes(self.payloads[min(self.fetched, len(self.payloads) - 1)])
|
||||
self.fetched += 1
|
||||
|
||||
def convert(self, src, dst):
|
||||
dst.write_bytes(b"vmdk:" + src.read_bytes())
|
||||
self.converted += 1
|
||||
|
||||
def size(self, path):
|
||||
return 2 * 1024**3
|
||||
|
||||
|
||||
def _cache(tmp_path, fake):
|
||||
return ImageCache(tmp_path, fetch=fake.fetch, convert=fake.convert, virtual_size=fake.size)
|
||||
|
||||
|
||||
def test_first_use_downloads_and_converts(tmp_path):
|
||||
fake = _Fake()
|
||||
vmdk, size = _cache(tmp_path, fake).vmdk(URL, f"sha256:{SHA}", timeout=60)
|
||||
assert vmdk.read_bytes() == b"vmdk:" + CONTENT
|
||||
assert size == 2 * 1024**3
|
||||
assert (fake.fetched, fake.converted) == (1, 1)
|
||||
|
||||
|
||||
def test_second_use_is_served_from_the_cache(tmp_path):
|
||||
fake = _Fake()
|
||||
_cache(tmp_path, fake).vmdk(URL, None, timeout=60)
|
||||
vmdk, size = _cache(tmp_path, fake).vmdk(URL, None, timeout=60)
|
||||
assert (fake.fetched, fake.converted) == (1, 1)
|
||||
assert size == 2 * 1024**3
|
||||
|
||||
|
||||
def test_the_download_is_not_kept_after_conversion(tmp_path):
|
||||
_cache(tmp_path, _Fake()).vmdk(URL, None, timeout=60)
|
||||
assert [p.suffix for p in tmp_path.iterdir()] and not list(tmp_path.glob("*.download"))
|
||||
|
||||
|
||||
def test_a_corrupt_download_is_retried_once(tmp_path):
|
||||
fake = _Fake(payloads=(b"truncated", CONTENT))
|
||||
vmdk, _ = _cache(tmp_path, fake).vmdk(URL, f"sha256:{SHA}", timeout=60)
|
||||
assert fake.fetched == 2
|
||||
assert vmdk.read_bytes() == b"vmdk:" + CONTENT
|
||||
|
||||
|
||||
def test_a_persistent_mismatch_is_an_error(tmp_path):
|
||||
fake = _Fake(payloads=(b"wrong",))
|
||||
with pytest.raises(RuntimeError, match="Checksum mismatch"):
|
||||
_cache(tmp_path, fake).vmdk(URL, f"sha256:{SHA}", timeout=60)
|
||||
assert fake.converted == 0
|
||||
assert not list(tmp_path.glob("*.vmdk"))
|
||||
|
||||
|
||||
def test_bare_hex_checksum_means_sha256(tmp_path):
|
||||
_cache(tmp_path, _Fake()).vmdk(URL, SHA, timeout=60)
|
||||
|
||||
|
||||
def test_different_urls_do_not_share_a_cache_entry(tmp_path):
|
||||
fake = _Fake()
|
||||
cache = _cache(tmp_path, fake)
|
||||
cache.vmdk(URL, None, timeout=60)
|
||||
cache.vmdk(URL + "?v=2", None, timeout=60)
|
||||
assert fake.converted == 2
|
||||
|
||||
|
||||
def test_missing_qemu_img_is_explained(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(image_mod.shutil, "which", lambda name: None)
|
||||
with pytest.raises(RuntimeError, match="qemu-img"):
|
||||
image_mod.convert_to_vmdk(tmp_path / "a", tmp_path / "b")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("qemu-img"), reason="qemu-img not installed")
|
||||
def test_real_conversion_round_trip(tmp_path):
|
||||
raw = tmp_path / "disk.raw"
|
||||
subprocess.run(
|
||||
["qemu-img", "create", "-f", "raw", str(raw), "64M"], check=True, capture_output=True
|
||||
)
|
||||
vmdk = tmp_path / "disk.vmdk"
|
||||
image_mod.convert_to_vmdk(raw, vmdk)
|
||||
info = subprocess.run(
|
||||
["qemu-img", "info", str(vmdk)], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
assert "streamOptimized" in info
|
||||
assert image_mod.virtual_size(raw) == 64 * 1024**2
|
||||
@@ -0,0 +1,79 @@
|
||||
"""The OVF descriptor an import is built from."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_vmware.provision.ovf import ovf_descriptor
|
||||
|
||||
NS = {
|
||||
"ovf": "http://schemas.dmtf.org/ovf/envelope/1",
|
||||
"rasd": "http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData",
|
||||
"vmw": "http://www.vmware.com/schema/ovf",
|
||||
}
|
||||
OVF = "{http://schemas.dmtf.org/ovf/envelope/1}"
|
||||
RASD = "{" + NS["rasd"] + "}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root():
|
||||
return ET.fromstring(
|
||||
ovf_descriptor("web01", cpu=2, memory_mb=4096, capacity_bytes=10 * 1024**3, nic_count=2)
|
||||
)
|
||||
|
||||
|
||||
def _items(root):
|
||||
return root.findall(".//ovf:VirtualHardwareSection/ovf:Item", NS)
|
||||
|
||||
|
||||
def _by_type(root, resource_type):
|
||||
return [i for i in _items(root) if i.find("rasd:ResourceType", NS).text == str(resource_type)]
|
||||
|
||||
|
||||
def test_cpu_and_memory(root):
|
||||
(cpu,) = _by_type(root, 3)
|
||||
(mem,) = _by_type(root, 4)
|
||||
assert cpu.find("rasd:VirtualQuantity", NS).text == "2"
|
||||
assert mem.find("rasd:VirtualQuantity", NS).text == "4096"
|
||||
|
||||
|
||||
def test_disk_references_the_stream_optimized_file(root):
|
||||
disk = root.find(".//ovf:DiskSection/ovf:Disk", NS)
|
||||
assert disk.get(f"{OVF}capacity") == str(10 * 1024**3)
|
||||
assert disk.get(f"{OVF}format").endswith("#streamOptimized")
|
||||
(file_ref,) = root.findall(".//ovf:References/ovf:File", NS)
|
||||
assert disk.get(f"{OVF}fileRef") == file_ref.get(f"{OVF}id")
|
||||
(item,) = _by_type(root, 17)
|
||||
assert item.find("rasd:HostResource", NS).text == f"ovf:/disk/{disk.get(f'{OVF}diskId')}"
|
||||
|
||||
|
||||
def test_one_vmxnet3_nic_per_network(root):
|
||||
nics = _by_type(root, 10)
|
||||
assert [n.find("rasd:Connection", NS).text for n in nics] == ["net0", "net1"]
|
||||
assert {n.find("rasd:ResourceSubType", NS).text for n in nics} == {"VmxNet3"}
|
||||
networks = root.findall(".//ovf:NetworkSection/ovf:Network", NS)
|
||||
assert [n.get(f"{OVF}name") for n in networks] == ["net0", "net1"]
|
||||
|
||||
|
||||
def test_rasd_elements_are_in_schema_order(root):
|
||||
"""vSphere rejects an Item whose CIM elements are out of order."""
|
||||
for item in _items(root):
|
||||
names = [child.tag.removeprefix(RASD) for child in item]
|
||||
assert names == sorted(names), names
|
||||
|
||||
|
||||
def test_name_is_escaped():
|
||||
ET.fromstring(ovf_descriptor("a<b&c", cpu=1, memory_mb=512, capacity_bytes=1, nic_count=1))
|
||||
|
||||
|
||||
def test_efi_firmware():
|
||||
root = ET.fromstring(
|
||||
ovf_descriptor("x", cpu=1, memory_mb=512, capacity_bytes=1, nic_count=1, firmware="efi")
|
||||
)
|
||||
config = root.find(".//vmw:Config", NS)
|
||||
assert (config.get("{" + NS["vmw"] + "}key"), config.get("{" + NS["vmw"] + "}value")) == (
|
||||
"firmware",
|
||||
"efi",
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Where a new VM goes: host, datastore, resource pool, folder, networks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_vmware.provision.placement import Inventory as Inv
|
||||
from napalm_vmware.provision.placement import choose_placement
|
||||
|
||||
|
||||
def _host(moref, name, *, datastores, networks, parent="domain-c1", state="connected", maint=False):
|
||||
return {
|
||||
"_moref": moref,
|
||||
"name": name,
|
||||
"parent": parent,
|
||||
"datastore": datastores,
|
||||
"network": networks,
|
||||
"runtime.connectionState": state,
|
||||
"runtime.inMaintenanceMode": maint,
|
||||
}
|
||||
|
||||
|
||||
def _ds(moref, name, free, *, accessible=True, maintenance="normal"):
|
||||
return {
|
||||
"_moref": moref,
|
||||
"name": name,
|
||||
"summary": {"accessible": accessible, "freeSpace": free, "maintenanceMode": maintenance},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inv():
|
||||
return Inv(
|
||||
hosts=[
|
||||
_host("host-1", "esx01", datastores=["ds-1", "ds-2"], networks=["net-1", "dvpg-1"]),
|
||||
_host("host-2", "esx02", datastores=["ds-2", "ds-3"], networks=["net-2"]),
|
||||
],
|
||||
datastores=[_ds("ds-1", "local1", 50), _ds("ds-2", "san", 900), _ds("ds-3", "local2", 100)],
|
||||
networks=[
|
||||
{"_moref": "net-1", "name": "VM Network"},
|
||||
{"_moref": "net-2", "name": "VM Network"},
|
||||
{"_moref": "dvpg-1", "name": "DC0_DVPG0"},
|
||||
],
|
||||
compute_resources=[
|
||||
{"_moref": "domain-c1", "parent": "group-h1", "resourcePool": "resgroup-1"}
|
||||
],
|
||||
folders=[
|
||||
{"_moref": "group-h1", "parent": "datacenter-1"},
|
||||
{"_moref": "group-v1", "parent": "datacenter-1"},
|
||||
],
|
||||
datacenters=[{"_moref": "datacenter-1", "name": "DC0", "vmFolder": "group-v1"}],
|
||||
)
|
||||
|
||||
|
||||
def test_most_free_space_wins_when_no_storage_is_named(inv):
|
||||
p = choose_placement(inv, storage=None, networks=["VM Network"])
|
||||
assert (p.host, p.host_name, p.datastore, p.datastore_name) == (
|
||||
"host-1",
|
||||
"esx01",
|
||||
"ds-2",
|
||||
"san",
|
||||
)
|
||||
assert (p.resource_pool, p.folder, p.datacenter_name) == ("resgroup-1", "group-v1", "DC0")
|
||||
|
||||
|
||||
def test_the_network_moref_is_the_one_on_the_chosen_host(inv):
|
||||
p = choose_placement(inv, storage="local2", networks=["VM Network"])
|
||||
assert (p.host, p.networks) == ("host-2", ["net-2"])
|
||||
|
||||
|
||||
def test_a_host_must_see_every_requested_network(inv):
|
||||
p = choose_placement(inv, storage=None, networks=["VM Network", "DC0_DVPG0"])
|
||||
assert p.host == "host-1"
|
||||
assert p.networks == ["net-1", "dvpg-1"]
|
||||
|
||||
|
||||
def test_named_datastore(inv):
|
||||
assert choose_placement(inv, storage="local1", networks=["VM Network"]).datastore == "ds-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("change", "message"),
|
||||
[
|
||||
(lambda i: None, "no usable datastore named 'nope'"),
|
||||
],
|
||||
)
|
||||
def test_unknown_datastore(inv, change, message):
|
||||
change(inv)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
choose_placement(inv, storage="nope", networks=["VM Network"])
|
||||
|
||||
|
||||
def test_unknown_network(inv):
|
||||
with pytest.raises(ValueError, match="no host"):
|
||||
choose_placement(inv, storage=None, networks=["Missing"])
|
||||
|
||||
|
||||
def test_hosts_in_maintenance_or_disconnected_are_skipped(inv):
|
||||
inv.hosts[0]["runtime.inMaintenanceMode"] = True
|
||||
inv.hosts[1]["runtime.connectionState"] = "notResponding"
|
||||
with pytest.raises(ValueError, match="no host"):
|
||||
choose_placement(inv, storage=None, networks=["VM Network"])
|
||||
|
||||
|
||||
def test_inaccessible_datastores_are_skipped(inv):
|
||||
inv.datastores[1]["summary"]["accessible"] = False
|
||||
p = choose_placement(inv, storage=None, networks=["VM Network"])
|
||||
assert p.datastore_name == "local2"
|
||||
|
||||
|
||||
def test_datacenter_is_found_through_nested_folders(inv):
|
||||
inv.compute_resources[0]["parent"] = "group-h2"
|
||||
inv.folders.append({"_moref": "group-h2", "parent": "group-h1"})
|
||||
assert choose_placement(inv, storage=None, networks=["VM Network"]).datacenter_name == "DC0"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The NoCloud seed: cloud-init's user-data, meta-data and network-config on an ISO."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pycdlib
|
||||
import yaml
|
||||
|
||||
from napalm_vmware.provision.seed import meta_data, network_config, nocloud_iso, user_data
|
||||
|
||||
|
||||
class TestUserData:
|
||||
def test_is_a_cloud_config(self):
|
||||
text = user_data({"hostname": "web01", "packages": ["open-vm-tools"]}, None)
|
||||
assert text.startswith("#cloud-config\n")
|
||||
assert yaml.safe_load(text) == {"hostname": "web01", "packages": ["open-vm-tools"]}
|
||||
|
||||
def test_ssh_keys_are_added_without_duplicates(self):
|
||||
config = {"ssh_authorized_keys": ["ssh-ed25519 AAA a"]}
|
||||
text = user_data(config, ["ssh-ed25519 AAA a", "ssh-ed25519 BBB b"])
|
||||
assert yaml.safe_load(text)["ssh_authorized_keys"] == [
|
||||
"ssh-ed25519 AAA a",
|
||||
"ssh-ed25519 BBB b",
|
||||
]
|
||||
assert config == {"ssh_authorized_keys": ["ssh-ed25519 AAA a"]} # caller's dict untouched
|
||||
|
||||
|
||||
def test_meta_data():
|
||||
assert yaml.safe_load(meta_data("web01", "iid-1")) == {
|
||||
"instance-id": "iid-1",
|
||||
"local-hostname": "web01",
|
||||
}
|
||||
|
||||
|
||||
class TestNetworkConfig:
|
||||
def test_dhcp_nics_matched_by_mac(self):
|
||||
cfg = network_config([("00:50:56:aa:bb:cc", True), ("00:50:56:aa:bb:dd", False)])
|
||||
assert cfg == {
|
||||
"version": 2,
|
||||
"ethernets": {
|
||||
"nic0": {"match": {"macaddress": "00:50:56:aa:bb:cc"}, "dhcp4": True},
|
||||
},
|
||||
}
|
||||
|
||||
def test_no_dhcp_nic_means_no_network_config(self):
|
||||
assert network_config([("00:50:56:aa:bb:cc", False)]) is None
|
||||
|
||||
|
||||
def _read(iso: bytes, name: str) -> str:
|
||||
reader = pycdlib.PyCdlib()
|
||||
reader.open_fp(io.BytesIO(iso))
|
||||
out = io.BytesIO()
|
||||
reader.get_file_from_iso_fp(out, rr_path=f"/{name}")
|
||||
reader.close()
|
||||
return out.getvalue().decode()
|
||||
|
||||
|
||||
class TestIso:
|
||||
def test_carries_the_three_files_under_their_cloud_init_names(self):
|
||||
iso = nocloud_iso("#cloud-config\n{}\n", "instance-id: x\n", {"version": 2})
|
||||
assert _read(iso, "user-data") == "#cloud-config\n{}\n"
|
||||
assert _read(iso, "meta-data") == "instance-id: x\n"
|
||||
assert yaml.safe_load(_read(iso, "network-config")) == {"version": 2}
|
||||
|
||||
def test_volume_label_is_cidata(self):
|
||||
"""cloud-init's NoCloud datasource finds the seed by this label."""
|
||||
reader = pycdlib.PyCdlib()
|
||||
reader.open_fp(io.BytesIO(nocloud_iso("u", "m", None)))
|
||||
assert reader.pvd.volume_identifier.decode().strip() == "cidata"
|
||||
reader.close()
|
||||
|
||||
def test_without_network_config(self):
|
||||
reader = pycdlib.PyCdlib()
|
||||
reader.open_fp(io.BytesIO(nocloud_iso("u", "m", None)))
|
||||
names = [
|
||||
c.rock_ridge.name().decode() for c in reader.list_children(rr_path="/") if c.rock_ridge
|
||||
]
|
||||
reader.close()
|
||||
assert "network-config" not in names
|
||||
@@ -0,0 +1,63 @@
|
||||
"""NFC upload body with lease progress, lease URLs and datastore URLs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from napalm_vmware.provision.transfer import ProgressFile, datastore_url, lease_url
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self):
|
||||
self.now += 15.0 # each read "takes" 15 s
|
||||
return self.now
|
||||
|
||||
|
||||
class TestProgressFile:
|
||||
def test_has_a_length_so_requests_does_not_chunk(self, tmp_path):
|
||||
"""A generator body is sent chunked; with a Content-Length header too,
|
||||
vSphere's NFC endpoint answers 500. A sized body avoids both."""
|
||||
path = tmp_path / "disk.vmdk"
|
||||
path.write_bytes(b"x" * 1000)
|
||||
body = ProgressFile(path, lambda pct: None)
|
||||
assert len(body) == 1000
|
||||
body.close()
|
||||
|
||||
def test_reads_everything_and_reports_rising_progress(self, tmp_path):
|
||||
path = tmp_path / "disk.vmdk"
|
||||
path.write_bytes(b"x" * (3 * 1024 * 1024 + 5))
|
||||
reports = []
|
||||
body = ProgressFile(path, reports.append, clock=_Clock())
|
||||
data = b"".join(iter(lambda: body.read(1024 * 1024), b""))
|
||||
body.close()
|
||||
assert len(data) == path.stat().st_size
|
||||
assert reports and reports == sorted(reports) and all(0 <= r < 100 for r in reports)
|
||||
|
||||
|
||||
class TestLeaseUrl:
|
||||
def test_star_becomes_the_host_on_the_default_port(self):
|
||||
assert (
|
||||
lease_url("https://*/nfc/x/disk-0.vmdk", "esx01", 443)
|
||||
== "https://esx01/nfc/x/disk-0.vmdk"
|
||||
)
|
||||
|
||||
def test_star_carries_a_non_default_port(self):
|
||||
assert (
|
||||
lease_url("https://*/nfc/x/d.vmdk", "10.0.0.5", 8443)
|
||||
== "https://10.0.0.5:8443/nfc/x/d.vmdk"
|
||||
)
|
||||
|
||||
def test_a_named_host_is_left_alone(self):
|
||||
"""Through vCenter the URL names the ESXi host that takes the disk."""
|
||||
assert (
|
||||
lease_url("https://esx02.lan/nfc/x/d.vmdk", "vc01", 443)
|
||||
== "https://esx02.lan/nfc/x/d.vmdk"
|
||||
)
|
||||
|
||||
|
||||
def test_datastore_url_quotes_the_path():
|
||||
assert (
|
||||
datastore_url("https://esx01:443", "web 01/cidata.iso")
|
||||
== "https://esx01:443/folder/web%2001/cidata.iso"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -103,3 +103,49 @@ def test_wrong_driver_is_refused():
|
||||
|
||||
def _status(driver, vm):
|
||||
return next(v["status"] for v in driver.get_vms() if v["vmid"] == vm["vmid"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_url(tmp_path):
|
||||
"""A tiny qcow2 served over HTTP, like a catalog image."""
|
||||
import functools
|
||||
import http.server
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
if not shutil.which("qemu-img"):
|
||||
pytest.skip("qemu-img not installed")
|
||||
subprocess.run(
|
||||
["qemu-img", "create", "-q", "-f", "qcow2", str(tmp_path / "tiny.qcow2"), "64M"], check=True
|
||||
)
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(tmp_path))
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_address[1]}/tiny.qcow2"
|
||||
server.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("which", ["esxi", "vcenter"])
|
||||
def test_provision_and_destroy(which, image_url, tmp_path, request):
|
||||
driver = request.getfixturevalue(which)
|
||||
driver._image_cache_dir = tmp_path / "cache"
|
||||
result = driver.create_vm_from_cloud_init(
|
||||
"netork-e2e",
|
||||
image_url=image_url,
|
||||
cpu=2,
|
||||
memory=1024,
|
||||
nics=[{"bridge": "VM Network", "dhcp": True, "mac": "00:50:56:01:02:03"}],
|
||||
cloud_init_config={"hostname": "netork-e2e"},
|
||||
ssh_public_keys=["ssh-ed25519 AAAA test"],
|
||||
disk_resize_gb=1,
|
||||
)
|
||||
try:
|
||||
assert driver.get_vm_status(result["vmid"])["status"] == "running"
|
||||
config = driver.get_vm_config(result["vmid"])
|
||||
assert (config["vcpus"], config["memory"]) == (2, 1024)
|
||||
assert config["disks"][0]["size"] == 1
|
||||
assert config["nics"][0]["mac"] == "00:50:56:01:02:03"
|
||||
finally:
|
||||
driver.destroy_vm(result["vmid"])
|
||||
assert not any(vm["vmid"] == result["vmid"] for vm in driver.get_vms())
|
||||
|
||||
Reference in New Issue
Block a user