feat: implement the HypervisorDriver VM contract
start_vm, stop_vm, reboot_vm, suspend_vm and get_vm_config existed only
as declarations. netOrk called Proxmox's own power_vm and read a VM's
raw config through _node_api(), so no other hypervisor could serve the
same endpoints. These let netOrk talk to every hypervisor alike.
The power methods accept a VM's name or vmid, wait for the Proxmox task,
and raise ValueError/RuntimeError as the contract says instead of
returning a result dict. A forced reboot of a container is stop + start,
since LXC has no reset; suspending a container is refused. power_vm is
unchanged for existing callers.
get_vm_config moves the config parsing netOrk did in
_parse_proxmox_hw_config into the driver and returns a VMConfigDict:
disks with storage and size, NICs with model, MAC, bridge and VLAN, CPU
topology, firmware, machine type and PCI/USB passthrough.
get_vms reports vmid as a string ("100"), following
napalm-device-types 2.0, still ordered numerically.
This commit is contained in:
@@ -49,6 +49,7 @@ from napalm_proxmox.sdn_mixin import ProxmoxSDNMixin
|
||||
from napalm_proxmox.lldp_mixin import ProxmoxLLDPMixin
|
||||
from napalm_proxmox.config_mixin import ProxmoxConfigMixin
|
||||
from napalm_proxmox.vm_mixin import ProxmoxVMMixin
|
||||
from napalm_proxmox.vm_contract_mixin import ProxmoxVMContractMixin
|
||||
from napalm_proxmox.vm_provision_mixin import ProxmoxVMProvisionMixin
|
||||
from napalm_proxmox.routing_mixin import ProxmoxRoutingMixin
|
||||
from napalm_proxmox.system_mixin import ProxmoxSystemMixin
|
||||
@@ -69,6 +70,7 @@ class ProxmoxDriver(
|
||||
ProxmoxLLDPMixin,
|
||||
ProxmoxConfigMixin,
|
||||
ProxmoxVMMixin,
|
||||
ProxmoxVMContractMixin,
|
||||
ProxmoxVMProvisionMixin,
|
||||
ProxmoxRoutingMixin,
|
||||
ProxmoxSystemMixin,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""HypervisorDriver contract methods for Proxmox VE: power actions and VM config.
|
||||
|
||||
``power_vm`` stays for callers that already use it; these are what a
|
||||
hypervisor-neutral caller talks to. They raise instead of returning a
|
||||
``{"success": ...}`` dict, and block until Proxmox reports the task finished.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import (
|
||||
VMConfigDict,
|
||||
VMDiskDict,
|
||||
VMNICDict,
|
||||
VMPassthroughDict,
|
||||
)
|
||||
|
||||
_JsonDict = dict[str, Any]
|
||||
|
||||
_POWER_TIMEOUT = 120
|
||||
_VM_DISK_KEY = re.compile(r"^(scsi|ide|virtio|sata)\d+$|^efidisk\d+$|^tpmstate\d+$")
|
||||
_CT_DISK_KEY = re.compile(r"^rootfs$|^mp\d+$")
|
||||
_NET_KEY = re.compile(r"^net\d+$")
|
||||
_PASSTHROUGH_KEY = re.compile(r"^(hostpci|usb)\d+$")
|
||||
_NIC_MODELS = {"virtio", "e1000", "e1000e", "vmxnet3", "rtl8139", "ne2k_pci"}
|
||||
_SIZE = re.compile(r"^(\d+(?:\.\d+)?)([KMGT]?)$", re.I)
|
||||
_GB_PER_UNIT = {"K": 1 / 1024**2, "M": 1 / 1024, "G": 1, "T": 1024, "": 1}
|
||||
|
||||
|
||||
def _options(value: str) -> tuple[str, dict[str, str]]:
|
||||
"""Split ``"volume,key=val,..."`` into the leading bare part and its options."""
|
||||
head = ""
|
||||
opts: dict[str, str] = {}
|
||||
for part in str(value).split(","):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
opts[k.strip().lower()] = v.strip()
|
||||
elif not head:
|
||||
head = part.strip()
|
||||
return head, opts
|
||||
|
||||
|
||||
def _size_gb(raw: str) -> int:
|
||||
m = _SIZE.match(raw or "")
|
||||
if not m:
|
||||
return 0
|
||||
return int(float(m.group(1)) * _GB_PER_UNIT[m.group(2).upper()])
|
||||
|
||||
|
||||
def _boot_order(cfg: _JsonDict) -> list[str]:
|
||||
boot = str(cfg.get("boot", "") or "")
|
||||
if boot.startswith("order="):
|
||||
return [d for d in boot[len("order=") :].split(";") if d]
|
||||
bootdisk = cfg.get("bootdisk")
|
||||
return [bootdisk] if bootdisk else []
|
||||
|
||||
|
||||
def _disks(cfg: _JsonDict, vm_type: str, boot_order: list[str]) -> list[VMDiskDict]:
|
||||
key_re = _VM_DISK_KEY if vm_type == "vm" else _CT_DISK_KEY
|
||||
disks: list[VMDiskDict] = []
|
||||
for key in sorted(cfg, key=lambda k: (k != "rootfs", k)):
|
||||
if not key_re.match(key):
|
||||
continue
|
||||
value = str(cfg[key] or "")
|
||||
head, opts = _options(value)
|
||||
if opts.get("media") == "cdrom" or head in ("none", "0", ""):
|
||||
continue
|
||||
disks.append(
|
||||
{
|
||||
"device": key,
|
||||
"storage": head.split(":", 1)[0],
|
||||
"size": _size_gb(opts.get("size", "")),
|
||||
"format": opts.get("format", ""),
|
||||
"bootable": key in boot_order,
|
||||
}
|
||||
)
|
||||
return disks
|
||||
|
||||
|
||||
def _nic(key: str, value: str) -> VMNICDict:
|
||||
_, opts = _options(value)
|
||||
model = next((m for m in _NIC_MODELS if m in opts), opts.get("type", ""))
|
||||
mac = opts.get(model, "") if model in _NIC_MODELS else opts.get("hwaddr", "")
|
||||
tag = opts.get("tag", "")
|
||||
return {
|
||||
"device": key,
|
||||
"mac": mac.upper(),
|
||||
"model": model,
|
||||
"bridge": opts.get("bridge", ""),
|
||||
"vlan_id": int(tag) if tag.isdigit() else 0,
|
||||
}
|
||||
|
||||
|
||||
def _passthrough(cfg: _JsonDict) -> list[VMPassthroughDict]:
|
||||
return [
|
||||
{"slot": key, "kind": "pci" if key.startswith("hostpci") else "usb", "config": str(val)}
|
||||
for key, val in sorted(cfg.items())
|
||||
if _PASSTHROUGH_KEY.match(key)
|
||||
]
|
||||
|
||||
|
||||
def parse_vm_config(vmid: str, vm_type: str, cfg: _JsonDict) -> VMConfigDict:
|
||||
"""Turn a raw ``/qemu/{id}/config`` or ``/lxc/{id}/config`` into a VMConfigDict."""
|
||||
is_vm = vm_type == "vm"
|
||||
cores = int(cfg.get("cores", 1) or 1)
|
||||
sockets = int(cfg.get("sockets", 1) or 1) if is_vm else 1
|
||||
boot_order = _boot_order(cfg)
|
||||
tags = str(cfg.get("tags", "") or "")
|
||||
name_key = "name" if is_vm else "hostname"
|
||||
result: VMConfigDict = {
|
||||
"name": cfg.get(name_key) or f"{'vm' if is_vm else 'ct'}-{vmid}",
|
||||
"vmid": vmid,
|
||||
"vcpus": cores * sockets,
|
||||
"memory": int(cfg.get("memory", 0) or 0),
|
||||
"os_type": cfg.get("ostype", ""),
|
||||
"boot_order": boot_order,
|
||||
"disks": _disks(cfg, vm_type, boot_order),
|
||||
"nics": [_nic(k, str(v)) for k, v in sorted(cfg.items()) if _NET_KEY.match(k)],
|
||||
"description": cfg.get("description", ""),
|
||||
"tags": [t for t in re.split(r"[;,\s]+", tags) if t],
|
||||
"passthrough": _passthrough(cfg),
|
||||
}
|
||||
if is_vm:
|
||||
result["cpu_type"] = str(cfg.get("cpu", "kvm64")).split(",")[0].removeprefix("cputype=")
|
||||
result["sockets"] = sockets
|
||||
result["cores_per_socket"] = cores
|
||||
result["firmware"] = "efi" if cfg.get("bios") == "ovmf" else "bios"
|
||||
if cfg.get("machine"):
|
||||
result["machine"] = cfg["machine"]
|
||||
return result
|
||||
|
||||
|
||||
class ProxmoxVMContractMixin:
|
||||
"""HypervisorDriver's VM methods on top of the Proxmox node API."""
|
||||
|
||||
def _resolve_vm(self, name: str) -> tuple[int, str]:
|
||||
"""Find a guest by vmid or display name; return ``(vmid, "vm"|"container")``."""
|
||||
node = self._node_api()
|
||||
for vm_type, listing in (("vm", node.qemu), ("container", node.lxc)):
|
||||
for guest in listing.get() or []:
|
||||
if str(guest.get("vmid")) == name or guest.get("name") == name:
|
||||
return int(guest["vmid"]), vm_type
|
||||
raise ValueError(f"No VM or container named or numbered {name!r}")
|
||||
|
||||
def _guest_api(self, vmid: int, vm_type: str) -> Any:
|
||||
node = self._node_api()
|
||||
return node.qemu(vmid) if vm_type == "vm" else node.lxc(vmid)
|
||||
|
||||
def _run_power(self, vmid: int, vm_type: str, action: str) -> None:
|
||||
try:
|
||||
upid = getattr(self._guest_api(vmid, vm_type).status, action).post()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"{action} of {vm_type} {vmid} failed: {exc}") from exc
|
||||
if upid:
|
||||
self._wait_for_task(upid, timeout=_POWER_TIMEOUT)
|
||||
|
||||
def start_vm(self, name: str) -> None:
|
||||
self._run_power(*self._resolve_vm(name), "start")
|
||||
|
||||
def stop_vm(self, name: str, force: bool = False) -> None:
|
||||
self._run_power(*self._resolve_vm(name), "stop" if force else "shutdown")
|
||||
|
||||
def reboot_vm(self, name: str, force: bool = False) -> None:
|
||||
vmid, vm_type = self._resolve_vm(name)
|
||||
if not force:
|
||||
self._run_power(vmid, vm_type, "reboot")
|
||||
elif vm_type == "vm":
|
||||
self._run_power(vmid, vm_type, "reset")
|
||||
else:
|
||||
self._run_power(vmid, vm_type, "stop")
|
||||
self._run_power(vmid, vm_type, "start")
|
||||
|
||||
def suspend_vm(self, name: str) -> None:
|
||||
vmid, vm_type = self._resolve_vm(name)
|
||||
if vm_type != "vm":
|
||||
raise RuntimeError(f"Proxmox cannot suspend container {vmid}")
|
||||
self._run_power(vmid, vm_type, "suspend")
|
||||
|
||||
def get_vm_config(self, name: str) -> VMConfigDict:
|
||||
vmid, vm_type = self._resolve_vm(name)
|
||||
cfg = self._guest_api(vmid, vm_type).config.get() or {}
|
||||
return parse_vm_config(str(vmid), vm_type, cfg)
|
||||
@@ -216,7 +216,7 @@ class ProxmoxVMMixin:
|
||||
"""Return all VMs (QEMU) and containers (LXC) on this node.
|
||||
|
||||
Each entry contains:
|
||||
* vmid (int) - Proxmox VM/container ID
|
||||
* vmid (str) - Proxmox VM/container ID, e.g. ``"100"``
|
||||
* name (str) - display name
|
||||
* type (str) - ``"vm"`` or ``"container"``
|
||||
* status (str) - ``"running"``, ``"stopped"``, etc.
|
||||
@@ -257,7 +257,7 @@ class ProxmoxVMMixin:
|
||||
|
||||
disks, onboot = self._get_vm_disk_and_boot(vmid, "qemu")
|
||||
result.append({
|
||||
"vmid": vmid,
|
||||
"vmid": str(vmid),
|
||||
"name": name,
|
||||
"type": "vm",
|
||||
"status": status,
|
||||
@@ -301,7 +301,7 @@ class ProxmoxVMMixin:
|
||||
|
||||
disks, onboot = self._get_vm_disk_and_boot(vmid, "lxc")
|
||||
result.append({
|
||||
"vmid": vmid,
|
||||
"vmid": str(vmid),
|
||||
"name": name,
|
||||
"type": "container",
|
||||
"status": status,
|
||||
@@ -321,7 +321,7 @@ class ProxmoxVMMixin:
|
||||
except Exception as exc:
|
||||
logger.warning("get_vms: failed to list LXC containers: %s", exc)
|
||||
|
||||
return sorted(result, key=lambda x: x["vmid"])
|
||||
return sorted(result, key=lambda x: int(x["vmid"]))
|
||||
|
||||
# Disk-key prefixes for QEMU: scsi, virtio, ide, sata (exclude cdrom/none entries)
|
||||
_DISK_KEYS_VM = re.compile(r"^(scsi|virtio|ide|sata)\d+$")
|
||||
|
||||
Reference in New Issue
Block a user