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.
397 lines
16 KiB
Python
397 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,
|
|
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),
|
|
]
|