From e1ce72cc5c2fcd603f43df58426dd48c8fedbf24 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Thu, 8 Oct 2026 12:08:21 +0200 Subject: [PATCH] feat: report a FreeBSD host as needing a reboot when its installed kernel is not the running one HOST_STATUS_COMMAND also prints freebsd-version -k and -r where freebsd-version exists. When the two differ, a kernel update was installed and is not running yet, so the host needs a reboot. When they match, it does not. --- napalm_device_types/host_status.py | 19 +++++++++++++ pyproject.toml | 2 +- tests/test_host_status.py | 45 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/napalm_device_types/host_status.py b/napalm_device_types/host_status.py index 97c6525..943db00 100644 --- a/napalm_device_types/host_status.py +++ b/napalm_device_types/host_status.py @@ -16,6 +16,8 @@ carries the command across. - A kernel newer than the running one is installed, of the same flavour. A Raspberry Pi carries ``rpi-v8`` and ``rpi-2712`` builds side by side, and only the running one's counts. +- On FreeBSD, ``freebsd-version -k`` (the installed kernel) differs from + ``freebsd-version -r`` (the running one): a patch replaced the kernel. It is ``None`` when none of these could be read, for example in a container without a ``/lib/modules`` of its own. @@ -50,6 +52,9 @@ HOST_STATUS_COMMAND = ( "if command -v needs-restarting >/dev/null 2>&1; then echo '[needs-restarting]'; " "needs-restarting -r >/dev/null 2>&1; echo $?; fi; " "echo '[kernel]'; uname -r; echo '[modules]'; ls -1 /lib/modules 2>/dev/null; " + "if command -v freebsd-version >/dev/null 2>&1; then " + "echo '[freebsd-kernel]'; freebsd-version -k; " + "echo '[freebsd-running]'; freebsd-version -r; fi; " "if command -v apt-config >/dev/null 2>&1; then echo '[apt-config]'; " "apt-config dump 2>/dev/null | grep '^APT::Periodic::Unattended-Upgrade '; fi; " f"echo '[timers]'; for u in {_APT_TIMER} {' '.join(_DNF_TIMERS)}; do " @@ -100,12 +105,26 @@ def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]: return None +def _freebsd_reboot(sections: Dict[str, List[str]]) -> Optional[Tuple[bool, Optional[str]]]: + """FreeBSD's answer from freebsd-version, or None on any other system.""" + installed = (sections.get("freebsd-kernel") or [""])[0] + running = (sections.get("freebsd-running") or [""])[0] + if not (installed and running): + return None + if installed != running: + return True, f"kernel {installed} installed, {running} running" + return False, None + + def _reboot(sections: Dict[str, List[str]]) -> Tuple[Optional[bool], Optional[str]]: if "reboot-required" in sections: return True, f"{_REBOOT_FILE} is present" needs = sections.get("needs-restarting") if needs and needs[0] == "1": return True, "needs-restarting -r reports a reboot" + freebsd = _freebsd_reboot(sections) + if freebsd is not None: + return freebsd running = (sections.get("kernel") or [""])[0] modules = sections.get("modules") or [] newer = kernel_reboot_pending(running, modules) if running and modules else None diff --git a/pyproject.toml b/pyproject.toml index 9354242..3093e1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-device-types" -version = "4.0.0" +version = "4.1.0" description = "Abstract device-type base classes for NAPALM drivers" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_host_status.py b/tests/test_host_status.py index c216b69..81f1208 100644 --- a/tests/test_host_status.py +++ b/tests/test_host_status.py @@ -198,3 +198,48 @@ class TestHostStatusMixin: def test_not_every_os_driver_has_it(self): assert not hasattr(OSDriver, "get_host_status") + + +class TestFreeBSD: + """freebsd-version -k names the installed kernel, -r the running one; a + patch that replaced the kernel shows as the two differing until the host + boots it. Recorded on a FreeBSD 15.1 host (NetOrk/netork#799).""" + + #: What the command printed there before this section existed. + BEFORE = ( + "HSTAT_BEGIN\n[kernel]\n15.1-RELEASE\n[modules]\n[timers]\n" + "apt-daily-upgrade.timer \ndnf-automatic.timer \ndnf-automatic-install.timer \n" + "HSTAT_END\n" + ) + + @staticmethod + def _report(installed: str, running: str) -> str: + return ( + f"HSTAT_BEGIN\n[kernel]\n{running}\n[modules]\n" + f"[freebsd-kernel]\n{installed}\n[freebsd-running]\n{running}\n" + "[timers]\napt-daily-upgrade.timer \nHSTAT_END\n" + ) + + def test_without_the_section_it_is_unknown(self): + assert parse_host_status(self.BEFORE)["reboot_required"] is None + + def test_the_running_kernel_is_the_installed_one(self): + status = parse_host_status(self._report("15.1-RELEASE-p4", "15.1-RELEASE-p4")) + assert status["reboot_required"] is False + assert status["reboot_reason"] is None + + def test_a_patched_kernel_waits_for_a_reboot(self): + status = parse_host_status(self._report("15.1-RELEASE-p5", "15.1-RELEASE-p4")) + assert status["reboot_required"] is True + assert status["reboot_reason"] == ( + "kernel 15.1-RELEASE-p5 installed, 15.1-RELEASE-p4 running" + ) + + def test_freebsd_does_not_patch_itself_as_far_as_netork_can_tell(self): + status = parse_host_status(self._report("15.1-RELEASE", "15.1-RELEASE")) + assert status["auto_updates"] is None + + def test_the_command_asks_only_where_freebsd_version_exists(self): + assert "command -v freebsd-version" in HOST_STATUS_COMMAND + assert "freebsd-version -k" in HOST_STATUS_COMMAND + assert "freebsd-version -r" in HOST_STATUS_COMMAND