fix: raise when an update reader cannot read, and report host status #5

Merged
christianmanivong merged 1 commits from feat/host-status into main 2026-10-08 10:15:14 +00:00
6 changed files with 160 additions and 28 deletions
Showing only changes of commit 25308bf747 - Show all commits
+16 -16
View File
@@ -8,22 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- SNMP: `run_device_action("fix_snmp")` installs net-snmp, writes the same
configuration netOrk uses on Linux, starts the agent and asks it for
sysDescr; `get_snmp_config` reports a running agent's community and port.
### Changed
- FreeBSD's `install_package` bootstraps pkg on a classic system that never
had it (`ASSUME_ALWAYS_YES`) instead of waiting for an answer.
- Packages: `get_packages`, `install_package`, `uninstall_package`.
- Updates: `get_available_updates`, with VuXML's verdict as `security` on
FreeBSD and base-system patches as one `base-system` entry (OpenBSD
`syspatch`, classic FreeBSD `freebsd-update`).
- Services: `get_services` and `manage_service` (start, stop, restart,
enable, disable) through `service` and `rcctl`.
- `get_listening_sockets()` in the shape of `ListeningSocketsMixin`: FreeBSD
through `sockstat`, OpenBSD through `fstat` as root and `netstat -an`
without, which the reading reports as `attributed: False`.
- `FreeBSDDriver` (`freebsd`) and `OpenBSDDriver` (`openbsd`) on a shared - `FreeBSDDriver` (`freebsd`) and `OpenBSDDriver` (`openbsd`) on a shared
`BsdDriver`: SSH over exec channels (no PTY, real exit codes), root through `BsdDriver`: SSH over exec channels (no PTY, real exit codes), root through
`sudo -S` with the password on stdin. `sudo -S` with the password on stdin.
@@ -32,5 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
their addresses (`ifconfig -a`), routes (`netstat -rn`), ARP (`arp -an`), their addresses (`ifconfig -a`), routes (`netstat -rn`), ARP (`arp -an`),
users and groups, processes (`ps … lstart`), cron jobs (system crontab and users and groups, processes (`ps … lstart`), cron jobs (system crontab and
the login user's). the login user's).
- `get_listening_sockets()` in the shape of `ListeningSocketsMixin`: FreeBSD
through `sockstat`, OpenBSD through `fstat` as root and `netstat -an`
without, which the reading reports as `attributed: False`.
- Packages: `get_packages`, `install_package`, `uninstall_package`. FreeBSD's
install bootstraps pkg on a classic system that never had it.
- Updates: `get_available_updates`, with VuXML's verdict as `security` on
FreeBSD and base-system patches as one `base-system` entry (OpenBSD
`syspatch`, classic FreeBSD `freebsd-update`). A reader that cannot read
raises instead of returning no updates (#4).
- Services: `get_services` and `manage_service` (start, stop, restart,
enable, disable) through `service` and `rcctl`.
- SNMP: `run_device_action("fix_snmp")` installs net-snmp, writes the same
configuration netOrk uses on Linux, starts the agent and asks it for
sysDescr; `get_snmp_config` reports a running agent's community and port.
- `get_host_status` (napalm-device-types' `HostStatusMixin`): on FreeBSD,
"reboot required" when the installed kernel differs from the running one.
- Parsers tested on output recorded from FreeBSD 15.1 (hand-installed and - Parsers tested on output recorded from FreeBSD 15.1 (hand-installed and
cloud image) and OpenBSD 7.9 (NetOrk/netork#793). cloud image) and OpenBSD 7.9 (NetOrk/netork#793).
+10
View File
@@ -37,6 +37,7 @@ over SSH, built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-dev
| `get_available_updates` | ✓ | ✓ | `pkg upgrade -n` + `pkg audit` (VuXML); `pkg_add -u -n -v` + `syspatch -c` | | `get_available_updates` | ✓ | ✓ | `pkg upgrade -n` + `pkg audit` (VuXML); `pkg_add -u -n -v` + `syspatch -c` |
| `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … <action>`; `rcctl ls on` + `rcctl check` / `rcctl <action>` | | `get_services`, `manage_service` | ✓ | ✓ | `service -e` + `service … status` / `service … <action>`; `rcctl ls on` + `rcctl check` / `rcctl <action>` |
| `run_device_action("fix_snmp")`, `get_snmp_config` | ✓ | ✓ | net-snmp from packages: `/usr/local/etc/snmp/snmpd.conf` + `service snmpd`; `/etc/snmp/snmpd.conf` + `rcctl … netsnmpd` | | `run_device_action("fix_snmp")`, `get_snmp_config` | ✓ | ✓ | net-snmp from packages: `/usr/local/etc/snmp/snmpd.conf` + `service snmpd`; `/etc/snmp/snmpd.conf` + `rcctl … netsnmpd` |
| `get_host_status` | ✓ | unknown | napalm-device-types' host status: on FreeBSD `freebsd-version -k` vs `-r` (4.1+) |
`get_listening_sockets` has the shape of napalm-device-types' `get_listening_sockets` has the shape of napalm-device-types'
`ListeningSocketsMixin` (whose `ss`/cgroup reading is Linux's) and its rule: `ListeningSocketsMixin` (whose `ss`/cgroup reading is Linux's) and its rule:
@@ -58,6 +59,15 @@ reports what `freebsd-update` has fetched (`updatesready`). On FreeBSD with
pkgbase the base system is packages from the `FreeBSD-base` repository and pkgbase the base system is packages from the `FreeBSD-base` repository and
needs no extra entry. needs no extra entry.
A reader that cannot read **raises** rather than return an empty list: netOrk
takes `[]` as "no updates" and would close every patch clock on the host. That
covers a refused sudo, a failing `pkg`, `syspatch` or `freebsd-update`, and a pkg
database pkg cannot read (only pkg's "not installed" means no packages).
**Reboot.** `get_host_status` reports a FreeBSD host whose installed kernel
(`freebsd-version -k`) differs from the running one (`-r`) as needing a reboot.
OpenBSD has no such reading yet, so it stays unknown there.
**Services** are the enabled ones. FreeBSD reads their status as root, as **Services** are the enabled ones. FreeBSD reads their status as root, as
root-only pidfiles hide a daemon from anyone else, and without root when sudo root-only pidfiles hide a daemon from anyone else, and without root when sudo
refuses; OpenBSD's `rcctl check` needs no root. Actions run as root. refuses; OpenBSD's `rcctl check` needs no root. Actions run as root.
+29 -1
View File
@@ -17,6 +17,7 @@ from typing import Any, Optional
import paramiko import paramiko
from napalm.base.exceptions import ConnectionClosedException, ConnectionException from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from napalm_device_types import OSDriver from napalm_device_types import OSDriver
from napalm_device_types.host_status import HostStatusMixin
from napalm_device_types.channel import ( from napalm_device_types.channel import (
ByteStream, ByteStream,
CommandResult, CommandResult,
@@ -47,7 +48,7 @@ _SNMPD_CONF = (
_SNMP_TYPES = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:") _SNMP_TYPES = ("STRING:", "INTEGER:", "OID:", "Timeticks:", "Hex-STRING:", "IpAddress:")
class BsdDriver(OSDriver): class BsdDriver(HostStatusMixin, OSDriver):
"""Base for the BSD drivers; a concrete one names the commands that differ.""" """Base for the BSD drivers; a concrete one names the commands that differ."""
VENDOR = "" VENDOR = ""
@@ -163,11 +164,38 @@ class BsdDriver(OSDriver):
line, prefix = self._privileged(command, privileged) line, prefix = self._privileged(command, privileged)
return open_stream_on_transport(self._transport(), line, stdin_prefix=prefix) return open_stream_on_transport(self._transport(), line, stdin_prefix=prefix)
def _read(
self,
command: str,
*,
privileged: bool = False,
timeout: float = 60,
ok: tuple[int, ...] = (0,),
) -> CommandResult:
"""Run a reader's command; raise when it did not read.
A reader that cannot read raises rather than return "nothing": an empty
update list would close every patch clock netOrk keeps (napalm-bsd#4).
"""
result = self.run_command(command, privileged=privileged, timeout=timeout)
if result.exit_code not in ok:
reason = (result.stderr or result.stdout).strip() or f"exit {result.exit_code}"
raise RuntimeError(f"{command}: {reason}")
return result
def _out(self, command: str, *, privileged: bool = False, timeout: float = 60) -> str: def _out(self, command: str, *, privileged: bool = False, timeout: float = 60) -> str:
"""What *command* printed. A failing command prints nothing useful, """What *command* printed. A failing command prints nothing useful,
and the readers below treat empty output as "nothing there".""" and the readers below treat empty output as "nothing there"."""
return self.run_command(command, privileged=privileged, timeout=timeout).stdout.strip() return self.run_command(command, privileged=privileged, timeout=timeout).stdout.strip()
def _run_host_status_command(self, command: str) -> str:
"""The transport for ``HostStatusMixin.get_host_status``: read-only, no root.
On FreeBSD the report compares ``freebsd-version -k`` with ``-r``
(napalm-device-types 4.1); elsewhere "reboot required" stays unknown.
"""
return self.run_command(command).stdout
# -- facts ------------------------------------------------------------------- # -- facts -------------------------------------------------------------------
def _platform(self) -> dict[str, str]: def _platform(self) -> dict[str, str]:
+24 -8
View File
@@ -53,8 +53,18 @@ class FreeBSDDriver(BsdDriver):
return parse.service_status(output) return parse.service_status(output)
def _has_pkg(self) -> bool: def _has_pkg(self) -> bool:
"""pkg is bootstrapped; a hand-installed classic system may have only the stub.""" """pkg is bootstrapped; a hand-installed classic system may have only the stub.
return self.run_command("pkg -N").exit_code == 0
Only the stub's "not installed" means no pkg; any other failure (a
database pkg cannot read) raises rather than read as "no packages".
"""
result = self.run_command("pkg -N")
if result.exit_code == 0:
return True
message = (result.stderr or result.stdout).strip()
if "is not installed" in message:
return False
raise RuntimeError(f"pkg -N: {message or f'exit {result.exit_code}'}")
def get_packages(self) -> list[dict]: def get_packages(self) -> list[dict]:
return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else [] return parse.pkg_query(self._out(self.PKG_QUERY)) if self._has_pkg() else []
@@ -69,10 +79,13 @@ class FreeBSDDriver(BsdDriver):
""" """
updates: list[dict] = [] updates: list[dict] = []
if self._has_pkg(): if self._has_pkg():
updates = parse.pkg_upgrades(self._out("pkg upgrade -n", privileged=True, timeout=300)) upgrade = self._read("pkg upgrade -n", privileged=True, timeout=300)
updates = parse.pkg_upgrades(upgrade.stdout)
audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120) audit = self.run_command("pkg audit -Fq", privileged=True, timeout=120)
known = audit.exit_code in (0, 1) # 1: vulnerable packages found vulnerable = parse.pkg_audit(audit.stdout)
vulnerable = parse.pkg_audit(audit.stdout) if known else set() # 1 is "vulnerable packages found" and any error alike; only a list
# of packages makes it a verdict.
known = audit.exit_code == 0 or (audit.exit_code == 1 and bool(vulnerable))
for update in updates: for update in updates:
update["security"] = (update["name"] in vulnerable) if known else None update["security"] = (update["name"] in vulnerable) if known else None
if "FreeBSD-base" in self._out("pkg repos -l").split(): if "FreeBSD-base" in self._out("pkg repos -l").split():
@@ -86,10 +99,13 @@ class FreeBSDDriver(BsdDriver):
2 when there are none; it does not fetch, which ``freebsd-update cron`` 2 when there are none; it does not fetch, which ``freebsd-update cron``
does daily where it is enabled. does daily where it is enabled.
""" """
ready = self.run_command( ready = self._read(
"freebsd-update --not-running-from-cron updatesready", privileged=True, timeout=60 "freebsd-update --not-running-from-cron updatesready",
privileged=True,
timeout=60,
ok=(0, 2),
) )
if ready.exit_code != 0: if ready.exit_code == 2:
return [] return []
return [ return [
{ {
+2 -2
View File
@@ -64,10 +64,10 @@ class OpenBSDDriver(BsdDriver):
updates: list[dict] = [ updates: list[dict] = [
{**candidate, "origin": None, "security": None} {**candidate, "origin": None, "security": None}
for candidate in parse.pkg_add_candidates( for candidate in parse.pkg_add_candidates(
self._out("pkg_add -u -n -v", privileged=True, timeout=300) self._read("pkg_add -u -n -v", privileged=True, timeout=300).stdout
) )
] ]
patches = parse.syspatch(self._out("syspatch -c", privileged=True, timeout=120)) patches = parse.syspatch(self._read("syspatch -c", privileged=True, timeout=120).stdout)
if patches: if patches:
installed = parse.syspatch(self._out("syspatch -l", privileged=True)) installed = parse.syspatch(self._out("syspatch -l", privileged=True))
summary = ( summary = (
+79 -1
View File
@@ -267,6 +267,15 @@ class TestPackages:
assert driver.get_packages() == [] assert driver.get_packages() == []
assert driver.calls == [("pkg -N", False)] assert driver.calls == [("pkg -N", False)]
def test_a_pkg_that_cannot_read_its_database_raises(self):
"""Any other failure is "could not read", never "no packages"."""
driver = _channel(
FreeBSDDriver,
{"pkg -N": ("pkg: sqlite error ...: database disk image is malformed", 1)},
)
with pytest.raises(RuntimeError, match="malformed"):
driver.get_packages()
def test_openbsd_packages(self): def test_openbsd_packages(self):
driver = _channel(OpenBSDDriver, {"pkg_info": _read("openbsd-vm", "pkg_info_full.txt")}) driver = _channel(OpenBSDDriver, {"pkg_info": _read("openbsd-vm", "pkg_info_full.txt")})
assert any(p["name"] == "pcre2" for p in driver.get_packages()) assert any(p["name"] == "pcre2" for p in driver.get_packages())
@@ -359,7 +368,7 @@ class TestAvailableUpdates:
driver = _channel( driver = _channel(
FreeBSDDriver, FreeBSDDriver,
{ {
"pkg -N": ("", 1), "pkg -N": ("pkg: pkg is not installed", 1),
("freebsd-update --not-running-from-cron updatesready", True): ("", 2), ("freebsd-update --not-running-from-cron updatesready", True): ("", 2),
}, },
) )
@@ -532,3 +541,72 @@ class TestSnmp:
def test_no_agent_running_means_no_config(self): def test_no_agent_running_means_no_config(self):
driver = _channel(OpenBSDDriver, {"pgrep -f /usr/local/sbin/snmpd": ("", 1)}) driver = _channel(OpenBSDDriver, {"pgrep -f /usr/local/sbin/snmpd": ("", 1)})
assert driver.get_snmp_config() is None assert driver.get_snmp_config() is None
class TestHostStatus:
"""napalm-device-types' host status, carried over the exec channel; on
FreeBSD it compares the installed with the running kernel (4.1.0)."""
REPORT = (
"HSTAT_BEGIN\n[kernel]\n15.1-RELEASE\n[modules]\n"
"[freebsd-kernel]\n15.1-RELEASE-p5\n[freebsd-running]\n15.1-RELEASE-p4\n"
"[timers]\nHSTAT_END\n"
)
def test_a_patched_kernel_waits_for_a_reboot(self):
from napalm_device_types.host_status import HOST_STATUS_COMMAND
driver = _channel(FreeBSDDriver, {HOST_STATUS_COMMAND: self.REPORT})
status = driver.get_host_status()
assert status["reboot_required"] is True
assert driver.calls == [(HOST_STATUS_COMMAND, False)] # read-only, no root
@pytest.mark.parametrize("cls", [FreeBSDDriver, OpenBSDDriver])
def test_both_have_it(self, cls):
assert callable(getattr(cls, "get_host_status", None))
class TestAFailedReadRaises:
"""A reader that could not read raises; [] would tell netOrk "no updates"
and close every patch clock on the host (napalm-bsd#4)."""
def test_freebsd_without_root(self):
driver = _channel(
FreeBSDDriver,
{"pkg -N": "", ("pkg upgrade -n", True): ("", 1)},
)
with pytest.raises(RuntimeError, match="pkg upgrade -n"):
driver.get_available_updates()
def test_a_failed_audit_leaves_security_unknown(self):
"""pkg audit exits 1 for "vulnerable packages found" and for errors alike;
only a list of packages makes the 1 a verdict."""
driver = _channel(
FreeBSDDriver,
{
"pkg -N": "",
("pkg upgrade -n", True): _read("freebsd-vm", "sudo_pkg_upgrade_n_latest.txt"),
("pkg audit -Fq", True): ("", 1),
"pkg repos -l": "FreeBSD-ports\nFreeBSD-base\n",
},
)
assert {u["security"] for u in driver.get_available_updates()} == {None}
def test_classic_freebsd_update_error(self):
driver = _channel(
FreeBSDDriver,
{
"pkg -N": ("pkg: pkg is not installed", 1),
("freebsd-update --not-running-from-cron updatesready", True): ("", 1),
},
)
with pytest.raises(RuntimeError, match="freebsd-update"):
driver.get_available_updates()
@pytest.mark.parametrize("failing", ["pkg_add -u -n -v", "syspatch -c"])
def test_openbsd_without_root(self, failing):
answers = {("pkg_add -u -n -v", True): "", ("syspatch -c", True): ""}
answers[(failing, True)] = ("", 1)
driver = _channel(OpenBSDDriver, answers)
with pytest.raises(RuntimeError, match=failing.split()[0]):
driver.get_available_updates()