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:
@@ -0,0 +1,135 @@
|
||||
"""Cloud images (qcow2/raw) converted to streamOptimized VMDKs, cached by URL.
|
||||
|
||||
ESXi cannot boot a qcow2 and cannot download one itself, so the conversion
|
||||
runs where the driver runs: download, verify, ``qemu-img convert``. The
|
||||
result is kept, keyed by URL, so the next VM from the same image skips both
|
||||
steps. Only the converted disk is kept; the download is deleted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
_CHUNK = 1024 * 1024
|
||||
DEFAULT_CACHE_DIR = Path(tempfile.gettempdir()) / "napalm-vmware-images"
|
||||
|
||||
Fetch = Callable[[str, Path, float], None]
|
||||
Convert = Callable[[Path, Path], None]
|
||||
VirtualSize = Callable[[Path], int]
|
||||
|
||||
|
||||
def _qemu_img() -> str:
|
||||
path = shutil.which("qemu-img")
|
||||
if path is None:
|
||||
raise RuntimeError(
|
||||
"qemu-img is not installed where netOrk runs the provisioning job; "
|
||||
"it is needed to convert cloud images for VMware (package qemu-utils)"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def fetch(url: str, dest: Path, timeout: float) -> None: # pragma: no cover - network
|
||||
with requests.get(url, stream=True, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
with dest.open("wb") as out:
|
||||
for chunk in response.iter_content(_CHUNK):
|
||||
out.write(chunk)
|
||||
|
||||
|
||||
def convert_to_vmdk(src: Path, dst: Path) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
_qemu_img(),
|
||||
"convert",
|
||||
"-O",
|
||||
"vmdk",
|
||||
"-o",
|
||||
"subformat=streamOptimized",
|
||||
str(src),
|
||||
str(dst),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def virtual_size(path: Path) -> int:
|
||||
"""The disk size the image describes, in bytes (not the file size)."""
|
||||
out = subprocess.run(
|
||||
[_qemu_img(), "info", "--output", "json", str(path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
return int(json.loads(out)["virtual-size"])
|
||||
|
||||
|
||||
def _digest(path: Path, algorithm: str) -> str:
|
||||
h = hashlib.new(algorithm)
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(_CHUNK), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Converted images in ``directory``, one ``<key>.vmdk`` plus ``<key>.json`` each."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: Path = DEFAULT_CACHE_DIR,
|
||||
*,
|
||||
fetch: Fetch = fetch,
|
||||
convert: Convert = convert_to_vmdk,
|
||||
virtual_size: VirtualSize = virtual_size,
|
||||
) -> None:
|
||||
self._dir = Path(directory)
|
||||
self._fetch = fetch
|
||||
self._convert = convert
|
||||
self._virtual_size = virtual_size
|
||||
|
||||
def vmdk(self, url: str, checksum: str | None, timeout: float) -> tuple[Path, int]:
|
||||
"""``(path to the VMDK, virtual disk size in bytes)`` for ``url``."""
|
||||
self._dir.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||
vmdk, meta = self._dir / f"{key}.vmdk", self._dir / f"{key}.json"
|
||||
if vmdk.exists() and meta.exists():
|
||||
return vmdk, int(json.loads(meta.read_text())["virtual_size"])
|
||||
|
||||
download = self._dir / f"{key}.download"
|
||||
try:
|
||||
self._download_verified(url, checksum, download, timeout)
|
||||
size = self._virtual_size(download)
|
||||
partial = self._dir / f"{key}.vmdk.partial"
|
||||
self._convert(download, partial)
|
||||
# Rename last: a VMDK that exists is a VMDK that is complete, even
|
||||
# when two jobs convert the same image at once.
|
||||
os.replace(partial, vmdk)
|
||||
meta.write_text(json.dumps({"url": url, "virtual_size": size}))
|
||||
finally:
|
||||
download.unlink(missing_ok=True)
|
||||
return vmdk, size
|
||||
|
||||
def _download_verified(
|
||||
self, url: str, checksum: str | None, dest: Path, timeout: float
|
||||
) -> None:
|
||||
algorithm, _, expected = (checksum or "").rpartition(":")
|
||||
algorithm = (algorithm or "sha256").lower()
|
||||
for _attempt in (1, 2):
|
||||
self._fetch(url, dest, timeout)
|
||||
if not checksum:
|
||||
return
|
||||
actual = _digest(dest, algorithm)
|
||||
if actual.lower() == expected.lower():
|
||||
return
|
||||
dest.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Checksum mismatch for {url}: expected {expected}, got {actual}")
|
||||
Reference in New Issue
Block a user