Compare commits
10
Commits
31b8a37895
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
007590b9bf | ||
|
|
79e52f060e | ||
|
|
3571149639 | ||
|
|
60b56c37e0 | ||
|
|
e82f99df7b | ||
|
|
2fed2f73e2 | ||
|
|
b49acb8ed7 | ||
|
|
a6f9a17858 | ||
|
|
e31bc2a3bf | ||
|
|
84717ff53b |
@@ -48,7 +48,8 @@ with Driver(
|
|||||||
optional_args={
|
optional_args={
|
||||||
# "port": 22,
|
# "port": 22,
|
||||||
# "pkg_manager": "apt", # force package manager; auto-detected by default
|
# "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
|
# "debugging": True, # enable verbose logging
|
||||||
},
|
},
|
||||||
) as dev:
|
) as dev:
|
||||||
@@ -69,6 +70,10 @@ with Driver(
|
|||||||
|
|
||||||
# Upgrade everything with pending updates
|
# Upgrade everything with pending updates
|
||||||
result = dev.apply_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
|
## Supported NAPALM methods
|
||||||
@@ -99,7 +104,8 @@ with Driver(
|
|||||||
| `get_packages()` | ✅ | apt, dnf, yum, apk, pacman |
|
| `get_packages()` | ✅ | apt, dnf, yum, apk, pacman |
|
||||||
| `get_pending_updates()` | ✅ | apt, dnf, yum, apk, pacman |
|
| `get_pending_updates()` | ✅ | apt, dnf, yum, apk, pacman |
|
||||||
| `apply_updates(packages)` | ✅ | 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_users()` | ✅ | `/etc/passwd` + `/etc/group` |
|
||||||
| `get_processes()` | ✅ | `ps axo` |
|
| `get_processes()` | ✅ | `ps axo` |
|
||||||
| `get_cron_jobs()` | ✅ | user crontabs + `/etc/cron.d/` |
|
| `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) |
|
| `/etc/passwd`, `/etc/group` | world-readable (default) |
|
||||||
| `/proc/uptime`, `/sys/class/dmi/…` | world-readable (default) |
|
| `/proc/uptime`, `/sys/class/dmi/…` | world-readable (default) |
|
||||||
| User crontabs (`/var/spool/cron/…`) | `root` or `sudo` required |
|
| 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) |
|
| `apt list --upgradable` | may require `apt-get update` (root) |
|
||||||
| `dnf check-update` / `yum check-update` | unprivileged, but slower without cache |
|
| `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
|
For full functionality it is recommended to run as `root` or grant passwordless `sudo` for
|
||||||
the above commands.
|
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
|
## Tested distributions
|
||||||
|
|
||||||
| Distribution | Version | Package manager | Tested |
|
| Distribution | Version | Package manager | Tested |
|
||||||
|
|||||||
+192
-87
@@ -31,7 +31,20 @@ from netmiko.exceptions import (
|
|||||||
)
|
)
|
||||||
from napalm.base.exceptions import ConnectionException, ConnectionClosedException
|
from napalm.base.exceptions import ConnectionException, ConnectionClosedException
|
||||||
from napalm.base.netmiko_helpers import netmiko_args
|
from napalm.base.netmiko_helpers import netmiko_args
|
||||||
from napalm_device_types import FingerprintRule, KernelFactsMixin, OSDriver
|
from napalm_device_types import (
|
||||||
|
APT_UPGRADABLE_COMMAND,
|
||||||
|
DNF_SECURITY_COMMAND,
|
||||||
|
FingerprintRule,
|
||||||
|
HostStatusMixin,
|
||||||
|
KernelFactsMixin,
|
||||||
|
ListeningSocketsMixin,
|
||||||
|
OSDriver,
|
||||||
|
SystemdServicesMixin,
|
||||||
|
SystemdUnavailable,
|
||||||
|
parse_apt_upgradable,
|
||||||
|
parse_dnf_security,
|
||||||
|
strip_terminal_codes,
|
||||||
|
)
|
||||||
from napalm_device_types.models import (
|
from napalm_device_types.models import (
|
||||||
ApplyUpdatesResultDict,
|
ApplyUpdatesResultDict,
|
||||||
CronJobDict,
|
CronJobDict,
|
||||||
@@ -56,6 +69,70 @@ _PKG_MANAGERS = ["apt", "dnf", "yum", "apk", "pacman"]
|
|||||||
_RC_MARKER = "__NETORK_RC="
|
_RC_MARKER = "__NETORK_RC="
|
||||||
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
|
_RC_MARKER_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
#: The end of a command's output when nothing better is known: a line that looks
|
||||||
|
#: like a shell prompt.
|
||||||
|
_PROMPT_RE = r"[#$\>]\s*$"
|
||||||
|
#: A command ending in ``echo __NAME=$?`` reports its exit status on a line of its
|
||||||
|
#: own: ``_RC_MARKER`` here, ``__APT_RC=`` and ``__SVC_RC=`` in napalm-device-types.
|
||||||
|
_STATUS_ECHO_RE = re.compile(r"echo\s+(__[A-Z_]+=)\$\?")
|
||||||
|
|
||||||
|
|
||||||
|
def _expect_for(command: str) -> str:
|
||||||
|
"""The pattern that ends *command*'s output.
|
||||||
|
|
||||||
|
netmiko stops reading as soon as the pattern matches what it has read so far.
|
||||||
|
A line the command prints can end in ``#``, ``$`` or ``>`` -- apt's
|
||||||
|
``<ftpmaster@ubuntu.com>`` after a bad signature -- and was taken for the
|
||||||
|
prompt: half the output came back, and the rest started the next command's
|
||||||
|
(#615). A command that echoes its exit status is read until that marker, with
|
||||||
|
a number, and the prompt line after it. The echoed command line carries a
|
||||||
|
literal ``$?`` and cannot match.
|
||||||
|
"""
|
||||||
|
markers = _STATUS_ECHO_RE.findall(command)
|
||||||
|
if not markers:
|
||||||
|
return _PROMPT_RE
|
||||||
|
return re.escape(markers[-1]) + r"\d+\s*\n.*" + _PROMPT_RE
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
#: 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 >/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. "
|
||||||
|
"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)
|
# DMI field values that carry no useful information (OEM defaults, blanks)
|
||||||
_BAD_DMI: frozenset[str] = frozenset({
|
_BAD_DMI: frozenset[str] = frozenset({
|
||||||
"", "none", "n/a", "not specified", "not applicable",
|
"", "none", "n/a", "not specified", "not applicable",
|
||||||
@@ -138,7 +215,9 @@ def _short_image_id(raw: str) -> str:
|
|||||||
return raw.strip().removeprefix("sha256:")[:12]
|
return raw.strip().removeprefix("sha256:")[:12]
|
||||||
|
|
||||||
|
|
||||||
class LinuxDriver(KernelFactsMixin, OSDriver):
|
class LinuxDriver(
|
||||||
|
KernelFactsMixin, ListeningSocketsMixin, SystemdServicesMixin, HostStatusMixin, OSDriver
|
||||||
|
):
|
||||||
"""NAPALM driver for generic Linux systems.
|
"""NAPALM driver for generic Linux systems.
|
||||||
|
|
||||||
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
|
Connects via SSH (netmiko ``linux`` device type) and auto-detects the
|
||||||
@@ -260,7 +339,7 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
command,
|
command,
|
||||||
read_timeout=read_timeout,
|
read_timeout=read_timeout,
|
||||||
cmd_verify=False,
|
cmd_verify=False,
|
||||||
expect_string=r'[#$\>]\s*$',
|
expect_string=_expect_for(command),
|
||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
def _sudo(self, command: str, read_timeout: float = 100) -> str:
|
def _sudo(self, command: str, read_timeout: float = 100) -> str:
|
||||||
@@ -284,13 +363,58 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
The status is ``None`` when the marker never arrived (output cut short),
|
The status is ``None`` when the marker never arrived (output cut short),
|
||||||
so a caller can tell "unknown" from "succeeded".
|
so a caller can tell "unknown" from "succeeded".
|
||||||
"""
|
"""
|
||||||
raw = self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
|
return _split_status(
|
||||||
matches = list(_RC_MARKER_RE.finditer(raw))
|
self._sudo(f"{command}; echo {_RC_MARKER}$?", read_timeout=read_timeout)
|
||||||
if not matches:
|
)
|
||||||
return raw, None
|
|
||||||
last = matches[-1]
|
def _is_root(self) -> bool:
|
||||||
output = (raw[: last.start()] + raw[last.end():]).strip()
|
"""Whether the SSH user is root, asked once per session.
|
||||||
return output, int(last.group(1))
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
def _detect_pkg_manager(self) -> str | None:
|
def _detect_pkg_manager(self) -> str | None:
|
||||||
"""Return the first package manager binary found on PATH."""
|
"""Return the first package manager binary found on PATH."""
|
||||||
@@ -848,6 +972,19 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
|
"""The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo."""
|
||||||
return self._send(command, read_timeout=60)
|
return self._send(command, read_timeout=60)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ListeningSocketsMixin – the transport for get_listening_sockets
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_listening_sockets_command(self, command: str, *, privileged: bool) -> str:
|
||||||
|
"""The transport for ``ListeningSocketsMixin.get_listening_sockets``.
|
||||||
|
|
||||||
|
Read-only either way; root only so that ``ss`` names every process.
|
||||||
|
"""
|
||||||
|
if privileged:
|
||||||
|
return self._run_privileged(command, 60)
|
||||||
|
return self._send(command, read_timeout=60)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# OSDriver – package management
|
# OSDriver – package management
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1208,48 +1345,49 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
def _get_updates_apt(self) -> list[UpdateDict]:
|
def _get_updates_apt(self) -> list[UpdateDict]:
|
||||||
# apt list --upgradable does not need root; avoid sudo so it works even
|
# apt list --upgradable does not need root; avoid sudo so it works even
|
||||||
# without a configured sudo password.
|
# without a configured sudo password.
|
||||||
out = self._send(
|
# Raises ValueError when apt failed or the output was cut short.
|
||||||
"LC_ALL=C apt list --upgradable 2>/dev/null | grep -v '^Listing'",
|
return parse_apt_upgradable(self._send(APT_UPGRADABLE_COMMAND, read_timeout=60))
|
||||||
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
|
|
||||||
|
|
||||||
def _get_updates_rpm(self) -> list[UpdateDict]:
|
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"
|
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] = []
|
updates: list[UpdateDict] = []
|
||||||
for line in out.splitlines():
|
for line in output.splitlines():
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 2 and not line.startswith(" ") and "." in parts[0]:
|
if len(parts) >= 2 and not line.startswith(" ") and "." in parts[0]:
|
||||||
name_arch = parts[0]
|
name = parts[0].rsplit(".", 1)[0]
|
||||||
name = name_arch.rsplit(".", 1)[0] if "." in name_arch else name_arch
|
|
||||||
updates.append({
|
updates.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"current_version": "",
|
"current_version": "",
|
||||||
"new_version": parts[1],
|
"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
|
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]:
|
def _get_updates_apk(self) -> list[UpdateDict]:
|
||||||
out = self._send("apk version -l '<' 2>/dev/null")
|
out = self._send("apk version -l '<' 2>/dev/null")
|
||||||
updates: list[UpdateDict] = []
|
updates: list[UpdateDict] = []
|
||||||
@@ -1379,50 +1517,25 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
return {"success": False, "output": "", "error": str(exc)}
|
return {"success": False, "output": "", "error": str(exc)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# OSDriver – services (systemd)
|
# OSDriver – services (systemd, through SystemdServicesMixin)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_services(self) -> list[ServiceDict]:
|
def get_services(self) -> list[ServiceDict]:
|
||||||
"""Return systemd service units (falls back to service --status-all on SysV)."""
|
"""systemd's services in one round trip; ``service --status-all`` without systemd."""
|
||||||
out = self._send(
|
try:
|
||||||
"systemctl list-units --type=service --all --no-legend --no-pager "
|
return super().get_services()
|
||||||
"--plain 2>/dev/null"
|
except SystemdUnavailable:
|
||||||
)
|
|
||||||
if not out:
|
|
||||||
return self._get_services_sysv()
|
return self._get_services_sysv()
|
||||||
|
|
||||||
services: list[ServiceDict] = []
|
def manage_service(self, name: str, action: str) -> dict[str, Any]:
|
||||||
for line in out.splitlines():
|
"""Start, stop, restart, enable or disable a systemd service.
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Retrieve main PID for running services
|
:raises ValueError: for an unknown action or an invalid name.
|
||||||
pid = 0
|
"""
|
||||||
if running:
|
result = super().manage_service(name, action)
|
||||||
pid_out = self._send(
|
if not result["success"] and "password is required" in result["output"]:
|
||||||
f"systemctl show -p MainPID --value {unit} 2>/dev/null"
|
result["output"] = f"{result['output']}\n{_SUDO_PASSWORD_HINT}"
|
||||||
)
|
return result
|
||||||
try:
|
|
||||||
pid = int(pid_out.strip())
|
|
||||||
except ValueError:
|
|
||||||
pid = 0
|
|
||||||
|
|
||||||
services.append({
|
|
||||||
"name": name,
|
|
||||||
"running": running,
|
|
||||||
"enabled": enabled,
|
|
||||||
"pid": pid,
|
|
||||||
})
|
|
||||||
return services
|
|
||||||
|
|
||||||
def _get_services_sysv(self) -> list[ServiceDict]:
|
def _get_services_sysv(self) -> list[ServiceDict]:
|
||||||
out = self._send("service --status-all 2>/dev/null")
|
out = self._send("service --status-all 2>/dev/null")
|
||||||
@@ -2110,11 +2223,7 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
if not self._sudo_password:
|
if not self._sudo_password:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"output": (
|
"output": _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."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
@@ -2140,11 +2249,7 @@ class LinuxDriver(KernelFactsMixin, OSDriver):
|
|||||||
if not self._sudo_password:
|
if not self._sudo_password:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"output": (
|
"output": _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."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Install snmpd if missing
|
# 1. Install snmpd if missing
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napalm>=4.0",
|
"napalm>=4.0",
|
||||||
"napalm-device-types>=2.1.0",
|
"napalm-device-types>=2.4.0",
|
||||||
"netmiko>=4.0.0",
|
"netmiko>=4.0.0",
|
||||||
"paramiko>=5.0.0", # CVE-2026-44405
|
"paramiko>=5.0.0", # CVE-2026-44405
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Unit tests for LinuxDriver – parsing helpers (no real SSH connection needed)."""
|
"""Unit tests for LinuxDriver – parsing helpers (no real SSH connection needed)."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from napalm_linux.linux import LinuxDriver, _arm_vendor_from_model
|
from napalm_linux.linux import LinuxDriver, _arm_vendor_from_model
|
||||||
@@ -226,6 +228,7 @@ APT_UPGRADABLE = (
|
|||||||
"Listing... Done\n"
|
"Listing... Done\n"
|
||||||
"openssh-server/stable 1:9.2p1-2+deb12u2 amd64 [upgradable from: 1:9.2p1-2+deb12u1]\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"
|
"curl/stable 7.88.1-10+deb12u6 amd64 [upgradable from: 7.88.1-10+deb12u5]\n"
|
||||||
|
"__APT_RC=0\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1049,6 +1052,106 @@ class TestSudoStatus:
|
|||||||
assert "__NETORK_RC=1" not in output
|
assert "__NETORK_RC=1" not in output
|
||||||
|
|
||||||
|
|
||||||
|
class _Channel:
|
||||||
|
"""A netmiko connection that hands out its output in chunks and stops where
|
||||||
|
netmiko does: at the first chunk after which ``expect_string`` matches all
|
||||||
|
that was read so far."""
|
||||||
|
|
||||||
|
def __init__(self, chunks):
|
||||||
|
self.chunks = list(chunks)
|
||||||
|
self.patterns: list = []
|
||||||
|
|
||||||
|
def send_command(self, command, *, expect_string, **_kwargs):
|
||||||
|
self.patterns.append(expect_string)
|
||||||
|
output = ""
|
||||||
|
while self.chunks:
|
||||||
|
output += self.chunks.pop(0)
|
||||||
|
if re.search(expect_string, output):
|
||||||
|
return output
|
||||||
|
raise TimeoutError(f"pattern not detected: {expect_string!r}")
|
||||||
|
|
||||||
|
|
||||||
|
#: What vault-01 sent on 2026-10-06 while its apt proxy served a corrupted
|
||||||
|
#: InRelease: the signature line ends in ">", which looks like a prompt (#615).
|
||||||
|
_BADSIG_CHUNKS = [
|
||||||
|
"sudo -n apt-get update -q 2>&1; echo __NETORK_RC=$?\n",
|
||||||
|
"Fehl:2 http://archive.ubuntu.com/ubuntu noble-updates InRelease\n"
|
||||||
|
" Die folgenden Signaturen waren ungültig: BADSIG 871920D1991BC93C "
|
||||||
|
"Ubuntu Archive Automatic Signing Key (2018) <ftpmaster@ubuntu.com>\n",
|
||||||
|
"W: Fehler beim Holen von http://archive.ubuntu.com/ubuntu/dists/noble-updates/InRelease\n"
|
||||||
|
"E: Das Depot ist nicht signiert.\n__NETORK_RC=100\n",
|
||||||
|
"chris@vault-01:~$ ",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadToTheEnd:
|
||||||
|
"""A line the command prints can end in ``>``, ``#`` or ``$`` -- apt's
|
||||||
|
``<ftpmaster@ubuntu.com>`` after a bad signature. Taken for the prompt, it
|
||||||
|
ended the read while the command still ran, and the rest arrived as the next
|
||||||
|
command's output (#615). A command that echoes its exit status is read until
|
||||||
|
that marker and the prompt after it."""
|
||||||
|
|
||||||
|
def test_a_signature_line_does_not_end_the_refresh(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._device = _Channel(_BADSIG_CHUNKS)
|
||||||
|
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "E: Das Depot ist nicht signiert." in result["output"]
|
||||||
|
|
||||||
|
def test_the_session_stays_in_step(self, driver):
|
||||||
|
"""Everything up to the prompt is consumed, so the next command reads its own output."""
|
||||||
|
driver._root = False
|
||||||
|
driver._device = _Channel(_BADSIG_CHUNKS + ["true\n", "__NETORK_RC=0\nchris@vault-01:~$ "])
|
||||||
|
|
||||||
|
driver.refresh_available_updates()
|
||||||
|
output, status = driver._sudo_status("true")
|
||||||
|
|
||||||
|
assert status == 0
|
||||||
|
assert "BADSIG" not in output
|
||||||
|
|
||||||
|
def test_the_echoed_command_does_not_count_as_the_marker(self, driver):
|
||||||
|
"""Its literal ``$?`` is no number."""
|
||||||
|
channel = _Channel(["sudo true; echo __NETORK_RC=$?\n", "__NETORK_RC=0\nchris@vault-01:~$ "])
|
||||||
|
driver._device = channel
|
||||||
|
|
||||||
|
assert driver._sudo_status("true")[1] == 0
|
||||||
|
assert channel.chunks == []
|
||||||
|
|
||||||
|
def test_the_marker_alone_is_not_the_end(self, driver):
|
||||||
|
"""The prompt after it has to be read too, or it would start the next output."""
|
||||||
|
channel = _Channel(["out\n__NETORK_RC=0\n", "chris@vault-01:~$ "])
|
||||||
|
driver._device = channel
|
||||||
|
|
||||||
|
driver._sudo_status("true")
|
||||||
|
|
||||||
|
assert channel.chunks == []
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command",
|
||||||
|
[
|
||||||
|
"{ LC_ALL=C apt list --upgradable 2>/dev/null; echo __APT_RC=$?; } | cat",
|
||||||
|
"timeout 45 systemctl restart -- cron.service; echo __SVC_RC=$?",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_every_status_marker_is_waited_for(self, driver, command):
|
||||||
|
marker = re.search(r"echo (__[A-Z_]+=)", command).group(1)
|
||||||
|
channel = _Channel([f"x <a@b>\n", f"{marker}0\nchris@host:~$ "])
|
||||||
|
driver._device = channel
|
||||||
|
|
||||||
|
output = driver._send(command)
|
||||||
|
|
||||||
|
assert f"{marker}0" in output
|
||||||
|
|
||||||
|
def test_a_command_without_a_marker_still_ends_at_the_prompt(self, driver):
|
||||||
|
channel = _Channel(["6.8.0-142-generic\nchris@host:~$ "])
|
||||||
|
driver._device = channel
|
||||||
|
|
||||||
|
assert driver._send("uname -r").startswith("6.8.0-142-generic")
|
||||||
|
assert channel.patterns == [r"[#$\>]\s*$"]
|
||||||
|
|
||||||
|
|
||||||
class TestUninstallExitStatus:
|
class TestUninstallExitStatus:
|
||||||
"""Whether a removal worked is what the package manager's exit status says.
|
"""Whether a removal worked is what the package manager's exit status says.
|
||||||
|
|
||||||
@@ -1180,3 +1283,337 @@ def test_get_kernel_facts_raises_on_output_without_a_report(driver):
|
|||||||
with patch.object(driver, "_send", return_value="sh: base64: not found"):
|
with patch.object(driver, "_send", return_value="sh: base64: not found"):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
driver.get_kernel_facts()
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Listening sockets: ss and the processes' cgroups, as root when it can
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SOCKETS = (
|
||||||
|
"SOCK_BEGIN\n[ss]\n"
|
||||||
|
'tcp LISTEN 0 128 0.0.0.0:5432 0.0.0.0:* users:(("postgres",pid=812,fd=6))\n'
|
||||||
|
"__SS_RC=0\n[cgroups]\n"
|
||||||
|
"812 0::/system.slice/system-postgresql.slice/postgresql@16-main.service\n"
|
||||||
|
"SOCK_END\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetListeningSockets:
|
||||||
|
def _sent(self, driver) -> list[str]:
|
||||||
|
return [c[0][0] for c in driver._device.send_command.call_args_list]
|
||||||
|
|
||||||
|
def test_it_reads_with_the_shared_command(self, driver):
|
||||||
|
from napalm_device_types import ListeningSocketsMixin
|
||||||
|
|
||||||
|
assert isinstance(driver, ListeningSocketsMixin)
|
||||||
|
driver._root = True
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
reading = driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert reading["attributed"] is True
|
||||||
|
[socket] = reading["sockets"]
|
||||||
|
assert (socket["port"], socket["unit"]) == (5432, "postgresql@16-main")
|
||||||
|
assert self._sent(driver)[0].startswith("sh -c '")
|
||||||
|
|
||||||
|
def test_without_a_sudo_password_the_whole_script_runs_under_sudo_n(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert self._sent(driver)[0].startswith("sudo -n sh -c '")
|
||||||
|
|
||||||
|
def test_with_a_sudo_password_it_goes_through_sudo(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._sudo_password = "pw" # noqa: S105
|
||||||
|
driver._device.send_command.return_value = _SOCKETS
|
||||||
|
|
||||||
|
driver.get_listening_sockets()
|
||||||
|
|
||||||
|
sent = self._sent(driver)[0]
|
||||||
|
assert sent.startswith("echo pw | sudo -S")
|
||||||
|
assert "sh -c '" in sent
|
||||||
|
|
||||||
|
def test_when_sudo_refuses_it_reads_what_the_user_may_see(self, driver):
|
||||||
|
driver._root = False
|
||||||
|
driver._device.send_command.side_effect = ["sudo: a password is required", _SOCKETS]
|
||||||
|
|
||||||
|
reading = driver.get_listening_sockets()
|
||||||
|
|
||||||
|
assert reading["attributed"] is False
|
||||||
|
refused, plain = self._sent(driver)
|
||||||
|
assert refused.startswith("sudo -n sh -c '")
|
||||||
|
assert plain.startswith("sh -c '")
|
||||||
|
|||||||
Reference in New Issue
Block a user