Author SHA1 Message Date
Christian Manivong a6f9a17858 feat: report where an update comes from and whether it is a security fix, refresh the index, read the host status
For netOrk MVP 5, on napalm-device-types 2.3.0:

- get_available_updates (apt) runs the shared APT_UPGRADABLE_COMMAND and
  parse_apt_upgradable: origin and security from apt's suites, and a
  ValueError instead of [] when apt failed or its output was cut short.
- dnf/yum: check-update's exit status decides (0 none, 100 updates, anything
  else raises); security comes from `updateinfo list --security`, and is None
  when dnf cannot say. The repository column becomes the origin.
- refresh_available_updates(): apt-get update, dnf/yum makecache, apk update;
  pacman is left out (-Sy without -u invites a partial upgrade).
- HostStatusMixin: reboot required and self-patching, read over SSH.
- _run_privileged(): root runs directly, a sudo password goes through _sudo,
  otherwise sudo -n. Shared by service control and the refresh.
- _split_status() drops terminal codes before it looks for the exit status;
  a pseudo-terminal left keypad codes in front of the marker.

OpenMediaVault inherits all of it.
2026-10-06 00:20:07 +02:00
christianmanivong e31bc2a3bf Merge pull request 'feat: start, stop, restart, enable and disable services, and list them in one round trip' (#9) from feat/manage-service into master 2026-10-05 11:12:14 +00:00
Christian Manivong 84717ff53b feat: start, stop, restart, enable and disable services, and list them in one round trip
napalm-device-types' SystemdServicesMixin (2.2.0) now provides get_services()
and manage_service(); this driver supplies only the transport (#7):

- _run_service_command(): unprivileged reads and root logins run as they
  are -- the user is asked once per session with "id -u", so a root login on a
  box without sudo is not prefixed with one. With a sudo password the command
  goes through _sudo(); without one through "sudo -n", which fails at once
  instead of hanging the session on a password prompt until the read timeout.
- get_services(): one round trip instead of an is-enabled and a show per unit
  (6.0 s -> 0.8 s on a 184-unit Ubuntu host). A host without systemd still
  falls back to "service --status-all".
- manage_service(): when sudo wants a password netOrk does not have, the
  failure says how to fix it -- the same hint the apt and SNMP actions give,
  now one constant (_SUDO_PASSWORD_HINT) instead of two copies.

OpenMediaVault and QNAP inherit this driver. OMV gets service control with
it; QNAP opts out (napalm-qnap-qts), since QTS has no systemd.

README: the sudo option is sudo_password, not secret; manage_service and the
systemctl permissions are listed.

Closes #7
2026-10-05 13:12:13 +02:00
christianmanivong 31b8a37895 Merge pull request 'feat: report the running kernel's modules and build configuration' (#8) from feat/kernel-facts into master 2026-10-05 04:36:47 +00:00
Christian Manivong a6e5568e0b feat: report the running kernel's modules and build configuration
LinuxDriver mixes in KernelFactsMixin from napalm-device-types and supplies
only the transport: the shared read-only command over the existing SSH
session, no sudo, one round trip. OpenMediaVault and QNAP inherit it.

Requires napalm-device-types 2.1.0.
2026-10-05 06:17:30 +02:00
4 changed files with 430 additions and 90 deletions
+21 -3
View File
@@ -48,7 +48,8 @@ with Driver(
optional_args={
# "port": 22,
# "pkg_manager": "apt", # force package manager; auto-detected by default
# "secret": "sudo-pass", # password for sudo / enable (defaults to login password)
# "sudo_password": "sudo-pass", # for commands that need root; without it,
# # `sudo -n` (passwordless sudo) is tried
# "debugging": True, # enable verbose logging
},
) as dev:
@@ -69,6 +70,10 @@ with Driver(
# Upgrade everything with pending updates
result = dev.apply_updates([])
# Restart a service (start, stop, restart, enable, disable)
result = dev.manage_service("cron", "restart")
print(result) # {"success": True, "output": ""}
```
## Supported NAPALM methods
@@ -99,7 +104,8 @@ with Driver(
| `get_packages()` | ✅ | apt, dnf, yum, apk, pacman |
| `get_pending_updates()` | ✅ | apt, dnf, yum, apk, pacman |
| `apply_updates(packages)` | ✅ | apt, dnf, yum, apk, pacman |
| `get_services()` | ✅ | systemd (fallback: SysV `service`) |
| `get_services()` | ✅ | systemd, one round trip (fallback: SysV `service`) |
| `manage_service(name, action)` | ✅ | systemd: start, stop, restart, enable, disable |
| `get_users()` | ✅ | `/etc/passwd` + `/etc/group` |
| `get_processes()` | ✅ | `ps axo` |
| `get_cron_jobs()` | ✅ | user crontabs + `/etc/cron.d/` |
@@ -127,13 +133,25 @@ The SSH user needs read access to:
| `/etc/passwd`, `/etc/group` | world-readable (default) |
| `/proc/uptime`, `/sys/class/dmi/…` | world-readable (default) |
| User crontabs (`/var/spool/cron/…`) | `root` or `sudo` required |
| `systemctl is-enabled <unit>` | unprivileged on most distros |
| `systemctl list-unit-files`, `systemctl show` | unprivileged |
| `systemctl start/stop/restart/enable/disable` | `root`, or `sudo` (with `sudo_password`, or passwordless) |
| `apt list --upgradable` | may require `apt-get update` (root) |
| `dnf check-update` / `yum check-update` | unprivileged, but slower without cache |
For full functionality it is recommended to run as `root` or grant passwordless `sudo` for
the above commands.
`get_services()` and `manage_service()` come from napalm-device-types'
`SystemdServicesMixin`; this driver supplies only the transport. An action runs as
`timeout 45 systemctl --no-ask-password <action> -- <unit>.service`, so a unit that hangs
on its way up or down cannot hold the session, and only the exit status decides whether it
succeeded. Without a sudo password it uses `sudo -n`, which fails at once instead of
waiting for a password prompt.
On OpenMediaVault (napalm-openmediavault inherits this driver), enabling or disabling a
unit that OMV manages itself — Samba, NFS, SSH — may be reverted the next time OMV applies
its configuration.
## Tested distributions
| Distribution | Version | Package manager | Tested |
+138 -86
View File
@@ -31,7 +31,19 @@ from netmiko.exceptions import (
)
from napalm.base.exceptions import ConnectionException, ConnectionClosedException
from napalm.base.netmiko_helpers import netmiko_args
from napalm_device_types import FingerprintRule, OSDriver
from napalm_device_types import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
FingerprintRule,
HostStatusMixin,
KernelFactsMixin,
OSDriver,
SystemdServicesMixin,
SystemdUnavailable,
parse_apt_upgradable,
parse_dnf_security,
strip_terminal_codes,
)
from napalm_device_types.models import (
ApplyUpdatesResultDict,
CronJobDict,
@@ -56,6 +68,40 @@ _PKG_MANAGERS = ["apt", "dnf", "yum", "apk", "pacman"]
_RC_MARKER = "__NETORK_RC="
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
def _split_status(raw: str) -> tuple[str, int | None]:
"""``(output, exit_status)`` of a command followed by ``echo {_RC_MARKER}$?``.
The status is ``None`` when the marker never arrived (output cut short), so
a caller can tell "unknown" from "succeeded".
"""
raw = strip_terminal_codes(raw)
matches = list(_RC_MARKER_RE.finditer(raw))
if not matches:
return raw, None
last = matches[-1]
return (raw[: last.start()] + raw[last.end():]).strip(), int(last.group(1))
#: How each package manager refreshes its index. pacman is left out on purpose:
#: ``pacman -Sy`` without ``-u`` invites a partial upgrade on the next install.
_REFRESH = {
# No LC_ALL=C here: under sudo it is an environment variable sudoers may refuse
# to set. Only the exit status decides, so the language is merely what is shown.
"apt": "apt-get update -q 2>&1",
"dnf": "dnf makecache -q 2>&1",
"yum": "yum makecache -q 2>&1",
"apk": "apk update -q 2>&1",
}
_YUM_SECURITY_COMMAND = "LC_ALL=C yum updateinfo list security -q 2>/dev/null"
#: 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. "
"Please add the sudo password to a Credential Profile assigned to this device, "
"or configure passwordless sudo (NOPASSWD) for this user."
)
# DMI field values that carry no useful information (OEM defaults, blanks)
_BAD_DMI: frozenset[str] = frozenset({
"", "none", "n/a", "not specified", "not applicable",
@@ -138,7 +184,7 @@ def _short_image_id(raw: str) -> str:
return raw.strip().removeprefix("sha256:")[:12]
class LinuxDriver(OSDriver):
class LinuxDriver(KernelFactsMixin, SystemdServicesMixin, HostStatusMixin, OSDriver):
"""NAPALM driver for generic Linux systems.
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
@@ -284,13 +330,43 @@ class LinuxDriver(OSDriver):
The status is ``None`` when the marker never arrived (output cut short),
so a caller can tell "unknown" from "succeeded".
"""
raw = self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
matches = list(_RC_MARKER_RE.finditer(raw))
if not matches:
return raw, None
last = matches[-1]
output = (raw[: last.start()] + raw[last.end():]).strip()
return output, int(last.group(1))
return _split_status(
self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
)
def _is_root(self) -> bool:
"""Whether the SSH user is root, asked once per session.
A root login on a box without sudo (an LXC container, a minimal Debian)
must not have its commands prefixed with a sudo that is not there.
"""
if getattr(self, "_root", None) is None:
self._root = self._send("id -u") == "0"
return bool(self._root)
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
"""The transport for :class:`SystemdServicesMixin`.
Without a sudo password, ``sudo -n`` fails at once where a prompt would
otherwise hang the session until the read timeout.
"""
if not privileged:
return self._send(command, read_timeout=timeout)
return self._run_privileged(command, timeout)
def _run_privileged(self, command: str, timeout: float = 100) -> str:
"""Run *command* as root: directly for a root login, through ``_sudo``
with a sudo password, and through ``sudo -n`` without one -- which fails at
once where a password prompt would hang the session until the timeout."""
if self._is_root():
return self._send(command, read_timeout=timeout)
if self._sudo_password:
return self._sudo(command, read_timeout=timeout)
return self._send(f"sudo -n {command}", read_timeout=timeout)
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)
def _detect_pkg_manager(self) -> str | None:
"""Return the first package manager binary found on PATH."""
@@ -840,6 +916,14 @@ class LinuxDriver(OSDriver):
}
}
# ------------------------------------------------------------------
# KernelFactsMixin – the transport for get_kernel_facts
# ------------------------------------------------------------------
def _run_kernel_facts_command(self, command: str) -> str:
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
return self._send(command, read_timeout=60)
# ------------------------------------------------------------------
# OSDriver – package management
# ------------------------------------------------------------------
@@ -1200,48 +1284,49 @@ class LinuxDriver(OSDriver):
def _get_updates_apt(self) -> list[UpdateDict]:
# apt list --upgradable does not need root; avoid sudo so it works even
# without a configured sudo password.
out = self._send(
"LC_ALL=C apt list --upgradable 2>/dev/null | grep -v '^Listing'",
read_timeout=60,
)
# Join wrapped lines: netmiko's 80-col pseudo-TTY causes long apt lines to
# break; continuation lines start with a space.
raw_lines: List[str] = []
for line in out.splitlines():
if line.startswith(" ") and raw_lines:
raw_lines[-1] += line.strip()
else:
raw_lines.append(line)
updates: list[UpdateDict] = []
for line in raw_lines:
# openssh-server/stable 1:9.2p1-2+deb12u2 amd64 [upgradable from: 1:9.2p1-2+deb12u1]
m = re.match(
r"^(\S+)/\S+\s+(\S+)\s+\S+\s+\[upgradable from:\s+(\S+)\]", line
)
if m:
updates.append({
"name": m.group(1),
"current_version": m.group(3),
"new_version": m.group(2),
})
return updates
# Raises ValueError when apt failed or the output was cut short.
return parse_apt_upgradable(self._send(APT_UPGRADABLE_COMMAND, read_timeout=60))
def _get_updates_rpm(self) -> list[UpdateDict]:
"""dnf/yum check-update: exit 100 means updates, 0 none, anything else failed."""
cmd = "dnf check-update --quiet 2>/dev/null" if self._pkg_manager == "dnf" else "yum check-update -q 2>/dev/null"
out = self._sudo(cmd)
output, status = _split_status(self._run_privileged(f"{cmd}; echo {_RC_MARKER}$?", 120))
if status not in (0, 100):
raise RuntimeError(f"{self._pkg_manager} check-update failed (exit {status}): {output[-200:]}")
security = self._rpm_security_names()
updates: list[UpdateDict] = []
for line in out.splitlines():
for line in output.splitlines():
parts = line.split()
if len(parts) >= 2 and not line.startswith(" ") and "." in parts[0]:
name_arch = parts[0]
name = name_arch.rsplit(".", 1)[0] if "." in name_arch else name_arch
name = parts[0].rsplit(".", 1)[0]
updates.append({
"name": name,
"current_version": "",
"new_version": parts[1],
"origin": parts[2] if len(parts) >= 3 else None,
"security": None if security is None else name in security,
})
return updates
def _rpm_security_names(self) -> set[str] | None:
"""Packages a pending security advisory covers; None when dnf/yum cannot say."""
cmd = DNF_SECURITY_COMMAND if self._pkg_manager == "dnf" else _YUM_SECURITY_COMMAND
output, status = _split_status(self._run_privileged(f"{cmd}; echo {_RC_MARKER}$?", 120))
return parse_dnf_security(output) if status == 0 else None
def refresh_available_updates(self) -> dict[str, Any]:
"""Refresh the package index (apt-get update, dnf makecache, apk update)."""
cmd = _REFRESH.get(self._pkg_manager or "")
if cmd is None:
return {
"success": False,
"output": f"Refreshing the index is not supported for {self._pkg_manager!r}",
}
output, status = _split_status(self._run_privileged(f"{cmd}; echo {_RC_MARKER}$?", 180))
if status != 0 and "password is required" in output:
output = f"{output}\n{_SUDO_PASSWORD_HINT}"
return {"success": status == 0, "output": output}
def _get_updates_apk(self) -> list[UpdateDict]:
out = self._send("apk version -l '<' 2>/dev/null")
updates: list[UpdateDict] = []
@@ -1371,50 +1456,25 @@ class LinuxDriver(OSDriver):
return {"success": False, "output": "", "error": str(exc)}
# ------------------------------------------------------------------
# OSDriver – services (systemd)
# OSDriver – services (systemd, through SystemdServicesMixin)
# ------------------------------------------------------------------
def get_services(self) -> list[ServiceDict]:
"""Return systemd service units (falls back to service --status-all on SysV)."""
out = self._send(
"systemctl list-units --type=service --all --no-legend --no-pager "
"--plain 2>/dev/null"
)
if not out:
"""systemd's services in one round trip; ``service --status-all`` without systemd."""
try:
return super().get_services()
except SystemdUnavailable:
return self._get_services_sysv()
services: list[ServiceDict] = []
for line in out.splitlines():
# ssh.service loaded active running OpenBSD Secure Shell server
parts = line.split(None, 4)
if len(parts) < 4:
continue
unit, load, active, sub = parts[0], parts[1], parts[2], parts[3]
name = unit.removesuffix(".service")
running = active == "active" and sub == "running"
enabled_out = self._send(
f"systemctl is-enabled {unit} 2>/dev/null"
)
enabled = enabled_out.strip() == "enabled"
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""Start, stop, restart, enable or disable a systemd service.
# Retrieve main PID for running services
pid = 0
if running:
pid_out = self._send(
f"systemctl show -p MainPID --value {unit} 2>/dev/null"
)
try:
pid = int(pid_out.strip())
except ValueError:
pid = 0
services.append({
"name": name,
"running": running,
"enabled": enabled,
"pid": pid,
})
return services
:raises ValueError: for an unknown action or an invalid name.
"""
result = super().manage_service(name, action)
if not result["success"] and "password is required" in result["output"]:
result["output"] = f"{result['output']}\n{_SUDO_PASSWORD_HINT}"
return result
def _get_services_sysv(self) -> list[ServiceDict]:
out = self._send("service --status-all 2>/dev/null")
@@ -2102,11 +2162,7 @@ class LinuxDriver(OSDriver):
if not self._sudo_password:
return {
"success": False,
"output": (
"sudo requires a password on this device but none is configured in "
"netOrk. Please add the sudo password to a Credential Profile assigned "
"to this device, or configure passwordless sudo (NOPASSWD) for this user."
),
"output": _SUDO_PASSWORD_HINT,
}
lines: list[str] = []
@@ -2132,11 +2188,7 @@ class LinuxDriver(OSDriver):
if not self._sudo_password:
return {
"success": False,
"output": (
"sudo requires a password on this device but none is configured in netOrk. "
"Please add the sudo password to a Credential Profile assigned to this device, "
"or configure passwordless sudo (NOPASSWD) for this user."
),
"output": _SUDO_PASSWORD_HINT,
}
# 1. Install snmpd if missing
+1 -1
View File
@@ -37,7 +37,7 @@ classifiers = [
]
dependencies = [
"napalm>=4.0",
"napalm-device-types>=0.3.0",
"napalm-device-types>=2.3.0",
"netmiko>=4.0.0",
"paramiko>=5.0.0", # CVE-2026-44405
]
+270
View File
@@ -226,6 +226,7 @@ APT_UPGRADABLE = (
"Listing... Done\n"
"openssh-server/stable 1:9.2p1-2+deb12u2 amd64 [upgradable from: 1:9.2p1-2+deb12u1]\n"
"curl/stable 7.88.1-10+deb12u6 amd64 [upgradable from: 7.88.1-10+deb12u5]\n"
"__APT_RC=0\n"
)
@@ -1145,3 +1146,272 @@ class TestUninstallExitStatus:
result = driver.uninstall_package("wazuh-agent")
assert result["success"] is False
# ---------------------------------------------------------------------------
# get_kernel_facts -- the command and its parse live in napalm-device-types
# ---------------------------------------------------------------------------
def _kernel_wire(report: str) -> str:
import base64
import gzip
return "KFACTS_BEGIN\n" + base64.encodebytes(gzip.compress(report.encode())).decode() + "KFACTS_END"
def test_get_kernel_facts_carries_the_shared_command_across(driver):
from napalm_device_types import KernelFactsMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND
assert isinstance(driver, KernelFactsMixin)
report = "[release]\n6.1.0-25-amd64\n[loaded]\ntipc\n[available]\nkernel/net/tipc/tipc.ko.xz\n"
with patch.object(driver, "_send", return_value=_kernel_wire(report)) as send:
facts = driver.get_kernel_facts()
assert send.call_args.args[0] == KERNEL_FACTS_COMMAND
assert facts["release"] == "6.1.0-25-amd64"
assert facts["loaded"] == ["tipc"]
assert facts["available"] == ["tipc"]
assert facts["builtin"] is None
def test_get_kernel_facts_raises_on_output_without_a_report(driver):
with patch.object(driver, "_send", return_value="sh: base64: not found"):
with pytest.raises(ValueError):
driver.get_kernel_facts()
# ---------------------------------------------------------------------------
# Services: listed in one round trip, controlled through systemctl (#7)
# ---------------------------------------------------------------------------
_REPORT = (
"SVC_BEGIN\n[files]\ncron.service enabled enabled\n[units]\n"
"MainPID=640\nId=cron.service\nNames=cron.service\nLoadState=loaded\n"
"ActiveState=active\nSubState=running\nUnitFileState=enabled\n"
"[generated]\nSVC_END\n"
)
class TestGetServices:
def test_one_command_lists_every_service(self, driver):
driver._device.send_command.return_value = _REPORT
services = driver.get_services()
assert services == [{"name": "cron", "running": True, "enabled": True, "pid": 640}]
assert driver._device.send_command.call_count == 1
assert "systemctl show" in driver._device.send_command.call_args[0][0]
def test_a_host_without_systemd_falls_back_to_service(self, driver):
driver._device.send_command.side_effect = [
"SVC_BEGIN\n[no-systemd]\n[files]\n[units]\n[generated]\nSVC_END\n",
" [ + ] cron\n [ - ] rsync\n",
]
services = driver.get_services()
assert {s["name"]: s["running"] for s in services} == {"cron": True, "rsync": False}
assert "service --status-all" in driver._device.send_command.call_args[0][0]
class TestManageService:
def _sent(self, driver) -> list[str]:
return [c[0][0] for c in driver._device.send_command.call_args_list]
def test_as_root_the_command_runs_as_it_is(self, driver):
driver._device.send_command.side_effect = ["0", "__SVC_RC=0"]
assert driver.manage_service("cron", "restart") == {"success": True, "output": ""}
uid, action = self._sent(driver)
assert uid == "id -u"
assert action.startswith("timeout 45 systemctl --no-ask-password restart -- cron.service")
def test_with_a_sudo_password_it_goes_through_sudo(self, driver):
driver._sudo_password = "pw" # noqa: S105
driver._device.send_command.side_effect = ["1000", "__SVC_RC=0"]
assert driver.manage_service("cron", "stop")["success"] is True
action = self._sent(driver)[1]
assert action.startswith("echo pw | sudo -S")
assert "timeout 45 systemctl --no-ask-password stop -- cron.service" in action
def test_without_one_sudo_never_waits_for_a_password(self, driver):
driver._device.send_command.side_effect = ["1000", "__SVC_RC=0"]
driver.manage_service("cron", "enable")
assert self._sent(driver)[1].startswith("sudo -n timeout 45 systemctl")
def test_a_missing_sudo_password_is_explained(self, driver):
driver._device.send_command.side_effect = [
"1000",
"sudo: a password is required\n__SVC_RC=1",
]
result = driver.manage_service("cron", "restart")
assert result["success"] is False
assert "sudo password" in result["output"]
assert "NOPASSWD" in result["output"]
def test_a_failure_keeps_systemctls_message(self, driver):
driver._device.send_command.side_effect = [
"0",
"Failed to start nope.service: Unit nope.service not found.\n__SVC_RC=5",
]
result = driver.manage_service("nope", "start")
assert result == {
"success": False,
"output": "Failed to start nope.service: Unit nope.service not found.",
}
def test_who_the_user_is_is_asked_once(self, driver):
driver._device.send_command.side_effect = ["0", "__SVC_RC=0", "__SVC_RC=0"]
driver.manage_service("cron", "stop")
driver.manage_service("cron", "start")
assert self._sent(driver).count("id -u") == 1
def test_an_invalid_name_is_refused_before_anything_is_sent(self, driver):
with pytest.raises(ValueError):
driver.manage_service("cron; reboot", "stop")
assert driver._device.send_command.call_count == 0
# ---------------------------------------------------------------------------
# Updates: origin and security, refresh, host status (netOrk MVP 5)
# ---------------------------------------------------------------------------
APT_WITH_SECURITY = (
"openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]\n"
"docker-compose-plugin/noble 5.6.0-1~ubuntu.24.04~noble amd64 [upgradable from: 5.5.1-1~ubuntu.24.04~noble]\n"
"__APT_RC=0\n"
)
class TestAvailableUpdates:
def test_apt_reports_origin_and_security(self, driver):
driver._pkg_manager = "apt"
_mock_send(driver, APT_WITH_SECURITY)
updates = {u["name"]: u for u in driver.get_available_updates()}
assert updates["openssl"]["security"] is True
assert updates["openssl"]["origin"] == "noble-updates,noble-security"
assert updates["docker-compose-plugin"]["security"] is False
def test_apt_that_could_not_read_raises_instead_of_reporting_nothing(self, driver):
driver._pkg_manager = "apt"
_mock_send(driver, "E: Could not open lock file\n__APT_RC=100\n")
with pytest.raises(ValueError):
driver.get_available_updates()
def test_apt_without_an_exit_status_raises(self, driver):
driver._pkg_manager = "apt"
_mock_send(driver, "openssl/noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable fro")
with pytest.raises(ValueError):
driver.get_available_updates()
def test_dnf_marks_what_a_security_advisory_covers(self, driver):
driver._pkg_manager = "dnf"
driver._device.send_command.side_effect = [
"0", # id -u
"openssl-libs.x86_64 1:3.1.4-2.fc40 updates\n"
"vim-enhanced.x86_64 2:9.1.083-1.fc40 updates\n__NETORK_RC=100",
"FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64\n__NETORK_RC=0",
]
updates = {u["name"]: u for u in driver.get_available_updates()}
assert updates["openssl-libs"]["security"] is True
assert updates["vim-enhanced"]["security"] is False
def test_dnf_without_advisories_leaves_security_unknown(self, driver):
driver._pkg_manager = "dnf"
driver._device.send_command.side_effect = [
"0",
"vim-enhanced.x86_64 2:9.1.083-1.fc40 updates\n__NETORK_RC=100",
"Error: updateinfo metadata missing\n__NETORK_RC=1",
]
assert driver.get_available_updates()[0]["security"] is None
def test_dnf_that_failed_raises(self, driver):
driver._pkg_manager = "dnf"
driver._device.send_command.side_effect = ["0", "Error: Failed to download metadata\n__NETORK_RC=1"]
with pytest.raises(RuntimeError):
driver.get_available_updates()
class TestRefreshAvailableUpdates:
def _sent(self, driver) -> list:
return [c[0][0] for c in driver._device.send_command.call_args_list]
def test_apt_refreshes_its_index_as_root(self, driver):
driver._pkg_manager = "apt"
driver._device.send_command.side_effect = ["0", "Hit:1 http://archive.ubuntu.com noble InRelease\n__NETORK_RC=0"]
result = driver.refresh_available_updates()
assert result["success"] is True
assert "apt-get update" in self._sent(driver)[1]
def test_without_a_sudo_password_it_never_waits_for_one(self, driver):
driver._pkg_manager = "apt"
driver._device.send_command.side_effect = ["1000", "sudo: a password is required\n__NETORK_RC=1"]
result = driver.refresh_available_updates()
assert result["success"] is False
assert self._sent(driver)[1].startswith("sudo -n apt-get update")
def test_dnf_refreshes_its_metadata(self, driver):
driver._pkg_manager = "dnf"
driver._device.send_command.side_effect = ["0", "Metadata cache created.\n__NETORK_RC=0"]
assert driver.refresh_available_updates()["success"] is True
assert "dnf makecache" in self._sent(driver)[1]
def test_pacman_is_not_refreshed_on_its_own(self, driver):
"""pacman -Sy without -u invites a partial upgrade on the next install."""
driver._pkg_manager = "pacman"
result = driver.refresh_available_updates()
assert result["success"] is False
driver._device.send_command.assert_not_called()
class TestHostStatus:
def test_the_driver_carries_the_shared_command(self, driver):
from napalm_device_types.host_status import HOST_STATUS_COMMAND
_mock_send(
driver,
"HSTAT_BEGIN\n[reboot-required]\n[kernel]\n6.8.0-142-generic\n[modules]\n"
"6.8.0-142-generic\n[timers]\napt-daily-upgrade.timer enabled\nHSTAT_END\n",
)
status = driver.get_host_status()
assert driver._device.send_command.call_args[0][0] == HOST_STATUS_COMMAND
assert status["reboot_required"] is True
class TestTerminalCodes:
def test_a_status_marker_behind_a_terminal_code_is_still_read(self, driver):
"""apt-get on a pseudo-terminal leaves keypad codes in front of the marker."""
driver._pkg_manager = "apt"
driver._device.send_command.side_effect = ["0", "Hit:1 noble InRelease\n\x1b>__NETORK_RC=0"]
assert driver.refresh_available_updates()["success"] is True