From 4d3574cd605e6f625413077c2449f53fc2636efa Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 27 Oct 2026 00:36:54 +0100 Subject: [PATCH] feat: shutdown_host and begin_stop_vm, shutting down without waiting HostShutdownMixin.shutdown_host(), on DeviceTypeDriver: a device shuts itself down cleanly through its own means; declared, so hasattr says whether a driver can. There is no forced variant. VmStopRequestMixin.begin_stop_vm(name): ask for a VM's clean shutdown and return at once, for a caller that waits on a timer and reads get_vm_status instead of blocking in stop_vm. No role demands it. For netOrk's graph workflows, which switch a hypervisor off when CI has nothing for it (NetOrk/netork#1636). --- CHANGELOG.md | 8 +++++++ README.md | 6 +++-- napalm_device_types/__init__.py | 6 +++++ napalm_device_types/base.py | 9 +++++++- napalm_device_types/host_shutdown.py | 32 ++++++++++++++++++++++++++ napalm_device_types/vm_stop_request.py | 29 +++++++++++++++++++++++ tests/test_host_shutdown.py | 27 ++++++++++++++++++++++ tests/test_vm_stop_request.py | 28 ++++++++++++++++++++++ 8 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 napalm_device_types/host_shutdown.py create mode 100644 napalm_device_types/vm_stop_request.py create mode 100644 tests/test_host_shutdown.py create mode 100644 tests/test_vm_stop_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0b69a..94e7a17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ recorded here; their history is the git log. ## [Unreleased] +### Added +- `HostShutdownMixin.shutdown_host()`, on `DeviceTypeDriver`: shut the device itself + down cleanly through its own means; declared, so `hasattr` says whether a driver + can. No forced variant (NetOrk/netork#1636). +- `VmStopRequestMixin.begin_stop_vm(name)`: ask for a VM's clean shutdown and return + at once, for callers that wait on a timer instead of in `stop_vm`. No role demands + it (NetOrk/netork#1636). + ## [4.22.0] - 2026-10-23 ### Added diff --git a/README.md b/README.md index ce16fd1..44c28e0 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,10 @@ 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. +`HostRebootMixin` (`reboot_host`) and `HostShutdownMixin` (`shutdown_host`) are mixed +into `DeviceTypeDriver` itself, since any device may be restartable or able to shut itself +down; like the others they only declare. `VmStopRequestMixin` (`begin_stop_vm`) asks for a +VM's clean shutdown without waiting for it — a hypervisor driver mixes it in when it can. **Fixes are templates** (`napalm_device_types.fixes`, since 4.13). A change the application wants made on a device runs as fixed steps the driver implements, with values diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index f0d0274..9cc53bf 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -44,6 +44,7 @@ instead of being restated on every role that happens to need it: * :class:`~napalm_device_types.health_metrics.HealthMetricsMixin` * :class:`~napalm_device_types.host_status.HostStatusMixin` * :class:`~napalm_device_types.host_reboot.HostRebootMixin` +* :class:`~napalm_device_types.host_shutdown.HostShutdownMixin` * :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin` * :class:`~napalm_device_types.kernel.KernelFactsMixin` * :class:`~napalm_device_types.listening.ListeningSocketsMixin` @@ -58,6 +59,7 @@ instead of being restated on every role that happens to need it: * :class:`~napalm_device_types.systemd.SystemdServicesMixin` * :class:`~napalm_device_types.updates.UpdateMixin` * :class:`~napalm_device_types.virtual_ips.VirtualIpsMixin` +* :class:`~napalm_device_types.vm_stop_request.VmStopRequestMixin` Introspection -- :func:`~napalm_device_types.roles.roles_of`, :func:`~napalm_device_types.roles.role_keys_of` and @@ -106,6 +108,7 @@ from napalm_device_types.host_firewall import ( parse_host_firewall, ) from napalm_device_types.host_reboot import HostRebootMixin +from napalm_device_types.host_shutdown import HostShutdownMixin from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status from napalm_device_types.hypervisor import HypervisorDriver from napalm_device_types.interface_filter import InterfaceFilterMixin @@ -214,6 +217,7 @@ from napalm_device_types.virtual_ips import ( VirtualIpsMixin, parse_virtual_ips, ) +from napalm_device_types.vm_stop_request import VmStopRequestMixin __all__ = [ "HypervisorHostDict", @@ -269,6 +273,7 @@ __all__ = [ "HOST_STATUS_COMMAND", "HostStatusMixin", "HostRebootMixin", + "HostShutdownMixin", "HypervisorDriver", "InterfaceFilterMixin", "MacAclMixin", @@ -317,6 +322,7 @@ __all__ = [ "UpdateMixin", "VIRTUAL_IPS_COMMAND", "VirtualIpsMixin", + "VmStopRequestMixin", "SERVICE_CLUSTERS_COMMAND", "ServiceClustersMixin", "parse_service_clusters", diff --git a/napalm_device_types/base.py b/napalm_device_types/base.py index 97f0f9b..b95622f 100644 --- a/napalm_device_types/base.py +++ b/napalm_device_types/base.py @@ -15,6 +15,7 @@ from napalm.base import NetworkDriver from napalm_device_types.channel import CommandChannelMixin from napalm_device_types.host_reboot import HostRebootMixin +from napalm_device_types.host_shutdown import HostShutdownMixin from napalm_device_types.ping_sweep import PingSweepMixin @@ -74,7 +75,13 @@ class PortSpec(NamedTuple): mandatory: bool = False -class DeviceTypeDriver(PingSweepMixin, HostRebootMixin, CommandChannelMixin, NetworkDriver): # type: ignore[misc] +class DeviceTypeDriver( + PingSweepMixin, + HostRebootMixin, + HostShutdownMixin, + CommandChannelMixin, + NetworkDriver, # type: ignore[misc] +): """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_shutdown.py b/napalm_device_types/host_shutdown.py new file mode 100644 index 0000000..e1e030b --- /dev/null +++ b/napalm_device_types/host_shutdown.py @@ -0,0 +1,32 @@ +"""Shutting the device itself down cleanly. + +Declared under ``if TYPE_CHECKING``: a contract, not a placeholder. Only a +driver that can shut its device down through the device's own means defines +``shutdown_host``, so ``hasattr(driver, "shutdown_host")`` tells a caller +whether to offer it. There is no forced variant here, by design: cutting the +power is the BMC's business, not a driver's. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +class HostShutdownMixin: + if TYPE_CHECKING: + + def shutdown_host(self) -> None: + """ + Shuts down the device this driver is connected to, cleanly. + + Returns once the device has accepted the request; the session is + usually gone right after. Whether the device really went off is + the caller's business, e.g. through its BMC. + + A driver that manages other machines shuts down its *own* host, + never one of them: a hypervisor node shuts down itself, and its + guests are stopped the way the node stops them on shutdown. + + :raises RuntimeError: If the device refuses the request. + """ + ... diff --git a/napalm_device_types/vm_stop_request.py b/napalm_device_types/vm_stop_request.py new file mode 100644 index 0000000..5d0b5de --- /dev/null +++ b/napalm_device_types/vm_stop_request.py @@ -0,0 +1,29 @@ +"""Asking for a VM's clean shutdown without waiting for it. + +``HypervisorDriver.stop_vm`` blocks until the VM is stopped, within a limit +of the driver's own. A caller that keeps its waits as timers asks with +``begin_stop_vm`` and reads ``get_vm_status`` until the VM says it stopped. + +Declared under ``if TYPE_CHECKING``: a driver mixes this in when its +hypervisor can take the request on its own; ``hasattr`` tells a caller +whether it did. No role base carries it. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + + +class VmStopRequestMixin: + if TYPE_CHECKING: + + def begin_stop_vm(self, name: str) -> None: + """ + Asks the hypervisor to shut a virtual machine down cleanly (ACPI) + and returns as soon as the request was accepted. + + :param name: VM name or its ``vmid``. + :raises ValueError: If no VM with the given name/ID exists. + :raises RuntimeError: If the hypervisor refuses the request. + """ + ... diff --git a/tests/test_host_shutdown.py b/tests/test_host_shutdown.py new file mode 100644 index 0000000..0a9182a --- /dev/null +++ b/tests/test_host_shutdown.py @@ -0,0 +1,27 @@ +"""shutdown_host: the contract for shutting the device itself down cleanly. + +netOrk's graph workflows switch a hypervisor off when it has nothing to do +(NetOrk/netork#1636): through its own API, never by pulling the power. Like +``reboot_host`` it is declared, not implemented, so ``hasattr`` tells netOrk +whether a driver can. +""" + +from __future__ import annotations + +import inspect + +from napalm_device_types import DeviceTypeDriver, HostShutdownMixin + + +def test_every_device_type_driver_carries_the_declaration(): + assert issubclass(DeviceTypeDriver, HostShutdownMixin) + + +def test_declared_not_implemented(): + assert not hasattr(DeviceTypeDriver, "shutdown_host") + + +def test_declaration_documents_the_contract(): + source = inspect.getsource(HostShutdownMixin) + assert "def shutdown_host(self) -> None" in source + assert "RuntimeError" in source diff --git a/tests/test_vm_stop_request.py b/tests/test_vm_stop_request.py new file mode 100644 index 0000000..144a3fc --- /dev/null +++ b/tests/test_vm_stop_request.py @@ -0,0 +1,28 @@ +"""begin_stop_vm: ask for a VM's clean shutdown and return at once. + +``stop_vm`` blocks until the VM is stopped, with a limit of the driver's own. +A caller that keeps its waits as timers (netOrk's graph workflows, +NetOrk/netork#1636) asks with ``begin_stop_vm`` and reads ``get_vm_status`` +until the VM is stopped. A driver mixes it in when it can; no role demands it. +""" + +from __future__ import annotations + +import inspect + +from napalm_device_types import HypervisorDriver, VmStopRequestMixin + + +def test_no_role_demands_it(): + assert not issubclass(HypervisorDriver, VmStopRequestMixin) + + +def test_declared_not_implemented(): + assert not hasattr(VmStopRequestMixin, "begin_stop_vm") + + +def test_declaration_documents_the_contract(): + source = inspect.getsource(VmStopRequestMixin) + assert "def begin_stop_vm(self, name: str) -> None" in source + assert "ValueError" in source + assert "RuntimeError" in source