From 60b56c37e08da1ccd34c3dc11f6242b530605808 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 6 Oct 2026 13:05:27 +0200 Subject: [PATCH] feat: restart the host (reboot_host) netOrk asks a driver for reboot_host before it offers a restart, and the Linux driver had none, so a Linux host -- and OpenMediaVault and QTS, which inherit this driver -- could not be restarted from netOrk at all (netOrk #637). reboot_host runs /sbin/reboot detached and two seconds late through _run_privileged: the launcher's exit status comes back before the host goes down, and closing the session cannot take the restart with it. A refusal (sudo without a password) raises with what the host said. --- napalm_linux/linux.py | 20 ++++++++++++++++++++ tests/test_linux.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/napalm_linux/linux.py b/napalm_linux/linux.py index 92c38c8..6f70288 100644 --- a/napalm_linux/linux.py +++ b/napalm_linux/linux.py @@ -120,6 +120,11 @@ _REFRESH = { } _YUM_SECURITY_COMMAND = "LC_ALL=C yum updateinfo list security -q 2>/dev/null" +#: Restart the host two seconds later, detached from this session: the launcher's +#: exit status comes back before the host goes down, and closing the session +#: cannot take the restart with it. +_REBOOT_DETACHED = "sh -c '(trap \"\" HUP; sleep 2; /sbin/reboot) /dev/null 2>&1 &'" + #: What to do when sudo wants a password netOrk does not have. _SUDO_PASSWORD_HINT = ( "sudo requires a password on this device but none is configured in netOrk. " @@ -389,6 +394,21 @@ class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDri return self._sudo(command, read_timeout=timeout) return self._send(f"sudo -n {command}", read_timeout=timeout) + def reboot_host(self) -> None: + """Restart the host (``HostRebootMixin``); returns once the restart is under way. + + :raises RuntimeError: when the host refuses -- sudo without a password, + no ``reboot`` -- or its answer carried no exit status. + """ + output, status = _split_status( + self._run_privileged(f"{_REBOOT_DETACHED}; echo {_RC_MARKER}$?", 30) + ) + if status != 0: + reason = output or f"the reboot command exited with status {status}" + if "password is required" in output: + reason = f"{reason}\n{_SUDO_PASSWORD_HINT}" + raise RuntimeError(reason) + def _run_host_status_command(self, command: str) -> str: """The transport for ``HostStatusMixin.get_host_status``: read-only, no sudo.""" return self._send(command, read_timeout=60) diff --git a/tests/test_linux.py b/tests/test_linux.py index 66cdd66..5ac2782 100644 --- a/tests/test_linux.py +++ b/tests/test_linux.py @@ -1517,3 +1517,41 @@ class TestTerminalCodes: driver._device.send_command.side_effect = ["0", "Hit:1 noble InRelease\n\x1b>__NETORK_RC=0"] assert driver.refresh_available_updates()["success"] is True + + +class TestRebootHost: + """``reboot_host`` (napalm-device-types' ``HostRebootMixin``) restarts the host. + + Without it netOrk could not restart a Linux host at all: its capability check + looks for ``reboot_host`` and found nothing (netOrk #637). The restart is + detached and a moment late, so the launcher's exit status comes back before + the host goes down, and closing the session cannot take it along. + """ + + def test_the_driver_can_restart_its_host(self): + assert callable(getattr(LinuxDriver, "reboot_host", None)) + + def test_the_restart_is_detached_and_privileged(self, driver): + driver._root = False + driver._device.send_command.return_value = "\n__NETORK_RC=0" + + driver.reboot_host() + + sent = driver._device.send_command.call_args[0][0] + assert sent.startswith("sudo -n sh -c ") + assert "/sbin/reboot" in sent and "trap" in sent and "&" in sent + assert sent.endswith("echo __NETORK_RC=$?") + + def test_a_refusal_is_raised_with_what_the_host_said(self, driver): + driver._root = False + driver._device.send_command.return_value = "sudo: a password is required\n__NETORK_RC=1" + + with pytest.raises(RuntimeError, match="password is required"): + driver.reboot_host() + + def test_no_exit_status_is_no_success(self, driver): + driver._root = True + driver._device.send_command.return_value = "something else" + + with pytest.raises(RuntimeError): + driver.reboot_host() -- 2.54.0