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.
114 lines
3.3 KiB
Python
114 lines
3.3 KiB
Python
"""The two HTTP transfers provisioning needs: a disk over NFC, a file to a datastore.
|
|
|
|
Both authenticate with the vSphere session's own cookie, so no second login
|
|
and no credentials beyond the ones the driver already holds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
|
|
#: vSphere drops an NFC lease that reports no progress for five minutes.
|
|
_PROGRESS_EVERY_SECONDS = 20.0
|
|
|
|
|
|
class ProgressFile:
|
|
"""A file body with a length, calling ``report(percent)`` as it is read.
|
|
|
|
Sized so ``requests`` sends it with a Content-Length instead of chunked:
|
|
vSphere's NFC endpoint refuses a chunked disk upload.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
path: Path,
|
|
report: Callable[[int], None],
|
|
clock: Callable[[], float] = time.monotonic,
|
|
) -> None:
|
|
self._fh = path.open("rb")
|
|
self._size = path.stat().st_size
|
|
self._sent = 0
|
|
self._report = report
|
|
self._clock = clock
|
|
self._last = clock()
|
|
|
|
def __len__(self) -> int:
|
|
return self._size
|
|
|
|
def read(self, size: int = -1) -> bytes:
|
|
chunk = self._fh.read(size)
|
|
self._sent += len(chunk)
|
|
if chunk and self._clock() - self._last >= _PROGRESS_EVERY_SECONDS:
|
|
self._report(min(99, self._sent * 100 // (self._size or 1)))
|
|
self._last = self._clock()
|
|
return chunk
|
|
|
|
def close(self) -> None:
|
|
self._fh.close()
|
|
|
|
|
|
def upload_disk(
|
|
url: str, vmdk: Path, cookie: str, verify: bool, report: Callable[[int], None]
|
|
) -> None:
|
|
"""Stream a streamOptimized VMDK to an NFC lease's device URL."""
|
|
body = ProgressFile(vmdk, report)
|
|
try:
|
|
response = requests.post(
|
|
url,
|
|
data=body,
|
|
headers={"Content-Type": "application/x-vnd.vmware-streamVmdk", "Cookie": cookie},
|
|
verify=verify,
|
|
timeout=(30, 600),
|
|
)
|
|
finally:
|
|
body.close()
|
|
response.raise_for_status()
|
|
|
|
|
|
def lease_url(url: str, hostname: str, port: int) -> str:
|
|
"""An NFC device URL with its ``*`` host filled in.
|
|
|
|
ESXi answers ``https://*/nfc/...``: "the host you are talking to". A port
|
|
other than 443 has to be carried over, or the upload misses a NAT'd host.
|
|
Through a vCenter the URL already names the ESXi host that takes the disk.
|
|
"""
|
|
host = hostname if port == 443 else f"{hostname}:{port}"
|
|
return url.replace("://*/", f"://{host}/", 1)
|
|
|
|
|
|
def datastore_url(base: str, path: str) -> str:
|
|
"""``https://host/folder/<path>`` -- the datastore file browser endpoint."""
|
|
return f"{base}/folder/{quote(path)}"
|
|
|
|
|
|
def upload_file(
|
|
base: str,
|
|
datacenter: str,
|
|
datastore: str,
|
|
path: str,
|
|
data: bytes,
|
|
cookie: str,
|
|
verify: bool,
|
|
) -> None:
|
|
"""Put ``data`` at ``[datastore] path``."""
|
|
response = requests.put(
|
|
datastore_url(base, path),
|
|
params={"dcPath": datacenter, "dsName": datastore},
|
|
data=data,
|
|
headers={"Content-Type": "application/octet-stream", "Cookie": cookie},
|
|
verify=verify,
|
|
timeout=(30, 120),
|
|
)
|
|
response.raise_for_status()
|
|
|
|
|
|
def session_cookie(si: Any) -> str:
|
|
"""The ``vmware_soap_session`` cookie of a pyVmomi ServiceInstance."""
|
|
return si._stub.cookie
|