feat: reboot_host contract, guest agent declaration, port group targets

HostRebootMixin declares reboot_host(), mixed into DeviceTypeDriver so
any device may be restartable. netOrk restarted hosts by sending
/sbin/reboot through a driver's private _send_command; a driver talking
to an API had no such method and the reboot was silently skipped.

HypervisorDriver gains GUEST_AGENT_PACKAGES / GUEST_AGENT_RUNCMD, the
agent cloud-init installs so the hypervisor can read a new VM's IP.
The default stays qemu-guest-agent; VMware declares open-vm-tools.

NetworkTargetDict.kind may be "portgroup": a VMware port group fixes its
VLAN like an SDN vnet does, without being one.
This commit is contained in:
2026-09-24 10:00:04 +02:00
parent 1ce0a0b6f2
commit 34b8f10ffa
8 changed files with 90 additions and 3 deletions
+2
View File
@@ -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
+3
View File
@@ -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",
+2 -1
View File
@@ -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
+30
View File
@@ -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.
"""
...
+5
View File
@@ -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",)
# ------------------------------------------------------------------
+2 -2
View File
@@ -889,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
+29
View File
@@ -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
+17
View File
@@ -51,3 +51,20 @@ class TestVMConfigCarriesWhatAHardwareViewShows:
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"])