Author SHA1 Message Date
christianmanivong 18676a573f feat: say where a pending update comes from, whether it is a security fix, and whether the host needs a reboot
netOrk MVP 5 measures "security updates applied within N days" and starts
patch runs inside agreed windows. That needs three things every Linux driver
reads the same way, so they live here once:

- UpdateDict gains optional `origin` and `security` (None = unknown).
- package_updates: APT_UPGRADABLE_COMMAND and parse_apt_upgradable(). apt's
  suites are the origin; a "-security" suite makes it a security update;
  several architectures of one package are one entry. The command runs
  through a pipe with its exit status printed inside the group: through a
  pseudo-terminal apt drew progress and keypad codes, one of which (ESC >)
  a screen-scraping read took for a prompt and stopped at. The parser raises
  ValueError when apt failed or its status never arrived.
  DNF_SECURITY_COMMAND / parse_dnf_security() / nevra_name() for dnf/yum.
- host_status: HOST_STATUS_COMMAND, parse_host_status(), HostStatusMixin
  (template form, hook _run_host_status_command). reboot_required from
  /var/run/reboot-required, needs-restarting -r, or a newer kernel of the
  running flavour (a Raspberry Pi carries two flavours side by side);
  auto_updates from APT::Periodic::Unattended-Upgrade with its timer, or
  dnf-automatic. HostStatusDict in models.py.
- terminal.strip_terminal_codes(): CSI, OSC and two-character escapes, now
  also used by the systemd parser.
- UpdateMixin: contract refresh_available_updates(); get_available_updates'
  docstring now states the rule that a reader raises when it cannot read and
  never returns [] for "don't know".

Fixtures are real output from Ubuntu 24.04, Debian 13 / OMV, Raspberry Pi OS
and Proxmox VE 9. Version 2.3.0.
2026-10-06 00:19:24 +02:00
christianmanivong 4071f35050 Merge pull request 'feat: list systemd services in one round trip and control them, once for every driver' (#5) from feat/systemd-services-mixin into main 2026-10-05 11:11:45 +00:00
christianmanivong 17d8dabb4d feat: list systemd services in one round trip and control them, once for every driver
Listing a host's services and starting or stopping one is the same on every
host that runs systemd, so the command, its parse, the check of a unit name
and the reading of an action's exit status live here once, and a driver
supplies only the transport (napalm-linux#7, napalm-proxmox#6):

- SYSTEMD_SERVICES_COMMAND: one read-only POSIX sh line. list-unit-files, then
  one systemctl show over every loaded service unit (Id, Names, LoadState,
  ActiveState, SubState, UnitFileState, MainPID), and is-enabled only for
  generated units, whose boot state lives in a SysV script's rc links. Framed;
  [no-systemd] when /run/systemd/system is missing. Replaces an is-enabled and
  a show per unit: 0.8 s instead of 6 s on a 180-unit Ubuntu host.
- parse_systemd_services(): loaded units except not-found, plus installed unit
  files that are not loaded; no templates, no aliases (also not the ones older
  systemd lists as "enabled"). enabled = UnitFileState enabled or
  enabled-runtime, read from systemctl show and never from list-unit-files'
  second column, which has had a preset column after it since systemd 245.
  A report whose end is missing raises ValueError, so a list cut short never
  reads as services that went away; a host without systemd raises
  SystemdUnavailable, a NotImplementedError, so a driver can fall back.
- unit_name() / service_action_command() / parse_action_result(): template
  instances, dots, colons and \xHH escapes accepted; a bare template, a leading
  "-" and anything a shell reads refused. The action runs as
  "timeout 45 systemctl --no-ask-password <action> -- <unit>.service" with its
  exit status printed after it; only that status decides, 124 is not called
  done, and terminal colour codes are dropped. The marker also keeps the output
  from ever being empty, which a transport that retries on an empty answer
  would take as a reason to run the action twice.
- SystemdServicesMixin, in the template form: get_services() and
  manage_service() are concrete, _run_service_command(command, *, privileged,
  timeout) is the driver's hook. Mixed in by the drivers whose host runs
  systemd, not by OSDriver.

Version 2.2.0.
2026-10-05 13:11:44 +02:00
christianmanivong d55b036a8e Merge pull request 'feat: read what a Linux kernel has built and loaded, once for every driver' (#4) from feat/kernel-facts into main 2026-10-05 04:36:42 +00:00
christianmanivong 536ffcf6e1 feat: read what a Linux kernel has built and loaded, once for every driver
A kernel CVE's exploitability often hangs on code that is not there: a module
neither loaded nor shipped, an option the kernel was built without. netOrk's
KB precondition vocabulary asks exactly that (kernel_module, kernel_config).

Reading it is the same on every Linux host, so the command and its parse live
here and a driver supplies only the transport:

- KERNEL_FACTS_COMMAND: one read-only POSIX sh line, no privileges. Release,
  /proc/modules, modules.builtin, modules.dep and the build configuration
  (/boot/config-* or /proc/config.gz). The report is framed, gzipped and
  base64-encoded, so nothing in it can look like a shell prompt to a
  screen-scraping transport, and ~300 kB of configuration crosses as a fifth.
- parse_kernel_facts(): a section the command could not print comes back None,
  never empty -- "could not read" and "read, and nothing there" must stay apart.
- module_name(): no path, no .ko suffix, "-" folded to "_", as the kernel does.
- KernelFactsMixin, in the template form: get_kernel_facts() is concrete,
  _run_kernel_facts_command() is the driver's hook. Mixed in by the drivers
  that can, not by OSDriver -- a Windows host is an OS driver too, and
  hasattr(driver, "get_kernel_facts") has to stay truthful.
- KernelFactsDict in models.py. Version 2.1.0.
2026-10-05 06:17:30 +02:00
christianmanivong 97e7ede131 Merge pull request 'feat: a new VM's CPU model can be chosen, from a list the hypervisor offers' (#3) from feat/vm-cpu-type into main 2026-10-04 15:42:15 +00:00
14 changed files with 1837 additions and 3 deletions
+26
View File
@@ -73,6 +73,32 @@ is a thin bundle over them — `PackageManagementMixin`, `HealthMetricsMixin`,
`HostRebootMixin` (`reboot_host`) is mixed into `DeviceTypeDriver` itself, since any
device may be restartable; like the others it only declares.
`KernelFactsMixin` (`get_kernel_facts`) is the exception that is mixed in by a driver
rather than by a role base: what a Linux kernel has built and loaded is read the same way
everywhere, so the command and its parse are concrete here and a driver supplies only
`_run_kernel_facts_command`. `OSDriver` does not carry it — a Windows host is an OS driver
too, and `hasattr(driver, "get_kernel_facts")` has to stay truthful.
`HostStatusMixin` (`get_host_status`) is mixed in the same way: whether a Linux host needs
a reboot to finish an update (`/var/run/reboot-required`, `needs-restarting -r`, or a newer
kernel of the running flavour installed) and whether it patches itself (unattended-upgrades,
dnf-automatic). `package_updates` holds the shared apt and dnf parsers: apt's suites become
an update's `origin`, a `-security` suite makes it a security update, and dnf's security
advisories do the same.
**Update readers raise when they cannot read.** `get_available_updates` returns an empty
list only when nothing is pending; netOrk keeps "pending since" per package, and an empty
list for "don't know" would reset it.
`SystemdServicesMixin` (`get_services`, `manage_service`) is mixed in the same way, by
the drivers whose host runs systemd. Listing the services, checking a unit name and
reading an action's exit status are the same on every such host, so they are concrete
here, and a driver supplies only `_run_service_command(command, *, privileged, timeout)`
— how a command reaches its host and how it gains root there. The listing is one round
trip (`list-unit-files` plus one `systemctl show` over every loaded unit) instead of an
`is-enabled` and a `show` per unit. A host without systemd raises `SystemdUnavailable`,
a `NotImplementedError`, so a driver can fall back to another init system.
A function class may use the **template form** — public method concrete, the
device-specific part a `_hook` declared under `if TYPE_CHECKING` — *when the base
genuinely does work* on the result: normalising, sorting, validating, or orchestrating
+33
View File
@@ -38,13 +38,16 @@ instead of being restated on every role that happens to need it:
* :class:`~napalm_device_types.dhcp.DhcpServerMixin`
* :class:`~napalm_device_types.firewall_rules.FirewallRuleMixin`
* :class:`~napalm_device_types.health_metrics.HealthMetricsMixin`
* :class:`~napalm_device_types.host_status.HostStatusMixin`
* :class:`~napalm_device_types.host_reboot.HostRebootMixin`
* :class:`~napalm_device_types.interface_filter.InterfaceFilterMixin`
* :class:`~napalm_device_types.kernel.KernelFactsMixin`
* :class:`~napalm_device_types.mac_acl.MacAclMixin`
* :class:`~napalm_device_types.nat_vpn.NatVpnMixin`
* :class:`~napalm_device_types.packages.PackageManagementMixin`
* :class:`~napalm_device_types.ping_sweep.PingSweepMixin`
* :class:`~napalm_device_types.services.ServiceControlMixin`
* :class:`~napalm_device_types.systemd.SystemdServicesMixin`
* :class:`~napalm_device_types.updates.UpdateMixin`
Introspection -- :func:`~napalm_device_types.roles.roles_of`,
@@ -63,6 +66,7 @@ from napalm_device_types.firewall_rules import FirewallRuleMixin
from napalm_device_types.health_metrics import HealthMetricsMixin
from napalm_device_types.host_reboot import HostRebootMixin
from napalm_device_types.interface_filter import InterfaceFilterMixin
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND, KernelFactsMixin, parse_kernel_facts
from napalm_device_types.lag import add_lag_interfaces
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.media import MediaDriver
@@ -71,7 +75,21 @@ from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
from napalm_device_types.ping_sweep import PingSweepMixin, driver_supports_ping
from napalm_device_types.roles import primary_role_of, role_keys_of, roles_of
from napalm_device_types.host_status import HOST_STATUS_COMMAND, HostStatusMixin, parse_host_status
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
parse_apt_upgradable,
parse_dnf_security,
)
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.terminal import strip_terminal_codes
from napalm_device_types.systemd import (
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_systemd_services,
)
from napalm_device_types.updates import UpdateMixin
from napalm_device_types.residential_gateway import ResidentialGatewayDriver
from napalm_device_types.storage import StorageDriver
@@ -86,6 +104,8 @@ __all__ = [
"FirewallDriver",
"FirewallRuleMixin",
"HealthMetricsMixin",
"HOST_STATUS_COMMAND",
"HostStatusMixin",
"HostRebootMixin",
"HypervisorDriver",
"InterfaceFilterMixin",
@@ -94,6 +114,15 @@ __all__ = [
"NatVpnMixin",
"OSDriver",
"PackageManagementMixin",
"APT_UPGRADABLE_COMMAND",
"DNF_SECURITY_COMMAND",
"parse_apt_upgradable",
"parse_dnf_security",
"parse_host_status",
"strip_terminal_codes",
"KernelFactsMixin",
"KERNEL_FACTS_COMMAND",
"parse_kernel_facts",
"PhoneDriver",
"PingSweepMixin",
"PortSpec",
@@ -101,6 +130,10 @@ __all__ = [
"ServiceControlMixin",
"StorageDriver",
"SwitchDriver",
"SYSTEMD_SERVICES_COMMAND",
"SystemdServicesMixin",
"SystemdUnavailable",
"parse_systemd_services",
"UpdateMixin",
"add_lag_interfaces",
"driver_supports_ping",
+177
View File
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""Host status: does the host need a reboot, and does it patch itself?
A patch run that installed a new kernel or libc has not closed anything until
the host restarts, so "reboot required" is part of being patched. Whether the
host installs updates on its own (unattended-upgrades, dnf-automatic) decides
how far netOrk's maintenance window reaches. Both are read the same way on every
Linux host, so the command and its parse live here once and a driver only
carries the command across.
**Reboot required** is any of:
- ``/var/run/reboot-required`` exists. Ubuntu always writes it; Debian does when
update-notifier or unattended-upgrades is installed.
- ``needs-restarting -r`` exits 1 (dnf-utils).
- 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.
It is ``None`` when none of these could be read, for example in a container
without a ``/lib/modules`` of its own.
**Auto updates** is apt's ``APT::Periodic::Unattended-Upgrade`` (set, not "0",
and ``apt-daily-upgrade.timer`` not disabled) or an enabled dnf-automatic timer.
It is ``None`` on a host with neither apt nor dnf-automatic.
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from napalm_device_types.models import HostStatusDict
from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "HSTAT_BEGIN"
_END = "HSTAT_END"
_REBOOT_FILE = "/var/run/reboot-required"
_APT_TIMER = "apt-daily-upgrade.timer"
_DNF_TIMERS = ("dnf-automatic.timer", "dnf-automatic-install.timer")
#: One line, POSIX ``sh``, read-only, no privileges. The frame markers are
#: printed in two halves so that an echoing transport does not show them early.
#: Each timer is asked on its own: older systemd prints nothing for an unknown
#: unit, which would shift a combined answer. Run through a pipe, so nothing in
#: it sees a terminal and colours its output.
HOST_STATUS_COMMAND = (
"{ printf '%s%s\\n' HSTAT_ BEGIN; "
f"[ -f {_REBOOT_FILE} ] && echo '[reboot-required]'; "
"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 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 "
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled "$u" 2>/dev/null)"; done; '
"printf '%s%s\\n' HSTAT_ END; } 2>/dev/null | cat"
)
_PERIODIC = re.compile(r'^APT::Periodic::Unattended-Upgrade\s+"([^"]*)"')
_OFF_STATES = frozenset({"disabled", "masked"})
def _sections(output: str) -> Dict[str, List[str]]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no intact host status report in the output") from None
sections: Dict[str, List[str]] = {}
current: List[str] = []
for line in lines[start + 1 : end]:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
elif line:
current.append(line)
return sections
def _version_key(version: str) -> Tuple[object, ...]:
"""Natural order: 6.8.0-142 after 6.8.0-87, 7.0.14 after 7.0.2."""
return tuple(int(part) if part.isdigit() else part for part in re.split(r"(\d+)", version))
def kernel_reboot_pending(running: str, installed: List[str]) -> Optional[str]:
"""The newest installed kernel of the running flavour, if it is newer than the
running one; otherwise None.
The flavour is what follows the last ``-`` (``generic``, ``amd64``, ``pve``,
``v8``); a kernel of another flavour is never a reason to reboot.
"""
flavour = running.rsplit("-", 1)[-1]
same = [k for k in installed if k.rsplit("-", 1)[-1] == flavour]
if not same:
return None
newest = max(same, key=lambda k: _version_key(k.rsplit("-", 1)[0]))
if _version_key(newest.rsplit("-", 1)[0]) > _version_key(running.rsplit("-", 1)[0]):
return newest
return 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"
running = (sections.get("kernel") or [""])[0]
modules = sections.get("modules") or []
newer = kernel_reboot_pending(running, modules) if running and modules else None
if newer:
return True, f"kernel {newer} installed, {running} running"
if needs or modules:
return False, None
return None, None
def _timer_states(sections: Dict[str, List[str]]) -> Dict[str, str]:
states: Dict[str, str] = {}
for line in sections.get("timers") or []:
unit, _, state = line.partition(" ")
states[unit] = state.strip()
return states
def _auto_updates(sections: Dict[str, List[str]]) -> Optional[bool]:
timers = _timer_states(sections)
if any(timers.get(t) == "enabled" for t in _DNF_TIMERS):
return True
if "apt-config" not in sections:
return None
match = next(filter(None, (_PERIODIC.match(line) for line in sections["apt-config"])), None)
switched_on = match is not None and match.group(1) not in ("", "0")
return switched_on and timers.get(_APT_TIMER) not in _OFF_STATES
def parse_host_status(output: str) -> HostStatusDict:
"""Parse what :data:`HOST_STATUS_COMMAND` printed.
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(output)
required, reason = _reboot(sections)
return {
"reboot_required": required,
"reboot_reason": reason,
"auto_updates": _auto_updates(sections),
}
class HostStatusMixin:
"""Adds :meth:`get_host_status` to a driver that can run a command on a Linux host.
The template form, like :class:`~napalm_device_types.kernel.KernelFactsMixin`:
the reading and its parse are the same everywhere, and a driver supplies only
:meth:`_run_host_status_command`. Mixed in by the drivers that can, so
``hasattr(driver, "get_host_status")`` stays a truthful answer.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_host_status_command(self, command: str) -> str:
"""Run *command* on the host with ``sh`` and return what it printed."""
...
def get_host_status(self) -> HostStatusDict:
"""
Returns whether the host needs a reboot and whether it patches itself.
* reboot_required (bool or None)
* reboot_reason (string or None)
* auto_updates (bool or None)
:raises ValueError: if the host's output carried no intact report.
"""
return parse_host_status(self._run_host_status_command(HOST_STATUS_COMMAND))
+168
View File
@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""What the running kernel has built and loaded.
A kernel CVE's exploitability often hangs on code that is simply not there --
a module that is neither loaded nor shipped, a subsystem the kernel was built
without. Reading that is identical on every Linux host, so the command and its
parse live here once and a driver only carries the command across: SSH,
an API's exec endpoint, whatever it has.
The command is read-only and needs no privileges. It frames its report and
sends it gzipped and base64-encoded, for two reasons: nothing in the payload can
then look like a shell prompt to a screen-scraping transport, and a kernel's
build configuration (~300 kB on a distribution kernel) crosses as a fifth of
that.
What the four lists mean for a module, and why "not loaded" alone is never
"absent": a module that is not loaded can still be loaded on demand -- by an
attacker too, where autoloading reaches it. Only a module that is neither
loaded, nor compiled in, nor shipped for this kernel is one it cannot have.
"""
from __future__ import annotations
import base64
import binascii
import gzip
import zlib
from typing import Dict, List, Optional, TYPE_CHECKING
from napalm_device_types.models import KernelFactsDict
_BEGIN = "KFACTS_BEGIN"
_END = "KFACTS_END"
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
KERNEL_FACTS_COMMAND = (
"r=$(uname -r); m=/lib/modules/$r; "
"printf '%s%s\\n' KFACTS_ BEGIN; "
"{ echo '[release]'; echo \"$r\"; "
"if [ -r /proc/modules ]; then echo '[loaded]'; cut -d' ' -f1 /proc/modules; fi; "
"if [ -r $m/modules.builtin ]; then echo '[builtin]'; cat $m/modules.builtin; fi; "
"if [ -r $m/modules.dep ]; then echo '[available]'; cut -d: -f1 $m/modules.dep; fi; "
"if [ -r /boot/config-$r ]; then echo '[config]'; grep '^CONFIG_' /boot/config-$r; "
"elif [ -r /proc/config.gz ]; then echo '[config]'; zcat /proc/config.gz | grep '^CONFIG_'; fi; "
"} 2>/dev/null | gzip -c | base64; "
"printf '%s%s\\n' KFACTS_ END"
)
def module_name(raw: str) -> str:
"""A module as the kernel names it: no path, no ``.ko`` suffix, ``_`` for ``-``.
``kernel/net/can/can-raw.ko.zst`` and ``can_raw`` are the same module; the
kernel itself treats dash and underscore alike.
"""
base = raw.strip().rsplit("/", 1)[-1]
suffix = base.find(".ko")
if suffix != -1:
base = base[:suffix]
return base.replace("-", "_").lower()
def _report(output: str) -> str:
lines = [line.strip() for line in output.splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no kernel facts in the output") from None
try:
packed = base64.b64decode("".join(lines[start + 1 : end]), validate=True)
return gzip.decompress(packed).decode()
except (binascii.Error, OSError, EOFError, zlib.error, UnicodeDecodeError) as exc:
raise ValueError(f"the kernel facts could not be decoded: {exc}") from exc
def _sections(report: str) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: Optional[List[str]] = None
for line in report.splitlines():
line = line.strip()
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
elif line and current is not None:
current.append(line)
return sections
def _config(lines: List[str]) -> Dict[str, str]:
config: Dict[str, str] = {}
for line in lines:
option, sep, value = line.partition("=")
if not sep:
continue
if len(value) >= 2 and value[0] == value[-1] == '"':
value = value[1:-1]
config[option] = value
return config
def parse_kernel_facts(output: str) -> KernelFactsDict:
"""Parse what :data:`KERNEL_FACTS_COMMAND` printed.
A section the command did not print -- the file was missing or unreadable --
comes back ``None``, never empty.
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(_report(output))
def names(key: str) -> Optional[List[str]]:
if key not in sections:
return None
return sorted({module_name(line) for line in sections[key]})
release = sections.get("release") or [""]
return {
"release": release[0],
"loaded": names("loaded"),
"builtin": names("builtin"),
"available": names("available"),
"config": _config(sections["config"]) if "config" in sections else None,
}
class KernelFactsMixin:
"""Adds :meth:`get_kernel_facts` to a driver that can run a command on a Linux host.
The template form (README, "Function classes"): the reading and its parse are
the same everywhere, so they are concrete here, and a driver supplies only
:meth:`_run_kernel_facts_command` -- how a command reaches its host. Mixed in
by the drivers that can, not by :class:`~napalm_device_types.os.OSDriver`:
a Windows host is an OS driver too and has no Linux kernel to read, and
``hasattr(driver, "get_kernel_facts")`` has to stay a truthful answer.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_kernel_facts_command(self, command: str) -> str:
"""Run *command* on the host with ``sh`` and return what it printed."""
...
def get_kernel_facts(self) -> KernelFactsDict:
"""
Returns what the running kernel has built and loaded.
* release (string) - ``uname -r``
* loaded (list or None) - loaded modules, from ``/proc/modules``
* builtin (list or None) - modules compiled into the kernel image
* available (list or None) - modules shipped for this kernel
* config (dict or None) - the build configuration's set options
``None`` means the source could not be read.
Example::
{
"release": "6.1.0-25-amd64",
"loaded": ["nf_tables", "tipc"],
"builtin": ["tcp_cubic"],
"available": ["can_raw", "nf_tables", "tipc"],
"config": {"CONFIG_TIPC": "m", "CONFIG_HZ": "250"},
}
:raises ValueError: if the host's output carried no intact report.
"""
return parse_kernel_facts(self._run_kernel_facts_command(KERNEL_FACTS_COMMAND))
+38 -1
View File
@@ -60,11 +60,30 @@ class ServiceDict(TypedDict):
class UpdateDict(TypedDict):
"""A software package that has a newer version available in the package repository."""
"""A software package that has a newer version available in the package repository.
``origin`` and ``security`` are optional: a reader that cannot tell leaves
them out, and netOrk treats a missing ``security`` as unknown.
"""
name: str
current_version: str
new_version: str
#: Where the new version comes from, e.g. apt's suites "noble-updates,noble-security".
origin: NotRequired[Optional[str]]
#: True for a security update, False for a known other one, None when unknown.
security: NotRequired[Optional[bool]]
class HostStatusDict(TypedDict):
"""What a host says about its own patch state (``HostStatusMixin.get_host_status``)."""
#: True when the host needs a reboot to finish an update, None when it cannot tell.
reboot_required: Optional[bool]
#: Why, e.g. "kernel 6.8.0-142-generic installed, 6.8.0-139-generic running".
reboot_reason: Optional[str]
#: True when the host installs updates on its own (unattended-upgrades, dnf-automatic).
auto_updates: Optional[bool]
# ---------------------------------------------------------------------------
@@ -298,6 +317,24 @@ class NATTranslationDict(TypedDict):
age: float
class KernelFactsDict(TypedDict):
"""What the running kernel has built and loaded (``KernelFactsMixin.get_kernel_facts``).
``None`` means *could not be read*; an empty list means *read, and there is
nothing*. The difference is what lets a consumer say "this module cannot be
loaded on this kernel" rather than "we did not look".
Module names are normalised by :func:`napalm_device_types.kernel.module_name`:
no path, no ``.ko`` suffix, ``-`` folded to ``_``.
"""
release: str # uname -r
loaded: Optional[List[str]] # /proc/modules
builtin: Optional[List[str]] # modules.builtin -- compiled into the kernel image
available: Optional[List[str]] # modules.dep -- shipped as loadable modules
config: Optional[Dict[str, str]] # build configuration, set options only; quotes stripped
class PortForwardDict(TypedDict):
"""A port the WAN side can reach, forwarded to a host inside.
+111
View File
@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""Pending package updates: which package, from where, and whether it is a security fix.
A patch deadline -- "security updates within 14 days" -- needs to know which
pending update is a security update. apt says so in the suite a candidate comes
from (``noble-security``, ``stable-security``), dnf in its update advisories.
Reading that is the same for every driver whose host runs apt or dnf, so the
parsers live here once and a driver only carries the command across.
apt: the suites a candidate comes from are its ``origin``; any suite ending in
``-security`` makes it a security update. A security fix that a later
``-updates`` build superseded shows only ``-updates`` and counts as not
security -- netOrk's CVE matching is what catches those.
"""
from __future__ import annotations
import re
from typing import Dict, List, Set
from napalm_device_types.models import UpdateDict
from napalm_device_types.terminal import strip_terminal_codes
#: Read-only, no root needed, in a fixed language so the parse holds, and
#: through a pipe: without a terminal apt draws no progress and no terminal
#: codes, one of which (``ESC >``) a screen-scraping transport took for a shell
#: prompt and stopped reading at. Its exit status is printed inside the group,
#: so it is apt's, not cat's.
APT_UPGRADABLE_COMMAND = "{ LC_ALL=C apt list --upgradable 2>/dev/null; echo __APT_RC=$?; } | cat"
#: Read-only. Lists the packages that a pending security advisory covers.
DNF_SECURITY_COMMAND = "LC_ALL=C dnf updateinfo list --security --quiet 2>/dev/null"
# openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
_APT_LINE = re.compile(r"^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from:\s+(\S+)\]")
_SECURITY_SUITE = "-security"
_APT_STATUS = re.compile(r"^__APT_RC=(\d+)\s*$", re.MULTILINE)
def _joined_lines(output: str) -> List[str]:
"""Lines as apt printed them: a terminal wraps long ones, and the
continuation starts with a space."""
lines: List[str] = []
for line in output.splitlines():
if line.startswith(" ") and lines:
lines[-1] += line.strip()
else:
lines.append(line)
return lines
def _apt_listing(output: str) -> str:
"""The listing without its exit status, or ``ValueError`` when apt failed or
the output was cut short -- "could not read" must never look like "nothing
pending"."""
text = strip_terminal_codes(output)
statuses = _APT_STATUS.findall(text)
if not statuses:
raise ValueError("apt list --upgradable reported no exit status; the output was cut short")
if statuses[-1] != "0":
raise ValueError(f"apt list --upgradable failed with exit status {statuses[-1]}")
return _APT_STATUS.sub("", text)
def parse_apt_upgradable(output: str) -> List[UpdateDict]:
"""Parse :data:`APT_UPGRADABLE_COMMAND`'s output, one entry per package.
A package listed for several architectures (``libc6`` for amd64 and i386)
is one entry; it counts as a security update if any of its lines does.
:raises ValueError: when apt failed or its exit status never arrived.
"""
by_name: Dict[str, UpdateDict] = {}
for line in _joined_lines(_apt_listing(output)):
match = _APT_LINE.match(line)
if not match:
continue
name, listed, new_version, current_version = match.groups()
suites = list(dict.fromkeys(listed.split(","))) # apt may list a suite twice
security = any(suite.endswith(_SECURITY_SUITE) for suite in suites)
seen = by_name.get(name)
if seen is not None:
seen["security"] = bool(seen.get("security")) or security
continue
by_name[name] = {
"name": name,
"current_version": current_version,
"new_version": new_version,
"origin": ",".join(suites),
"security": security,
}
return list(by_name.values())
def nevra_name(nevra: str) -> str:
"""The package name of an RPM ``name-[epoch:]version-release.arch``."""
without_arch = nevra.rsplit(".", 1)[0]
return without_arch.rsplit("-", 2)[0]
def parse_dnf_security(output: str) -> Set[str]:
"""The names of the packages a pending security advisory covers.
Parses :data:`DNF_SECURITY_COMMAND`'s ``ADVISORY SEVERITY/Sec. NEVRA`` lines.
"""
names: Set[str] = set()
for line in output.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[1].endswith("/Sec."):
names.add(nevra_name(parts[-1]))
return names
+329
View File
@@ -0,0 +1,329 @@
# -*- coding: utf-8 -*-
"""systemd services: listing them in one round trip, and starting and stopping them.
What systemd reports about its services, and how one is started or stopped, is
the same on every host that runs it. So the command, its parse, the check of a
unit name and the reading of an action's exit status live here once, and a
driver only carries a command across: SSH, an API's exec endpoint, whatever it
has.
**Listing.** One command prints the installed unit files and, for every loaded
service unit, what ``systemctl show`` knows about it -- state, boot state and
main PID together, instead of asking ``systemctl is-enabled`` and ``systemctl
show`` once per unit (two hundred round trips on an ordinary Linux host). The
report is framed, and a report whose end is missing raises: a list cut short
must never read as services that went away.
**What counts as enabled.** A unit file state of ``enabled`` or
``enabled-runtime``. ``static`` does not: such a unit starts only when
something else pulls it in, and calling it enabled made every one of them look
like a service of the host. The state is read from ``UnitFileState``, never
from a column of ``list-unit-files``, whose second column has been followed by
a preset column since systemd 245.
**Starting and stopping.** ``systemctl`` runs bounded by ``timeout`` and never
asks for a password, and its exit status is printed after it. The marker also
keeps the output from ever being empty, which a transport that retries on an
empty answer would otherwise take as a reason to run the action twice.
"""
from __future__ import annotations
import re
from shlex import quote
from typing import Any, Dict, List, Set, Tuple, TYPE_CHECKING
from napalm_device_types.models import ServiceDict
from napalm_device_types.services import ServiceControlMixin
from napalm_device_types.terminal import strip_terminal_codes
_BEGIN = "SVC_BEGIN"
_END = "SVC_END"
_NO_SYSTEMD = "no-systemd"
_SUFFIX = ".service"
#: What ``systemctl show`` prints per unit. It prints them in its own order.
_PROPERTIES = "Id,Names,LoadState,ActiveState,SubState,UnitFileState,MainPID"
#: Picks the units whose file state is ``generated`` out of ``systemctl show``'s
#: output, whatever order it prints the properties in.
_GENERATED_AWK = (
'awk -F= \'NF<2{id="";g=0;next} $1=="Id"{id=$2} '
'$1=="UnitFileState"{g=($2=="generated")} id!=""&&g{print id;id="";g=0}\''
)
#: One line, POSIX ``sh``, read-only. The frame markers are printed in two
#: halves so that a transport which echoes the command does not show them early.
#: ``xargs -0`` passes escaped names such as ``foo\x2dbar.service`` unchanged.
#: A generated unit -- the wrapper systemd makes for a SysV script -- has no unit
#: file whose state says whether it starts at boot; ``systemctl is-enabled``
#: asks the script's rc links instead, for those few units only.
SYSTEMD_SERVICES_COMMAND = (
"printf '%s%s\\n' SVC_ BEGIN; "
"[ -d /run/systemd/system ] || echo '[no-systemd]'; "
"echo '[files]'; systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null; "
"echo '[units]'; s=$(systemctl list-units --type=service --all --no-legend --no-pager --plain "
"2>/dev/null | awk '{print $1}' | tr '\\n' '\\0' | xargs -0 -r systemctl show --no-pager "
f"-p {_PROPERTIES} -- 2>/dev/null); printf '%s\\n' \"$s\"; "
f"echo '[generated]'; printf '%s\\n' \"$s\" | {_GENERATED_AWK} | while read -r u; do "
'printf \'%s %s\\n\' "$u" "$(systemctl is-enabled -- "$u" 2>/dev/null)"; done; '
"printf '%s%s\\n' SVC_ END"
)
#: The lifecycle actions :meth:`SystemdServicesMixin.manage_service` accepts.
SERVICE_ACTIONS = ("start", "stop", "restart", "enable", "disable")
#: Seconds an action may run on the host before ``timeout`` stops waiting for
#: it. systemd itself carries on with the job.
ACTION_TIMEOUT = 45
#: What a transport should allow for one command: the action's own bound plus
#: the round trip around it.
_TRANSPORT_TIMEOUT = ACTION_TIMEOUT + 15
_TIMED_OUT = 124 # timeout(1)'s exit status when the time ran out
_RC_MARKER = "__SVC_RC="
_RC_RE = re.compile(rf"^{_RC_MARKER}(\d+)\s*$", re.MULTILINE)
#: The characters systemd allows in a unit name, with ``\xHH`` for any other byte.
_UNIT_RE = re.compile(r"(?:[A-Za-z0-9_.:@-]|\\x[0-9A-Fa-f]{2})+")
_MAX_UNIT_LENGTH = 255
_ENABLED = frozenset({"enabled", "enabled-runtime"})
#: Unit file states of a service that is installed but need not be loaded.
_INSTALLED = frozenset({"enabled", "enabled-runtime", "disabled", "indirect"})
class SystemdUnavailable(NotImplementedError):
"""The host does not run systemd; a driver may fall back to another init system."""
def unit_name(name: str) -> str:
"""*name* as a service unit's name without ``.service``, or ``ValueError``.
Accepts template instances (``wg-quick@wg0``), dots (``snapd.apparmor``),
colons and systemd's ``\\xHH`` escapes. Refuses a bare template
(``getty@``), a leading ``-`` that a command would read as an option, and
anything a shell would read.
"""
base = name[: -len(_SUFFIX)] if name.endswith(_SUFFIX) else name
if (
not _UNIT_RE.fullmatch(base)
or base.startswith("-")
or base.endswith("@")
or len(base) + len(_SUFFIX) > _MAX_UNIT_LENGTH
):
raise ValueError(f"Invalid service name: {name!r}")
return base
def service_action_command(name: str, action: str) -> str:
"""The shell command that applies *action* to the service *name*.
:raises ValueError: for an unknown action or an invalid name.
"""
if action not in SERVICE_ACTIONS:
raise ValueError(f"Invalid action {action!r}; use one of {', '.join(SERVICE_ACTIONS)}")
unit = quote(unit_name(name) + _SUFFIX)
return (
f"timeout {ACTION_TIMEOUT} systemctl --no-ask-password {action} -- {unit} 2>&1; "
f"echo {_RC_MARKER}$?"
)
def parse_action_result(output: str) -> Dict[str, Any]:
"""``{"success", "output"}`` from what :func:`service_action_command` printed.
Only the exit status decides. A job still running when ``timeout`` gave up
is not reported as done, and output without a status is no success.
"""
output = strip_terminal_codes(output)
statuses = _RC_RE.findall(output)
text = _RC_RE.sub("", output).strip()
if not statuses:
return {"success": False, "output": text or "No exit status came back from the host."}
status = int(statuses[-1])
if status == 0:
return {"success": True, "output": text}
if status == _TIMED_OUT:
note = f"Still running after {ACTION_TIMEOUT} s; systemd carries on with the job."
return {"success": False, "output": f"{text}\n{note}".strip()}
return {"success": False, "output": text or f"systemctl exited with status {status}."}
def _frame(output: str) -> List[str]:
lines = [line.strip() for line in strip_terminal_codes(output).splitlines()]
try:
start = lines.index(_BEGIN)
end = lines.index(_END, start)
except ValueError:
raise ValueError("no intact systemd service report in the output") from None
return lines[start + 1 : end]
def _sections(lines: List[str]) -> Dict[str, List[str]]:
sections: Dict[str, List[str]] = {}
current: List[str] = []
for line in lines:
if line.startswith("[") and line.endswith("]"):
current = sections.setdefault(line[1:-1], [])
else:
current.append(line)
return sections
def _unit_blocks(lines: List[str]) -> List[Dict[str, str]]:
"""``systemctl show``'s output, one dict per unit.
Units are separated by a blank line -- except where ``xargs`` split the
list over two runs and the blocks meet, so a key seen twice starts the next
unit as well.
"""
blocks: List[Dict[str, str]] = []
current: Dict[str, str] = {}
for line in lines:
key, sep, value = line.partition("=")
if not sep or key in current:
if current:
blocks.append(current)
current = {}
if sep:
current[key] = value
if current:
blocks.append(current)
return blocks
def _base(unit: str) -> str:
return unit[: -len(_SUFFIX)]
def _main_pid(block: Dict[str, str]) -> int:
try:
return int(block.get("MainPID") or 0)
except ValueError:
return 0
def _loaded(blocks: List[Dict[str, str]]) -> Tuple[Dict[str, ServiceDict], Set[str]]:
"""The loaded services, and every name they go by (aliases included)."""
services: Dict[str, ServiceDict] = {}
names: Set[str] = set()
for block in blocks:
unit = block.get("Id", "")
if not unit.endswith(_SUFFIX) or block.get("LoadState") == "not-found":
continue
names.update(block.get("Names", unit).split())
running = block.get("ActiveState") == "active" and block.get("SubState") == "running"
services[_base(unit)] = {
"name": _base(unit),
"running": running,
"enabled": block.get("UnitFileState") in _ENABLED,
"pid": _main_pid(block) if running else 0,
}
return services, names
def _installed(lines: List[str], known: Set[str]) -> Dict[str, ServiceDict]:
"""Installed services that are not loaded: neither running nor starting now.
Templates, static units and aliases are left out -- the last also when an
older systemd lists an alias as ``enabled``, which is why every name a
loaded unit goes by is skipped.
"""
services: Dict[str, ServiceDict] = {}
for line in lines:
parts = line.split()
if len(parts) < 2:
continue
unit, state = parts[0], parts[1]
if (
not unit.endswith(_SUFFIX)
or unit.endswith("@" + _SUFFIX)
or unit in known
or state not in _INSTALLED
):
continue
services[_base(unit)] = {
"name": _base(unit),
"running": False,
"enabled": state in _ENABLED,
"pid": 0,
}
return services
def _apply_generated(services: Dict[str, ServiceDict], lines: List[str]) -> None:
"""Take a generated unit's boot state from ``is-enabled``'s answer."""
for line in lines:
parts = line.split()
if len(parts) == 2 and parts[0].endswith(_SUFFIX) and _base(parts[0]) in services:
services[_base(parts[0])]["enabled"] = parts[1] in _ENABLED
def parse_systemd_services(output: str) -> List[ServiceDict]:
"""Parse what :data:`SYSTEMD_SERVICES_COMMAND` printed, sorted by name.
Lists every loaded service unit but those that are not found, and every
installed one that is not loaded.
:raises SystemdUnavailable: when the host does not run systemd.
:raises ValueError: when the output carries no intact report.
"""
sections = _sections(_frame(output))
if _NO_SYSTEMD in sections:
raise SystemdUnavailable("the host does not run systemd")
loaded, known = _loaded(_unit_blocks(sections.get("units", [])))
_apply_generated(loaded, sections.get("generated", []))
merged = {**_installed(sections.get("files", []), known), **loaded}
return [merged[name] for name in sorted(merged)]
class SystemdServicesMixin(ServiceControlMixin):
"""Implements :class:`ServiceControlMixin` for a driver whose host runs systemd.
The template form (README, "Function classes"): the command, the parse,
the check of the name and the reading of the exit status are the same
everywhere, so they are concrete here, and a driver supplies only
:meth:`_run_service_command` -- how a command reaches its host, and how it
gains root there when it needs to.
"""
if TYPE_CHECKING: # pragma: no cover - declared for type checkers only
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
"""Run *command* with ``sh`` on the host and return what it printed.
*privileged* commands change the system and need root; *timeout*
is how long the transport should wait for the output, in seconds.
"""
...
def get_services(self) -> List[ServiceDict]:
"""
Returns the services systemd knows, in one round trip.
* name (string) - the unit name without ``.service``
* running (bool) - active and running
* enabled (bool) - the unit file is enabled
* pid (int) - the main process; 0 when not running
:raises SystemdUnavailable: if the host does not run systemd.
:raises ValueError: if the host's output carried no intact report.
"""
output = self._run_service_command(
SYSTEMD_SERVICES_COMMAND, privileged=False, timeout=_TRANSPORT_TIMEOUT
)
return parse_systemd_services(output)
def manage_service(self, name: str, action: str) -> Dict[str, Any]:
"""
Applies *action* (start, stop, restart, enable, disable) to the service *name*.
:returns: ``{"success": bool, "output": str}``
:raises ValueError: for an unknown action or an invalid name, before
anything is sent.
"""
command = service_action_command(name, action)
output = self._run_service_command(command, privileged=True, timeout=_TRANSPORT_TIMEOUT)
return parse_action_result(output)
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""What a pseudo-terminal adds to a command's output, taken out again.
A screen-scraping transport (netmiko) gives the remote command a terminal. Tools
then colour their output and draw progress: systemctl colours its errors, apt
switches the keypad mode with ``ESC =`` / ``ESC >``. Every parser in this package
reads the text without them.
"""
from __future__ import annotations
import re
#: CSI sequences (colours, cursor), OSC sequences (window titles) and the
#: two-character escapes (``ESC =``, ``ESC >``, ``ESC (B``).
_TERMINAL_CODES = re.compile(
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|\([0-9A-Za-z]|[=>78DEHMNOc])"
)
def strip_terminal_codes(text: str) -> str:
"""*text* without terminal escape sequences."""
return _TERMINAL_CODES.sub("", text)
+19 -1
View File
@@ -13,7 +13,7 @@ this class in can never shadow a working implementation from a sibling base.
from __future__ import annotations
from typing import List, TYPE_CHECKING
from typing import Any, Dict, List, TYPE_CHECKING
from napalm_device_types.models import ApplyUpdatesResultDict, UpdateDict
@@ -30,6 +30,14 @@ class UpdateMixin:
* name (string) - package name
* current_version (string) - currently installed version
* new_version (string) - version available in the repository
* origin (string, optional) - where it comes from, e.g. apt's suites
* security (bool or None, optional) - a security update; leave it
out or None when the source does not say
**An empty list means nothing is pending.** A reader that cannot
read -- no package index yet, an API that did not answer -- raises
instead: netOrk keeps "pending since" per package, and an empty
list for "don't know" would reset every one of those clocks.
Example::
@@ -43,6 +51,16 @@ class UpdateMixin:
"""
...
def refresh_available_updates(self) -> Dict[str, Any]:
"""
Refreshes the host's package index, so that :meth:`get_available_updates`
reports what the repositories offer now (``apt-get update``,
``dnf makecache``, ``opkg update``, a firmware check). Installs nothing.
:returns: ``{"success": bool, "output": str}``
"""
...
def apply_updates(self, packages: List[str]) -> ApplyUpdatesResultDict:
"""
Upgrades the given packages to the newest available version.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "2.0.0"
version = "2.3.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+200
View File
@@ -0,0 +1,200 @@
"""Host status: does the host need a reboot, and does it patch itself?
A patch run that installed a new kernel has not closed anything until the host
boots it, so "reboot required" is part of being patched. Whether the host
installs updates on its own (unattended-upgrades, dnf-automatic) is what netOrk
shows next to the window it governs. Both are read the same way on every Linux
host, so the command and its parse live here once (netOrk MVP 5).
The fixtures are the real states of six hosts on netOrk's test server.
"""
from __future__ import annotations
import pytest
from napalm_device_types import OSDriver
from napalm_device_types.host_status import (
HOST_STATUS_COMMAND,
HostStatusMixin,
kernel_reboot_pending,
parse_host_status,
)
def _wire(
*,
reboot_file=False,
running="6.8.0-142-generic",
modules=("6.8.0-139-generic", "6.8.0-142-generic"),
needs_restarting=None,
periodic=None,
timer="enabled",
dnf_timers=("not-found", "not-found"),
):
lines = ["HSTAT_BEGIN"]
if reboot_file:
lines.append("[reboot-required]")
if needs_restarting is not None:
lines += ["[needs-restarting]", str(needs_restarting)]
lines += ["[kernel]", running, "[modules]", *modules]
if periodic is not None:
lines += ["[apt-config]", *periodic]
lines += [
"[timers]",
f"apt-daily-upgrade.timer {timer}",
f"dnf-automatic.timer {dnf_timers[0]}",
f"dnf-automatic-install.timer {dnf_timers[1]}",
"HSTAT_END",
]
return "\n".join(lines) + "\n"
UNATTENDED = ['APT::Periodic::Update-Package-Lists "1";', 'APT::Periodic::Unattended-Upgrade "1";']
class TestRebootRequired:
def test_the_reboot_required_file_says_so(self):
status = parse_host_status(_wire(reboot_file=True))
assert status["reboot_required"] is True
assert "reboot-required" in status["reboot_reason"]
def test_a_newer_installed_kernel_than_the_running_one(self):
"""z2m-garden: running 6.8.0-139, 6.8.0-142 installed."""
status = parse_host_status(
_wire(
running="6.8.0-139-generic",
modules=("6.8.0-87-generic", "6.8.0-139-generic", "6.8.0-142-generic"),
)
)
assert status["reboot_required"] is True
assert "6.8.0-142-generic" in status["reboot_reason"]
def test_the_newest_kernel_running_needs_none(self):
"""vault-01: 6.8.0-142 running and newest; 6.8.0-94 sorts below it."""
status = parse_host_status(
_wire(running="6.8.0-142-generic", modules=("6.8.0-94-generic", "6.8.0-142-generic"))
)
assert status["reboot_required"] is False
assert status["reboot_reason"] is None
def test_needs_restarting_exit_1_means_reboot(self):
assert parse_host_status(_wire(needs_restarting=1))["reboot_required"] is True
def test_needs_restarting_exit_0_does_not(self):
assert parse_host_status(_wire(needs_restarting=0))["reboot_required"] is False
def test_no_kernel_information_is_unknown(self):
"""A container has no /lib/modules of its own."""
status = parse_host_status(_wire(modules=()))
assert status["reboot_required"] is None
class TestKernelRebootPending:
@pytest.mark.parametrize(
("running", "installed", "newer"),
[
# Raspberry Pi: two flavours side by side; only the running one counts.
(
"6.18.33+rpt-rpi-v8",
[
"6.12.75+rpt-rpi-2712",
"6.12.75+rpt-rpi-v8",
"6.18.33+rpt-rpi-2712",
"6.18.33+rpt-rpi-v8",
],
None,
),
# Debian (OMV): 7.1.8 installed while 7.1.3 runs.
(
"7.1.3+deb13-amd64",
["6.12.57+deb13-amd64", "7.1.3+deb13-amd64", "7.1.8+deb13-amd64"],
"7.1.8+deb13-amd64",
),
# Proxmox: 7.0.14-19 is newer than 7.0.2-6, numerically.
("7.0.14-19-pve", ["7.0.14-19-pve", "7.0.2-6-pve"], None),
# Arch: the running kernel's modules were replaced by the upgrade.
("6.10.5-arch1-1", ["6.10.9-arch1-1"], "6.10.9-arch1-1"),
],
)
def test_the_newer_kernel_of_the_running_flavour(self, running, installed, newer):
assert kernel_reboot_pending(running, installed) == newer
class TestAutoUpdates:
def test_unattended_upgrades_switched_on(self):
assert parse_host_status(_wire(periodic=UNATTENDED))["auto_updates"] is True
def test_apt_without_the_setting_does_not_patch_itself(self):
"""Proxmox and Raspberry Pi OS: apt-config answers, the setting is absent."""
assert parse_host_status(_wire(periodic=[]))["auto_updates"] is False
def test_switched_off_by_zero(self):
off = ['APT::Periodic::Unattended-Upgrade "0";']
assert parse_host_status(_wire(periodic=off))["auto_updates"] is False
def test_a_disabled_timer_stops_it_even_when_configured(self):
status = parse_host_status(_wire(periodic=UNATTENDED, timer="disabled"))
assert status["auto_updates"] is False
def test_dnf_automatic(self):
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found")))
assert status["auto_updates"] is True
def test_neither_apt_nor_dnf_is_unknown(self):
assert parse_host_status(_wire())["auto_updates"] is None
class TestTheReport:
def test_a_cut_short_report_raises(self):
with pytest.raises(ValueError):
parse_host_status(_wire().replace("HSTAT_END\n", ""))
def test_the_frame_is_not_in_the_command_itself(self):
assert "HSTAT_BEGIN" not in HOST_STATUS_COMMAND
assert "HSTAT_END" not in HOST_STATUS_COMMAND
def test_it_runs_without_a_terminal(self):
"""No colour codes from ls, nothing a screen scraper could take for a prompt."""
assert HOST_STATUS_COMMAND.rstrip().endswith("| cat")
def test_terminal_codes_are_dropped(self):
status = parse_host_status(
"\x1b[0m" + _wire(reboot_file=True).replace("HSTAT_END", "\x1b>HSTAT_END")
)
assert status["reboot_required"] is True
def test_it_changes_nothing(self):
for word in ("rm ", "apt-get ", "dnf install", "systemctl start", "reboot"):
assert word not in HOST_STATUS_COMMAND.replace("reboot-required", "")
class _Driver(HostStatusMixin):
def __init__(self, reply: str) -> None:
self.reply = reply
self.commands: list = []
def _run_host_status_command(self, command: str) -> str:
self.commands.append(command)
return self.reply
class TestHostStatusMixin:
def test_a_driver_supplies_only_the_transport(self):
driver = _Driver(_wire(reboot_file=True, periodic=UNATTENDED))
status = driver.get_host_status()
assert driver.commands == [HOST_STATUS_COMMAND]
assert (status["reboot_required"], status["auto_updates"]) == (True, True)
def test_not_every_os_driver_has_it(self):
assert not hasattr(OSDriver, "get_host_status")
+151
View File
@@ -0,0 +1,151 @@
"""get_kernel_facts: what the running kernel has built and loaded.
A kernel CVE's preconditions ask whether a module is loaded or a build option
set. Reading that is the same on every Linux host -- one read-only command and
its parse -- so both live here once, and a driver only carries the command
across (#268 in netOrk).
"""
from __future__ import annotations
import base64
import gzip
import os
import subprocess
import pytest
from napalm_device_types import OSDriver
from napalm_device_types.kernel import (
KERNEL_FACTS_COMMAND,
KernelFactsMixin,
module_name,
parse_kernel_facts,
)
REPORT = """[release]
6.1.0-25-amd64
[loaded]
tipc
nf_tables
[builtin]
kernel/net/ipv4/tcp_cubic.ko
kernel/drivers/char/tpm/tpm-tis.ko
[available]
kernel/net/tipc/tipc.ko.xz
kernel/net/can/can-raw.ko.zst
kernel/net/netfilter/nf_tables.ko
[config]
CONFIG_TIPC=m
CONFIG_BPF_JIT=y
CONFIG_DEFAULT_HOSTNAME="(none)"
CONFIG_HZ=250
"""
def _wire(report: str, *, noise: str = "") -> str:
"""The report as the command prints it: framed, gzipped, base64 in lines."""
payload = base64.encodebytes(gzip.compress(report.encode())).decode()
return f"{noise}KFACTS_BEGIN\n{payload}KFACTS_END\n"
class TestParsing:
def test_every_section_is_read(self):
facts = parse_kernel_facts(_wire(REPORT))
assert facts["release"] == "6.1.0-25-amd64"
assert facts["loaded"] == ["nf_tables", "tipc"]
assert facts["builtin"] == ["tcp_cubic", "tpm_tis"]
assert facts["available"] == ["can_raw", "nf_tables", "tipc"]
assert facts["config"] == {
"CONFIG_TIPC": "m",
"CONFIG_BPF_JIT": "y",
"CONFIG_DEFAULT_HOSTNAME": "(none)",
"CONFIG_HZ": "250",
}
def test_a_section_never_printed_is_none_not_empty(self):
"""``None`` is "could not read"; an empty list would claim "read it,
and there is nothing" -- and that is what turns a module into
``not_met`` downstream."""
facts = parse_kernel_facts(_wire("[release]\n6.1.0\n[loaded]\n"))
assert facts["loaded"] == []
assert facts["builtin"] is None
assert facts["available"] is None
assert facts["config"] is None
def test_whatever_surrounds_the_frame_is_ignored(self):
"""A screen-scraping transport may echo the command or a banner."""
noise = "Last login: today\nprintf '%s%s\\n' KFACTS_ BEGIN; ...\n"
assert parse_kernel_facts(_wire(REPORT, noise=noise))["release"] == "6.1.0-25-amd64"
def test_output_without_the_frame_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("sh: gzip: not found\n")
def test_a_damaged_payload_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
class TestModuleNames:
@pytest.mark.parametrize(
"raw, name",
[
("tipc", "tipc"),
("kernel/net/tipc/tipc.ko", "tipc"),
("kernel/net/tipc/tipc.ko.zst", "tipc"),
("kernel/net/can/can-raw.ko.xz", "can_raw"),
("CAN-RAW", "can_raw"),
(" nf_tables ", "nf_tables"),
],
)
def test_dash_and_underscore_are_one_name(self, raw, name):
"""The kernel treats ``-`` and ``_`` in module names as the same."""
assert module_name(raw) == name
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
"""An echoing transport prints the command back; the markers must only
appear once the command has run."""
assert "KFACTS_BEGIN" not in KERNEL_FACTS_COMMAND
assert "KFACTS_END" not in KERNEL_FACTS_COMMAND
def test_it_writes_nothing(self):
for verb in ("modprobe", "insmod", "rmmod", "sudo", " > ", ">>"):
assert verb not in KERNEL_FACTS_COMMAND
@pytest.mark.skipif(os.uname().sysname != "Linux", reason="reads a Linux kernel")
def test_it_runs_and_parses_on_this_host(self):
out = subprocess.run(
["sh", "-c", KERNEL_FACTS_COMMAND], capture_output=True, text=True, timeout=60
).stdout
facts = parse_kernel_facts(out)
assert facts["release"] == os.uname().release
assert facts["loaded"] is None or all(isinstance(m, str) for m in facts["loaded"])
class TestTheTemplate:
def test_a_driver_supplies_only_the_transport(self):
class Driver(KernelFactsMixin):
def _run_kernel_facts_command(self, command: str) -> str:
self.sent = command
return _wire(REPORT)
driver = Driver()
facts = driver.get_kernel_facts()
assert driver.sent == KERNEL_FACTS_COMMAND
assert facts["release"] == "6.1.0-25-amd64"
def test_not_every_os_driver_has_it(self):
"""A Windows host is an OSDriver too, and has no Linux kernel to read:
``hasattr`` has to stay a truthful answer, so the drivers that can mix
this in themselves."""
assert not issubclass(OSDriver, KernelFactsMixin)
assert not hasattr(OSDriver, "get_kernel_facts")
+149
View File
@@ -0,0 +1,149 @@
"""Pending updates: which package, from where, and whether it closes a security hole.
A patch deadline ("security updates within 14 days") needs to know which pending
update is a security update. apt says so in the suite a candidate comes from
(``noble-security``, ``stable-security``); dnf says so in its update advisories.
Reading that is the same for every driver whose host runs apt or dnf, so the
parsers live here once (netOrk MVP 5, #556).
Fixture lines are from real hosts (Ubuntu 24.04, Debian 13 / OMV, Proxmox VE 9).
"""
from __future__ import annotations
import pytest
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
nevra_name,
parse_apt_upgradable,
parse_dnf_security,
)
UBUNTU = """\
docker-compose-plugin/noble 5.6.0-1~ubuntu.24.04~noble amd64 [upgradable from: 5.5.1-1~ubuntu.24.04~noble]
openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]
__APT_RC=0
"""
DEBIAN = """\
linux-image-amd64/stable-backports 7.1.13-1~bpo13+1 amd64 [upgradable from: 7.1.8-1~bpo13+1]
libssl3t64/stable-security 3.5.1-1+deb13u2 amd64 [upgradable from: 3.5.1-1+deb13u1]
__APT_RC=0
"""
def _by_name(updates):
return {u["name"]: u for u in updates}
class TestAptUpgradable:
def test_each_line_is_a_package_with_both_versions(self):
update = _by_name(parse_apt_upgradable(UBUNTU))["docker-compose-plugin"]
assert update["current_version"] == "5.5.1-1~ubuntu.24.04~noble"
assert update["new_version"] == "5.6.0-1~ubuntu.24.04~noble"
def test_the_suites_are_its_origin(self):
updates = _by_name(parse_apt_upgradable(UBUNTU))
assert updates["openssl"]["origin"] == "noble-updates,noble-security"
assert updates["docker-compose-plugin"]["origin"] == "noble"
def test_a_suite_apt_lists_twice_is_named_once(self):
line = (
"fonts-opensymbol/noble-updates,noble-updates,noble-security,noble-security "
"4:102.12+LibO24.2.7-0ubuntu0.24.04.7 all [upgradable from: 4:102.12+LibO24.2.7-0ubuntu0.24.04.6]\n"
"__APT_RC=0\n"
)
assert parse_apt_upgradable(line)[0]["origin"] == "noble-updates,noble-security"
@pytest.mark.parametrize(
("output", "name", "security"),
[
(UBUNTU, "openssl", True),
(UBUNTU, "docker-compose-plugin", False),
(DEBIAN, "libssl3t64", True),
(DEBIAN, "linux-image-amd64", False),
],
)
def test_a_security_suite_makes_it_a_security_update(self, output, name, security):
assert _by_name(parse_apt_upgradable(output))[name]["security"] is security
def test_a_line_the_terminal_wrapped_is_joined(self):
wrapped = (
"openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable fro\n"
" m: 3.0.13-0ubuntu3.5]\n__APT_RC=0\n"
)
assert _by_name(parse_apt_upgradable(wrapped))["openssl"]["current_version"] == (
"3.0.13-0ubuntu3.5"
)
def test_one_package_for_several_architectures_is_one_entry(self):
multiarch = (
"libc6/noble-updates 2.39-0ubuntu8.5 amd64 [upgradable from: 2.39-0ubuntu8.4]\n"
"libc6/noble-updates,noble-security 2.39-0ubuntu8.5 i386 [upgradable from: 2.39-0ubuntu8.4]\n"
"__APT_RC=0\n"
)
updates = parse_apt_upgradable(multiarch)
assert [u["name"] for u in updates] == ["libc6"]
assert updates[0]["security"] is True
def test_noise_is_ignored(self):
noisy = "Listing... Done\nWARNING: apt does not have a stable CLI interface.\n" + UBUNTU
assert len(parse_apt_upgradable(noisy)) == 2
def test_nothing_pending_is_an_empty_list(self):
assert parse_apt_upgradable("__APT_RC=0\n") == []
def test_a_list_without_its_exit_status_raises(self):
"""Cut short: a transport stopped reading early, so nothing can be concluded."""
with pytest.raises(ValueError):
parse_apt_upgradable(UBUNTU.replace("__APT_RC=0\n", ""))
def test_a_failed_apt_raises(self):
with pytest.raises(ValueError, match="100"):
parse_apt_upgradable("E: Could not get lock\n__APT_RC=100\n")
def test_terminal_codes_around_the_status_are_dropped(self):
"""What a pseudo-terminal left on a Raspberry Pi OS host."""
raw = "Listing... 0%\n\x1b[?1h\x1b=\n" + UBUNTU.replace("__APT_RC=0", "\x1b>__APT_RC=0")
assert len(parse_apt_upgradable(raw)) == 2
def test_the_command_reads_without_root_or_a_terminal(self):
"""Through a pipe apt draws no progress and no terminal codes; one of
those, ESC >, ended a screen-scraping read at a false prompt."""
assert "LC_ALL=C apt list --upgradable" in APT_UPGRADABLE_COMMAND
assert APT_UPGRADABLE_COMMAND.rstrip().endswith("| cat")
assert "sudo" not in APT_UPGRADABLE_COMMAND
class TestDnfSecurity:
ADVISORIES = """\
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-1:3.1.4-2.fc40.x86_64
RLSA-2024:1234 Moderate/Sec. kernel-core-5.14.0-427.13.1.el9_4.x86_64
"""
def test_the_names_of_packages_with_a_security_advisory(self):
assert parse_dnf_security(self.ADVISORIES) == {"openssl-libs", "openssl", "kernel-core"}
def test_nothing_is_an_empty_set(self):
assert parse_dnf_security("") == set()
@pytest.mark.parametrize(
("nevra", "name"),
[
("openssl-libs-1:3.1.4-2.fc40.x86_64", "openssl-libs"),
("kernel-core-5.14.0-427.13.1.el9_4.x86_64", "kernel-core"),
("python3-dnf-4.14.0-9.el9.noarch", "python3-dnf"),
],
)
def test_the_name_of_a_nevra(self, nevra, name):
assert nevra_name(nevra) == name
+412
View File
@@ -0,0 +1,412 @@
"""systemd services: listing them in one round trip, and starting and stopping them.
What systemd reports, and how a unit is started or stopped, is the same on
every host that runs it -- so the command, its parse, the name check and the
reading of the exit status live here once, and a driver only carries a command
across (napalm-linux#7, napalm-proxmox#6).
"""
from __future__ import annotations
import os
import subprocess
import pytest
from napalm_device_types import OSDriver
from napalm_device_types.systemd import (
ACTION_TIMEOUT,
SERVICE_ACTIONS,
SYSTEMD_SERVICES_COMMAND,
SystemdServicesMixin,
SystemdUnavailable,
parse_action_result,
parse_systemd_services,
service_action_command,
unit_name,
)
FILES = """\
apparmor.service enabled enabled
ssh.service enabled enabled
sshd.service alias -
getty@.service enabled enabled
rsync.service disabled enabled
cups.service indirect enabled
plymouth-quit.service static -
systemd-networkd-wait-online.service enabled-runtime enabled
nfs-server.service masked enabled
"""
UNITS = """\
MainPID=812
Id=ssh.service
Names=ssh.service sshd.service
LoadState=loaded
ActiveState=active
SubState=running
UnitFileState=enabled
MainPID=0
Id=apparmor.service
Names=apparmor.service
LoadState=loaded
ActiveState=active
SubState=exited
UnitFileState=enabled
MainPID=900
Id=getty@tty1.service
Names=getty@tty1.service
LoadState=loaded
ActiveState=active
SubState=running
UnitFileState=enabled
MainPID=0
Id=systemd-fsck@dev-disk-by\\x2dlabel-BOOT.service
Names=systemd-fsck@dev-disk-by\\x2dlabel-BOOT.service
LoadState=loaded
ActiveState=inactive
SubState=dead
UnitFileState=static
MainPID=0
Id=display-manager.service
Names=display-manager.service
LoadState=not-found
ActiveState=inactive
SubState=dead
UnitFileState=
MainPID=0
Id=nfs-server.service
Names=nfs-server.service
LoadState=masked
ActiveState=inactive
SubState=dead
UnitFileState=masked
MainPID=0
Id=systemd-networkd-wait-online.service
Names=systemd-networkd-wait-online.service
LoadState=loaded
ActiveState=active
SubState=exited
UnitFileState=enabled-runtime
"""
GENERATED_UNIT = """
MainPID=0
Id=rrdcached.service
Names=rrdcached.service
LoadState=loaded
ActiveState=active
SubState=running
UnitFileState=generated
"""
def _wire(
files: str = FILES,
units: str = UNITS,
*,
generated: str = "",
noise: str = "",
end: bool = True,
) -> str:
"""The report as the command prints it, framed."""
tail = "SVC_END\n" if end else ""
return f"{noise}SVC_BEGIN\n[files]\n{files}[units]\n{units}[generated]\n{generated}{tail}"
def _by_name(services):
return {s["name"]: s for s in services}
class TestParseSystemdServices:
def test_a_loaded_unit_is_read_with_its_state(self):
services = _by_name(parse_systemd_services(_wire()))
assert services["ssh"] == {"name": "ssh", "running": True, "enabled": True, "pid": 812}
assert services["apparmor"] == {
"name": "apparmor",
"running": False,
"enabled": True,
"pid": 0,
}
def test_enabled_means_enabled_now_not_merely_installed(self):
services = _by_name(parse_systemd_services(_wire()))
assert services["systemd-networkd-wait-online"]["enabled"] is True
assert services[r"systemd-fsck@dev-disk-by\x2dlabel-BOOT"]["enabled"] is False
assert services["nfs-server"]["enabled"] is False
def test_a_generated_unit_takes_its_boot_state_from_is_enabled(self):
"""A SysV script's unit is generated; only is-enabled knows its rc links."""
raw = _wire(units=UNITS + GENERATED_UNIT, generated="rrdcached.service enabled\n")
assert _by_name(parse_systemd_services(raw))["rrdcached"]["enabled"] is True
def test_a_generated_unit_is_not_enabled_unless_is_enabled_says_so(self):
raw = _wire(units=UNITS + GENERATED_UNIT, generated="rrdcached.service disabled\n")
assert _by_name(parse_systemd_services(raw))["rrdcached"]["enabled"] is False
def test_a_unit_that_is_not_there_is_left_out(self):
assert "display-manager" not in _by_name(parse_systemd_services(_wire()))
def test_an_installed_unit_that_is_not_loaded_is_listed(self):
services = _by_name(parse_systemd_services(_wire()))
assert services["rsync"] == {"name": "rsync", "running": False, "enabled": False, "pid": 0}
assert services["cups"]["enabled"] is False
def test_templates_and_static_files_that_are_not_loaded_are_not(self):
services = _by_name(parse_systemd_services(_wire()))
assert "getty@" not in services
assert "plymouth-quit" not in services
assert services["getty@tty1"]["running"] is True
def test_an_alias_never_appears_beside_its_unit(self):
assert "sshd" not in _by_name(parse_systemd_services(_wire()))
def test_an_alias_that_older_systemd_calls_enabled_does_not_either(self):
files = (
"\n".join(
"sshd.service enabled enabled" if line.startswith("sshd.service") else line
for line in FILES.splitlines()
)
+ "\n"
)
assert "sshd" not in _by_name(parse_systemd_services(_wire(files=files)))
def test_an_escaped_name_survives(self):
assert r"systemd-fsck@dev-disk-by\x2dlabel-BOOT" in _by_name(
parse_systemd_services(_wire())
)
def test_blocks_run_together_are_still_told_apart(self):
"""xargs may split the unit list across two systemctl runs."""
units = UNITS.replace(
"UnitFileState=enabled\n\nMainPID=0\nId=apparmor",
"UnitFileState=enabled\nMainPID=0\nId=apparmor",
)
services = _by_name(parse_systemd_services(_wire(units=units)))
assert services["ssh"]["pid"] == 812
assert services["apparmor"]["running"] is False
def test_the_list_is_sorted_by_name(self):
names = [s["name"] for s in parse_systemd_services(_wire())]
assert names == sorted(names)
def test_terminal_colours_in_the_report_are_dropped(self):
files = FILES.replace(
"rsync.service disabled",
"rsync.service \x1b[0;1;31mdisabled\x1b[0m",
)
assert "rsync" in _by_name(parse_systemd_services(_wire(files=files)))
def test_whatever_surrounds_the_frame_is_ignored(self):
noisy = _wire(noise="user@host:~$ systemctl ...\n") + "user@host:~$ "
assert "ssh" in _by_name(parse_systemd_services(noisy))
def test_a_cut_short_report_raises(self):
"""A missing tail must not read as services that went away."""
with pytest.raises(ValueError):
parse_systemd_services(_wire(end=False))
def test_output_without_the_frame_raises(self):
with pytest.raises(ValueError):
parse_systemd_services("bash: systemctl: command not found\n")
def test_a_host_without_systemd_says_so(self):
raw = "SVC_BEGIN\n[no-systemd]\n[files]\n[units]\nSVC_END\n"
with pytest.raises(SystemdUnavailable):
parse_systemd_services(raw)
def test_no_systemd_is_a_not_implemented_error(self):
assert issubclass(SystemdUnavailable, NotImplementedError)
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):
"""An echoing transport must not show the end marker early."""
assert "SVC_END" not in SYSTEMD_SERVICES_COMMAND
assert "SVC_BEGIN" not in SYSTEMD_SERVICES_COMMAND
def test_it_changes_nothing(self):
for verb in ("start", "stop", "restart", "enable", "disable", "mask"):
assert f"systemctl {verb}" not in SYSTEMD_SERVICES_COMMAND
@pytest.mark.skipif(not os.path.isdir("/run/systemd/system"), reason="needs systemd")
def test_it_runs_and_parses_on_this_host(self):
out = subprocess.run(
["sh", "-c", SYSTEMD_SERVICES_COMMAND], capture_output=True, text=True, timeout=60
).stdout
services = _by_name(parse_systemd_services(out))
assert "systemd-journald" in services
assert services["systemd-journald"]["running"] is True
class TestUnitName:
@pytest.mark.parametrize(
("raw", "name"),
[
("ssh", "ssh"),
("ssh.service", "ssh"),
("getty@tty1", "getty@tty1"),
("wg-quick@wg0", "wg-quick@wg0"),
("snapd.apparmor", "snapd.apparmor"),
("systemd-backlight@backlight:acpi_video0", "systemd-backlight@backlight:acpi_video0"),
(r"systemd-fsck@dev-disk-by\x2dlabel-BOOT", r"systemd-fsck@dev-disk-by\x2dlabel-BOOT"),
],
)
def test_a_unit_name_is_accepted(self, raw, name):
assert unit_name(raw) == name
@pytest.mark.parametrize(
"raw",
[
"",
"-x",
"foo@",
"foo@.service",
"a b",
"a;b",
"$(id)",
"a/b",
r"bad\x2",
"ssh\n",
"x" * 256,
],
)
def test_anything_else_is_refused(self, raw):
with pytest.raises(ValueError):
unit_name(raw)
class TestServiceActionCommand:
def test_the_command_is_bounded_and_never_asks(self):
cmd = service_action_command("getty@tty1", "restart")
assert cmd.startswith(f"timeout {ACTION_TIMEOUT} systemctl --no-ask-password restart -- ")
assert "getty@tty1.service" in cmd
def test_an_escaped_name_is_quoted_for_the_shell(self):
cmd = service_action_command(r"systemd-fsck@dev-disk-by\x2dlabel-BOOT", "stop")
assert r"'systemd-fsck@dev-disk-by\x2dlabel-BOOT.service'" in cmd
def test_its_exit_status_is_printed_after_it(self):
assert service_action_command("ssh", "start").endswith("; echo __SVC_RC=$?")
def test_the_actions(self):
assert SERVICE_ACTIONS == ("start", "stop", "restart", "enable", "disable")
def test_an_unknown_action_is_refused(self):
with pytest.raises(ValueError):
service_action_command("ssh", "mask")
def test_an_invalid_name_is_refused(self):
with pytest.raises(ValueError):
service_action_command("ssh; reboot", "stop")
class TestParseActionResult:
def test_exit_status_zero_is_success(self):
assert parse_action_result("__SVC_RC=0\n") == {"success": True, "output": ""}
def test_what_systemctl_printed_comes_back_without_the_marker(self):
raw = (
"Created symlink /etc/systemd/system/multi-user.target.wants/cron.service.\n__SVC_RC=0"
)
result = parse_action_result(raw)
assert result["success"] is True
assert result["output"].startswith("Created symlink")
assert "__SVC_RC" not in result["output"]
def test_terminal_colours_are_dropped(self):
"""systemctl colours its errors when a transport gives it a terminal."""
raw = (
"\x1b[0;1;31mFailed to restart x.service: Unit x.service not found.\x1b[0m\n"
"__SVC_RC=5\n"
)
assert parse_action_result(raw)["output"] == (
"Failed to restart x.service: Unit x.service not found."
)
def test_a_failure_keeps_its_message(self):
raw = "Failed to start foo.service: Unit foo.service not found.\n__SVC_RC=5\n"
assert parse_action_result(raw) == {
"success": False,
"output": "Failed to start foo.service: Unit foo.service not found.",
}
def test_a_job_still_running_at_the_timeout_is_not_called_done(self):
result = parse_action_result("__SVC_RC=124\n")
assert result["success"] is False
assert str(ACTION_TIMEOUT) in result["output"]
def test_no_exit_status_is_no_success(self):
assert parse_action_result("Connection reset\n")["success"] is False
def test_the_echoed_command_is_not_taken_for_the_status(self):
raw = "timeout 45 systemctl restart -- cron.service 2>&1; echo __SVC_RC=$?\n__SVC_RC=1\n"
assert parse_action_result(raw)["success"] is False
class _Driver(SystemdServicesMixin):
def __init__(self, reply: str) -> None:
self.reply = reply
self.calls: list = []
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
self.calls.append((command, privileged, timeout))
return self.reply
class TestSystemdServicesMixin:
def test_listing_runs_the_command_unprivileged(self):
driver = _Driver(_wire())
assert "ssh" in _by_name(driver.get_services())
assert driver.calls == [(SYSTEMD_SERVICES_COMMAND, False, ACTION_TIMEOUT + 15)]
def test_an_action_runs_privileged_and_reports_its_outcome(self):
driver = _Driver("__SVC_RC=0\n")
assert driver.manage_service("cron", "restart") == {"success": True, "output": ""}
command, privileged, timeout = driver.calls[0]
assert command == service_action_command("cron", "restart")
assert privileged is True
assert timeout > ACTION_TIMEOUT
def test_an_invalid_request_is_refused_before_anything_is_sent(self):
driver = _Driver("__SVC_RC=0\n")
with pytest.raises(ValueError):
driver.manage_service("cron;reboot", "stop")
assert driver.calls == []
def test_not_every_os_driver_has_it(self):
assert not hasattr(OSDriver, "manage_service")
assert callable(getattr(SystemdServicesMixin, "manage_service"))