feat!: a VM's vmid is a string, and its config can describe its hardware #1
@@ -70,6 +70,8 @@ Behaviour shared across roles lives in a function class, exactly once, and a rol
|
||||
is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
|
||||
`ServiceControlMixin`, `UpdateMixin`, `NatVpnMixin`, `MacAclMixin`, `FirewallRuleMixin`,
|
||||
`DhcpServerMixin`, `PingSweepMixin`, `ConfigLifecycleMixin`, `InterfaceFilterMixin`.
|
||||
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
|
||||
device may be restartable; like the others it only declares.
|
||||
|
||||
A function class may use the **template form** — public method concrete, the
|
||||
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
|
||||
@@ -238,7 +240,7 @@ class ProxmoxDriver(HypervisorDriver):
|
||||
# return List[VMDict]
|
||||
...
|
||||
|
||||
def snapshot_create(self, name, snapshot, description="", include_memory=False):
|
||||
def create_vm_snapshot(self, name, snapshot, description="", include_memory=False):
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ instead of being restated on every role that happens to need it:
|
||||
* :class:`~napalm_device_types.dhcp.DhcpServerMixin`
|
||||
* :class:`~napalm_device_types.firewall_rules.FirewallRuleMixin`
|
||||
* :class:`~napalm_device_types.health_metrics.HealthMetricsMixin`
|
||||
* :class:`~napalm_device_types.host_reboot.HostRebootMixin`
|
||||
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
|
||||
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
|
||||
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
|
||||
@@ -60,6 +61,7 @@ from napalm_device_types.hypervisor import HypervisorDriver
|
||||
from napalm_device_types.os import OSDriver
|
||||
from napalm_device_types.firewall_rules import FirewallRuleMixin
|
||||
from napalm_device_types.health_metrics import HealthMetricsMixin
|
||||
from napalm_device_types.host_reboot import HostRebootMixin
|
||||
from napalm_device_types.interface_filter import InterfaceFilterMixin
|
||||
from napalm_device_types.mac_acl import MacAclMixin
|
||||
from napalm_device_types.media import MediaDriver
|
||||
@@ -83,6 +85,7 @@ __all__ = [
|
||||
"FirewallDriver",
|
||||
"FirewallRuleMixin",
|
||||
"HealthMetricsMixin",
|
||||
"HostRebootMixin",
|
||||
"HypervisorDriver",
|
||||
"InterfaceFilterMixin",
|
||||
"MacAclMixin",
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import NamedTuple
|
||||
|
||||
from napalm.base import NetworkDriver
|
||||
|
||||
from napalm_device_types.host_reboot import HostRebootMixin
|
||||
from napalm_device_types.ping_sweep import PingSweepMixin
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ class PortSpec(NamedTuple):
|
||||
mandatory: bool = False
|
||||
|
||||
|
||||
class DeviceTypeDriver(PingSweepMixin, NetworkDriver):
|
||||
class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, NetworkDriver):
|
||||
"""Common base for all netOrk device-type drivers.
|
||||
|
||||
Sits between napalm.base.NetworkDriver and the type-specific abstract
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Restarting the device itself.
|
||||
|
||||
Declared under ``if TYPE_CHECKING``: a contract, not a placeholder. Only a
|
||||
driver that can actually restart its device defines ``reboot_host``, so
|
||||
``hasattr(driver, "reboot_host")`` tells a caller whether to offer it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
class HostRebootMixin:
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def reboot_host(self) -> None:
|
||||
"""
|
||||
Restarts the device this driver is connected to.
|
||||
|
||||
Returns once the device has accepted the request; the session is
|
||||
usually gone right after. Waiting for the device to come back is
|
||||
the caller's business (see ``REBOOT_SETTLE_SECONDS``).
|
||||
|
||||
A driver that manages other machines restarts its *own* host, never
|
||||
one of them: a hypervisor restarts the hypervisor, not a VM.
|
||||
|
||||
:raises RuntimeError: If the device refuses, e.g. an ESXi host that
|
||||
is not in maintenance mode while VMs are running.
|
||||
"""
|
||||
...
|
||||
@@ -43,6 +43,11 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
ROLE: str = "hypervisor"
|
||||
TYPE_LABEL: str = "Hypervisor"
|
||||
|
||||
#: What cloud-init installs and starts on a VM provisioned through this
|
||||
#: driver, so the hypervisor can read the guest's IP address back.
|
||||
GUEST_AGENT_PACKAGES: tuple[str, ...] = ("qemu-guest-agent",)
|
||||
GUEST_AGENT_RUNCMD: tuple[str, ...] = ("systemctl enable --now qemu-guest-agent",)
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -59,7 +64,8 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
Each entry contains:
|
||||
|
||||
* name (string) - VM display name
|
||||
* vmid (int) - hypervisor-internal numeric ID
|
||||
* vmid (string) - hypervisor-internal ID (Proxmox: ``"100"``,
|
||||
VMware: the instance UUID)
|
||||
* status (string) - ``"running"``, ``"stopped"``, ``"paused"``, ``"suspended"``
|
||||
* vcpus (int) - number of virtual CPUs assigned
|
||||
* memory (int) - configured RAM in megabytes
|
||||
@@ -73,7 +79,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
[
|
||||
{
|
||||
"name": "web01",
|
||||
"vmid": 100,
|
||||
"vmid": "100",
|
||||
"status": "running",
|
||||
"vcpus": 4,
|
||||
"memory": 8192,
|
||||
@@ -84,7 +90,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
},
|
||||
{
|
||||
"name": "db-backup",
|
||||
"vmid": 101,
|
||||
"vmid": "101",
|
||||
"status": "stopped",
|
||||
"vcpus": 2,
|
||||
"memory": 4096,
|
||||
@@ -101,13 +107,14 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
"""
|
||||
Returns the full hardware configuration of a virtual machine.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
|
||||
The returned dictionary contains:
|
||||
|
||||
* name (string) - VM display name
|
||||
* vmid (int) - hypervisor-internal numeric ID
|
||||
* vmid (string) - hypervisor-internal ID (Proxmox: ``"100"``,
|
||||
VMware: the instance UUID)
|
||||
* vcpus (int) - number of virtual CPUs
|
||||
* memory (int) - RAM in megabytes
|
||||
* os_type (string) - guest OS type hint (e.g. ``"l26"``, ``"win11"``, ``"other"``)
|
||||
@@ -131,11 +138,21 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
* description (string) - free-text notes / description
|
||||
* tags (list of strings) - organisational tags
|
||||
|
||||
Optional, present only where the hypervisor exposes them:
|
||||
|
||||
* os_name (string) - human-readable guest OS
|
||||
* cpu_type (string) - emulated CPU model
|
||||
* sockets (int), cores_per_socket (int) - vCPU topology
|
||||
* firmware (string) - ``"bios"`` or ``"efi"``
|
||||
* machine (string) - machine type / virtual hardware version
|
||||
* passthrough (list) - host devices handed to the VM, each with
|
||||
``slot``, ``kind`` (``"pci"``/``"usb"``) and ``config``
|
||||
|
||||
Example::
|
||||
|
||||
{
|
||||
"name": "web01",
|
||||
"vmid": 100,
|
||||
"vmid": "100",
|
||||
"vcpus": 4,
|
||||
"memory": 8192,
|
||||
"os_type": "l26",
|
||||
@@ -174,7 +191,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
|
||||
The method blocks until the hypervisor reports the VM as running.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
:raises RuntimeError: If the VM cannot be started (e.g. resource limit).
|
||||
|
||||
@@ -192,7 +209,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
and the method blocks until the VM is stopped. With ``force=True``
|
||||
the VM is immediately powered off (equivalent to pulling the plug).
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param force: ``True`` for immediate power-off, ``False`` for graceful shutdown.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
:raises RuntimeError: If the VM is already stopped.
|
||||
@@ -211,7 +228,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
With ``force=False`` (default) a graceful ACPI reboot is requested.
|
||||
With ``force=True`` the VM is reset immediately without OS shutdown.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param force: ``True`` for an immediate reset, ``False`` for graceful reboot.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
:raises RuntimeError: If the VM is not currently running.
|
||||
@@ -228,7 +245,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
Suspends (pauses) a running virtual machine, preserving its in-memory
|
||||
state. The VM can be resumed with :meth:`start_vm`.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
:raises RuntimeError: If the VM is not currently running.
|
||||
|
||||
@@ -246,7 +263,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
"""
|
||||
Returns all snapshots of a virtual machine.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:raises ValueError: If no VM with the given name/ID exists.
|
||||
|
||||
Each entry contains:
|
||||
@@ -286,7 +303,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
"""
|
||||
Creates a snapshot of a virtual machine.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param snapshot: Name for the new snapshot.
|
||||
:param description: Optional human-readable description.
|
||||
:param include_memory: Whether to include the current RAM state
|
||||
@@ -306,7 +323,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
"""
|
||||
Deletes a snapshot of a virtual machine.
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param snapshot: Name of the snapshot to delete.
|
||||
:raises ValueError: If the VM or snapshot does not exist.
|
||||
:raises RuntimeError: If other snapshots depend on this one (must delete children first).
|
||||
@@ -324,7 +341,7 @@ class HypervisorDriver(PackageManagementMixin, HealthMetricsMixin, DeviceTypeDri
|
||||
The VM is stopped (if running), reverted, and then left in the state
|
||||
the snapshot recorded (running or stopped depending on ``has_memory``).
|
||||
|
||||
:param name: VM name or numeric VMID as a string.
|
||||
:param name: VM name or its ``vmid``.
|
||||
:param snapshot: Name of the snapshot to roll back to.
|
||||
:raises ValueError: If the VM or snapshot does not exist.
|
||||
:raises RuntimeError: If the rollback fails.
|
||||
|
||||
@@ -512,7 +512,7 @@ class VMNICDict(TypedDict):
|
||||
|
||||
class VMDict(TypedDict):
|
||||
name: str
|
||||
vmid: int
|
||||
vmid: str
|
||||
status: str
|
||||
vcpus: int
|
||||
memory: int
|
||||
@@ -522,9 +522,17 @@ class VMDict(TypedDict):
|
||||
node: str
|
||||
|
||||
|
||||
class VMPassthroughDict(TypedDict):
|
||||
"""A host device handed through to a VM (PCI, USB)."""
|
||||
|
||||
slot: str # hypervisor's device key, e.g. "hostpci0"
|
||||
kind: str # "pci" or "usb"
|
||||
config: str # hypervisor's own description of the device
|
||||
|
||||
|
||||
class VMConfigDict(TypedDict):
|
||||
name: str
|
||||
vmid: int
|
||||
vmid: str
|
||||
vcpus: int
|
||||
memory: int
|
||||
os_type: str
|
||||
@@ -533,6 +541,14 @@ class VMConfigDict(TypedDict):
|
||||
nics: List[VMNICDict]
|
||||
description: str
|
||||
tags: List[str]
|
||||
# Hardware details not every hypervisor exposes; absent when unknown.
|
||||
os_name: NotRequired[str] # human-readable guest OS, e.g. "Ubuntu Linux (64-bit)"
|
||||
cpu_type: NotRequired[str] # e.g. "host", "kvm64"
|
||||
sockets: NotRequired[int]
|
||||
cores_per_socket: NotRequired[int]
|
||||
firmware: NotRequired[str] # "bios" or "efi"
|
||||
machine: NotRequired[str] # machine type / virtual hardware version
|
||||
passthrough: NotRequired[List[VMPassthroughDict]]
|
||||
|
||||
|
||||
class StorageVolumeDict(TypedDict):
|
||||
@@ -873,8 +889,8 @@ class NetworkTargetDict(TypedDict):
|
||||
is surfaced via ``fixed_vlan_tag`` instead, for display purposes.
|
||||
"""
|
||||
|
||||
name: str # Bridge or vnet name, usable directly as NICConfigDict.bridge
|
||||
kind: str # "bridge" or "vnet"
|
||||
name: str # Bridge, vnet or port group name, usable directly as NICConfigDict.bridge
|
||||
kind: str # "bridge", "vnet" or "portgroup" (VMware: VLAN fixed like a vnet's)
|
||||
vlan_aware: bool # True if a NICConfigDict.vlan_tag may be set on top of this target
|
||||
fixed_vlan_tag: NotRequired[int | None] # vnet only: the VLAN ID already baked into it
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-device-types"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
description = "Abstract device-type base classes for NAPALM drivers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""reboot_host: the contract for restarting the device itself.
|
||||
|
||||
netOrk used to restart a host by sending ``/sbin/reboot`` through a driver's
|
||||
private ``_send_command``. A driver that talks to an API instead has no such
|
||||
method, and the caller swallowed the resulting AttributeError, so the reboot
|
||||
"succeeded" without happening. A declared contract lets netOrk ask first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from napalm_device_types import DeviceTypeDriver, HostRebootMixin
|
||||
|
||||
|
||||
def test_every_device_type_driver_carries_the_declaration():
|
||||
assert issubclass(DeviceTypeDriver, HostRebootMixin)
|
||||
|
||||
|
||||
def test_declared_not_implemented():
|
||||
"""hasattr is netOrk's capability probe; only a driver that implements it
|
||||
may answer True."""
|
||||
assert not hasattr(DeviceTypeDriver, "reboot_host")
|
||||
|
||||
|
||||
def test_declaration_documents_the_contract():
|
||||
source = inspect.getsource(HostRebootMixin)
|
||||
assert "def reboot_host(self) -> None" in source
|
||||
assert "RuntimeError" in source
|
||||
@@ -0,0 +1,70 @@
|
||||
"""A VM's ``vmid`` is a string on every hypervisor.
|
||||
|
||||
Proxmox numbers its guests, but VMware identifies them by UUID or MoRef
|
||||
(``"vm-42"``). An ``int`` in the contract forced netOrk to call ``int()`` on
|
||||
whatever came back, which cannot represent the second kind at all. The
|
||||
provisioning dicts already carried ``vmid`` as a string; these pin the
|
||||
read-side dicts to the same type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import get_type_hints
|
||||
|
||||
import pytest
|
||||
from napalm_device_types.models import (
|
||||
VMConfigDict,
|
||||
VMDict,
|
||||
VMProvisionResultDict,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", [VMDict, VMConfigDict, VMProvisionResultDict])
|
||||
def test_vmid_is_a_string(model):
|
||||
assert get_type_hints(model)["vmid"] is str
|
||||
|
||||
|
||||
class TestVMConfigCarriesWhatAHardwareViewShows:
|
||||
"""netOrk's VM hardware view used to read Proxmox's raw config through the
|
||||
driver's private API. The contract has to carry those details so a second
|
||||
hypervisor can fill the same view -- optionally, since not every platform
|
||||
has every one of them."""
|
||||
|
||||
OPTIONAL = {
|
||||
"os_name",
|
||||
"cpu_type",
|
||||
"sockets",
|
||||
"cores_per_socket",
|
||||
"firmware",
|
||||
"machine",
|
||||
"passthrough",
|
||||
}
|
||||
|
||||
def test_hardware_details_are_optional_fields(self):
|
||||
assert self.OPTIONAL <= VMConfigDict.__optional_keys__
|
||||
|
||||
def test_contract_core_stays_required(self):
|
||||
assert "vmid" in VMConfigDict.__required_keys__
|
||||
assert "disks" in VMConfigDict.__required_keys__
|
||||
|
||||
def test_passthrough_entry_shape(self):
|
||||
from napalm_device_types.models import VMPassthroughDict
|
||||
|
||||
assert get_type_hints(VMPassthroughDict) == {"slot": str, "kind": str, "config": str}
|
||||
|
||||
|
||||
class TestGuestAgentDeclaration:
|
||||
"""netOrk's cloud-init installed qemu-guest-agent on every new VM. A VMware
|
||||
guest reports its IP through open-vm-tools instead; the hypervisor says
|
||||
which, and netOrk stops hard-coding one of them."""
|
||||
|
||||
def test_default_is_qemu_guest_agent(self):
|
||||
from napalm_device_types import HypervisorDriver
|
||||
|
||||
assert HypervisorDriver.GUEST_AGENT_PACKAGES == ("qemu-guest-agent",)
|
||||
assert HypervisorDriver.GUEST_AGENT_RUNCMD == ("systemctl enable --now qemu-guest-agent",)
|
||||
|
||||
def test_attributes_are_not_methods(self):
|
||||
from napalm_device_types import HypervisorDriver
|
||||
|
||||
assert not callable(vars(HypervisorDriver)["GUEST_AGENT_PACKAGES"])
|
||||
Reference in New Issue
Block a user