diff --git a/README.md b/README.md index f15dff5..765e55c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index 37d2060..436e2d7 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -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", diff --git a/napalm_device_types/base.py b/napalm_device_types/base.py index 5f2d52d..110cefe 100644 --- a/napalm_device_types/base.py +++ b/napalm_device_types/base.py @@ -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 diff --git a/napalm_device_types/host_reboot.py b/napalm_device_types/host_reboot.py new file mode 100644 index 0000000..2a21c1f --- /dev/null +++ b/napalm_device_types/host_reboot.py @@ -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. + """ + ... diff --git a/napalm_device_types/hypervisor.py b/napalm_device_types/hypervisor.py index 268e8df..9e01036 100644 --- a/napalm_device_types/hypervisor.py +++ b/napalm_device_types/hypervisor.py @@ -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",) + # ------------------------------------------------------------------ diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index 5319808..3f9d67b 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -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 diff --git a/tests/test_host_reboot.py b/tests/test_host_reboot.py new file mode 100644 index 0000000..ec12da3 --- /dev/null +++ b/tests/test_host_reboot.py @@ -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 diff --git a/tests/test_vm_identifiers.py b/tests/test_vm_identifiers.py index d38f6e2..ec64e39 100644 --- a/tests/test_vm_identifiers.py +++ b/tests/test_vm_identifiers.py @@ -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"])