napalm_device_types' create_vm_from_cloud_init gained cpu_type, the CPU model a new VM gets (netork#494). VMware sets CPU compatibility per cluster (EVC), not per VM, and this driver has no get_vm_cpu_types() to offer a model, so None is the only value it can honour. Anything else raises ValueError before the image is fetched rather than being dropped silently.
402 lines
16 KiB
Python
402 lines
16 KiB
Python
"""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,
|
|
cpu_type: str | None = None,
|
|
download_timeout: int = 300,
|
|
timeout: int = 180,
|
|
) -> VMProvisionResultDict:
|
|
if cpu_type is not None:
|
|
# CPU compatibility is a cluster setting here (EVC), not a per-VM
|
|
# one, and there is no get_vm_cpu_types() that could offer a model.
|
|
raise ValueError(f"VMware cannot give a single VM a CPU type ({cpu_type!r})")
|
|
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),
|
|
]
|