"""HypervisorDriver provisioning: new VMs from netOrk's cloud-image catalog. The image is downloaded and converted where the driver runs (``provision/image.py``), imported over OVF/NFC, and configured by cloud-init from a NoCloud ISO placed next to the VM's files. See the README for why this path and not OVA templates or a Content Library. """ from __future__ import annotations import logging import posixpath import time import uuid from pathlib import Path from typing import Any from napalm_device_types.models import ( NetworkTargetDict, NICConfigDict, StorageTargetDict, VMProvisionResultDict, VMStatusDict, ) from pyVmomi import vim from napalm_vmware import paths from napalm_vmware._plain import to_plain from napalm_vmware._tasks import fault_message, invoke, wait_for_task, wait_until from napalm_vmware.parse import vm_devices as dev from napalm_vmware.parse.networks import virtual_networks from napalm_vmware.parse.vms import vm_list from napalm_vmware.provision import transfer from napalm_vmware.provision.image import ImageCache from napalm_vmware.provision.ovf import ovf_descriptor from napalm_vmware.provision.placement import Inventory as PlacementInventory from napalm_vmware.provision.placement import Placement, choose_placement from napalm_vmware.provision.seed import meta_data, network_config, nocloud_iso, user_data logger = logging.getLogger(__name__) _GB = 1024**3 _SEED_ISO = "cidata.iso" _TASK_TIMEOUT = 300 _STATUS = {"poweredOn": "running", "poweredOff": "stopped", "suspended": "suspended"} class VmwareProvisioningMixin: """Mixed into both drivers ahead of :class:`VmwareBaseDriver`.""" GUEST_AGENT_PACKAGES: tuple[str, ...] = ("open-vm-tools",) # The unit is open-vm-tools on Debian/Ubuntu and vmtoolsd on EL/Fedora. GUEST_AGENT_RUNCMD: tuple[str, ...] = ( "systemctl enable --now open-vm-tools || systemctl enable --now vmtoolsd", ) hostname: str _port: int _verify_ssl: bool _image_cache_dir: Path _si: Any _inventory: Any _mo: Any _find_vm: Any _hosts: Any _index: Any # -- choosing targets -------------------------------------------------------- def get_image_storages(self) -> list[StorageTargetDict]: result: list[StorageTargetDict] = [] for ds in self._inventory.collect("Datastore", paths.DATASTORE): summary = ds.get("summary") or {} if ( not summary.get("accessible") or summary.get("maintenanceMode", "normal") != "normal" ): continue result.append( { "name": ds.get("name", ""), "type": str(summary.get("type", "")).lower(), "total_gb": round(int(summary.get("capacity") or 0) / _GB, 1), "available_gb": round(int(summary.get("freeSpace") or 0) / _GB, 1), } ) return sorted(result, key=lambda s: s["name"]) def get_network_targets(self) -> list[NetworkTargetDict]: networks = virtual_networks( self._hosts(), self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP), self._inventory.collect("DistributedVirtualSwitch", paths.DV_SWITCH), ) # The port group fixes the VLAN; a NIC cannot add a tag of its own. return [ { "name": net["name"], "kind": "portgroup", "vlan_aware": False, "fixed_vlan_tag": net["vlan_id"] or None, } for net in sorted(networks.values(), key=lambda n: n["name"]) ] def _check_nics(self, nics: list[NICConfigDict]) -> None: if not nics: raise RuntimeError("A VM needs at least one network adapter") targets = {t["name"]: t for t in self.get_network_targets()} for nic in nics: target = targets.get(nic["bridge"]) if target is None: raise RuntimeError(f"There is no port group named {nic['bridge']!r}") if nic.get("trunk_vlan_tags"): raise RuntimeError("VMware port groups cannot trunk per adapter") tag = nic.get("vlan_tag") if tag and tag != target.get("fixed_vlan_tag"): raise RuntimeError( f"Port group {nic['bridge']!r} carries VLAN " f"{target.get('fixed_vlan_tag') or 'untagged'}, not {tag}; " "choose a port group on the VLAN instead" ) def _placement_inventory(self) -> PlacementInventory: collect = self._inventory.collect return PlacementInventory( hosts=self._hosts(), datastores=collect("Datastore", paths.DATASTORE), networks=collect("Network", paths.NETWORK), compute_resources=collect("ComputeResource", paths.COMPUTE_RESOURCE), folders=collect("Folder", paths.FOLDER), datacenters=collect("Datacenter", paths.DATACENTER), ) # -- creating ---------------------------------------------------------------- def create_vm_from_cloud_init( self, name: str, *, image_url: str, cpu: int, memory: int, nics: list[NICConfigDict], cloud_init_config: dict[str, Any], image_checksum: str | None = None, ssh_public_keys: list[str] | None = None, disk_resize_gb: int | None = None, storage: str | None = None, download_timeout: int = 300, timeout: int = 180, ) -> VMProvisionResultDict: self._check_nics(nics) try: vmdk, image_size = ImageCache(self._image_cache_dir).vmdk( image_url, image_checksum, download_timeout ) place = choose_placement( self._placement_inventory(), storage, [n["bridge"] for n in nics] ) except ValueError as exc: raise RuntimeError(str(exc)) from exc descriptor = ovf_descriptor( name, cpu=cpu, memory_mb=memory, capacity_bytes=image_size, nic_count=len(nics) ) vm_ref = self._import_ovf(name, descriptor, vmdk, place, timeout) try: self._finish_vm( vm_ref, name, place, nics, user_data(cloud_init_config, ssh_public_keys), disk_gb=disk_resize_gb, image_size=image_size, ) self._run_task(self._mo(vim.VirtualMachine, vm_ref).PowerOnVM_Task) except Exception as exc: logger.warning("Provisioning %s failed after import, removing it: %s", name, exc) self._discard(vm_ref) raise RuntimeError(f"Provisioning {name!r} failed: {exc}") from exc props = self._inventory.properties( self._mo(vim.VirtualMachine, vm_ref), ["config.instanceUuid"] ) return {"vmid": props["config.instanceUuid"], "name": name, "node": place.host_name} def _run_task(self, call: Any, *args: Any, **kwargs: Any) -> None: wait_for_task(self._inventory, invoke(call, *args, **kwargs), _TASK_TIMEOUT) def _import_ovf( # pragma: no cover - live NFC session; covered by tests/test_vcsim.py self, name: str, descriptor: str, vmdk: Path, place: Placement, timeout: int ) -> str: """Create the VM from ``descriptor`` and stream ``vmdk`` into it; return its MoRef.""" content = self._si.RetrieveContent() pool = self._mo(vim.ResourcePool, place.resource_pool) params = vim.OvfManager.CreateImportSpecParams( entityName=name, diskProvisioning="thin", networkMapping=[ vim.OvfManager.NetworkMapping(name=f"net{i}", network=self._mo(vim.Network, ref)) for i, ref in enumerate(place.networks) ], ) spec = invoke( content.ovfManager.CreateImportSpec, descriptor, pool, self._mo(vim.Datastore, place.datastore), params, ) if spec.error: raise RuntimeError("; ".join(e.msg or type(e).__name__ for e in spec.error)) lease = invoke( pool.ImportVApp, spec.importSpec, self._mo(vim.Folder, place.folder), self._mo(vim.HostSystem, place.host), ) try: wait_until( lambda: ( self._inventory.properties(lease, ["state"]).get("state") in ("ready", "error") ), timeout, "the import lease", ) info = self._inventory.properties(lease, ["state", "error", "info"]) if info.get("state") == "error": raise RuntimeError(fault_message(info.get("error") or {})) url = transfer.lease_url(info["info"]["deviceUrl"][0]["url"], self.hostname, self._port) transfer.upload_disk( url, vmdk, transfer.session_cookie(self._si), self._verify_ssl, report=lambda pct: lease.HttpNfcLeaseProgress(pct), ) lease.HttpNfcLeaseComplete() except Exception: try: lease.HttpNfcLeaseAbort() except Exception: # noqa: BLE001 - the original error is the one to report pass raise return info["info"]["entity"] def _finish_vm( self, vm_ref: str, name: str, place: Placement, nics: list[NICConfigDict], user: str, *, disk_gb: int | None, image_size: int, ) -> None: """MACs, disk size and the cloud-init seed: everything the OVF could not say.""" vm_mo = self._mo(vim.VirtualMachine, vm_ref) props = self._inventory.properties( vm_mo, ["config.hardware.device", "config.files.vmPathName"], raw=True ) # Live device objects: an "edit" replaces the device with what is sent, # so it has to be the whole device, backing included. devices = list(props.get("config.hardware.device") or []) adapters = [d for d in devices if dev.is_nic(to_plain(d))] changes = _mac_changes(adapters, nics) + _disk_growth(devices, disk_gb, image_size) macs = [ (nic.get("mac") or adapter.macAddress or "").lower() for adapter, nic in zip(adapters, nics) ] seed = nocloud_iso( user, meta_data(name, f"iid-{uuid.uuid4()}"), network_config( [(mac, nic.get("dhcp", i == 0)) for i, (mac, nic) in enumerate(zip(macs, nics))] ), ) vm_dir = posixpath.dirname(str(props["config.files.vmPathName"]).split("] ", 1)[1]) self._upload_seed(place, f"{vm_dir}/{_SEED_ISO}", seed) iso_path = f"[{place.datastore_name}] {vm_dir}/{_SEED_ISO}" changes += _seed_cdrom(iso_path) self._run_task(vm_mo.ReconfigVM_Task, spec=vim.vm.ConfigSpec(deviceChange=changes)) def _upload_seed(self, place: Placement, path: str, data: bytes) -> None: # pragma: no cover transfer.upload_file( f"https://{self.hostname}:{self._port}", place.datacenter_name, place.datastore_name, path, data, transfer.session_cookie(self._si), self._verify_ssl, ) def _discard(self, vm_ref: str) -> None: vm_mo = self._mo(vim.VirtualMachine, vm_ref) try: state = self._inventory.properties(vm_mo, ["runtime.powerState"]).get( "runtime.powerState" ) if state == "poweredOn": self._run_task(vm_mo.PowerOffVM_Task) self._run_task(vm_mo.Destroy_Task) except Exception as exc: # noqa: BLE001 - report the provisioning error, not this one logger.error("Could not remove half-provisioned VM %s: %s", vm_ref, exc) # -- after creation ------------------------------------------------------------ def destroy_vm(self, vmid: str, *, remove_disk: bool = True, timeout: int = 60) -> None: try: vm = self._find_vm(vmid) except ValueError as exc: raise RuntimeError(str(exc)) from exc vm_mo = self._mo(vim.VirtualMachine, vm["_moref"]) if vm.get("runtime.powerState") == "poweredOn": self._run_task(vm_mo.PowerOffVM_Task) if remove_disk: self._run_task(vm_mo.Destroy_Task) else: invoke(vm_mo.UnregisterVM) def get_vm_status( self, vmid: str, *, wait_for_ip: bool = False, timeout: int = 300, poll_interval: int = 5 ) -> VMStatusDict: deadline = time.monotonic() + timeout while True: status = self._vm_status(vmid) if not wait_for_ip or status.get("ip_address"): return status if time.monotonic() >= deadline: raise RuntimeError(f"VM {vmid} reported no IP address within {timeout}s") time.sleep(poll_interval) def _vm_status(self, vmid: str) -> VMStatusDict: try: vm = self._find_vm(vmid) except ValueError as exc: raise RuntimeError(str(exc)) from exc hosts = self._hosts() (entry,) = vm_list([vm], hosts, self._index(hosts)) status: VMStatusDict = {"status": _STATUS.get(vm.get("runtime.powerState", ""), "unknown")} if entry["ipv4"]: status["ip_address"] = entry["ipv4"] if vm.get("guest.hostName"): status["hostname"] = vm["guest.hostName"] first_nic = next(iter(entry["interfaces"].values()), None) if first_nic and first_nic["mac_address"]: status["mac_address"] = first_nic["mac_address"] return status def _mac_changes(adapters: list[Any], nics: list[NICConfigDict]) -> list[Any]: """Device edits pinning the MACs the caller asked for.""" changes = [] for adapter, nic in zip(adapters, nics): if not nic.get("mac"): continue adapter.addressType = "manual" adapter.macAddress = nic["mac"].lower() changes.append(vim.vm.device.VirtualDeviceSpec(operation="edit", device=adapter)) return changes def _disk_growth(devices: list[Any], disk_gb: int | None, image_size: int) -> list[Any]: if not disk_gb or disk_gb * _GB <= image_size: return [] disk = next(d for d in devices if isinstance(d, vim.vm.device.VirtualDisk)) disk.capacityInKB = disk_gb * 1024 * 1024 # Both fields, or the live object carries the old size in the other one; # pyVmomi's stubs type capacityInBytes as None, hence setattr. setattr(disk, "capacityInBytes", disk_gb * _GB) # noqa: B010 return [vim.vm.device.VirtualDeviceSpec(operation="edit", device=disk)] def _seed_cdrom(iso_path: str) -> list[Any]: """A SATA controller and a CD-ROM with the seed ISO, both new. The OVF describes only a SCSI controller; a CD-ROM needs IDE or SATA. Negative keys are placeholders vSphere replaces, and let the CD-ROM name its not-yet-existing controller. """ controller = vim.vm.device.VirtualAHCIController(key=-101, busNumber=0) cdrom = vim.vm.device.VirtualCdrom( key=-102, controllerKey=-101, unitNumber=0, backing=vim.vm.device.VirtualCdrom.IsoBackingInfo(fileName=iso_path), connectable=vim.vm.device.VirtualDevice.ConnectInfo( startConnected=True, connected=True, allowGuestControl=True ), ) return [ vim.vm.device.VirtualDeviceSpec(operation="add", device=controller), vim.vm.device.VirtualDeviceSpec(operation="add", device=cdrom), ]