From a2bfa709f9048877db6bb63feb5e55ccca022cd1 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Mon, 19 Oct 2026 17:40:06 +0200 Subject: [PATCH] feat: get_service_packages, which packages a systemd service is (NetOrk/netork#1236) Mixes in napalm-device-types' ServicePackagesMixin. The transport runs the shared read-only command on an exec channel under sh, without sudo: it is about 1 kB, past what the PTY takes reliably, and written for sh whatever the login shell. Requires napalm-device-types 4.20.0; version 0.12.0. --- CHANGELOG.md | 3 +++ napalm_linux/linux.py | 11 +++++++++++ pyproject.toml | 4 ++-- tests/test_linux.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a07fa6..2f332bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `get_service_packages()` (napalm-device-types' `ServicePackagesMixin`, NetOrk/netork#1236): which installed packages each systemd service is made of — the one shipping its unit file and the one shipping its `ExecStart` binary. Read-only, on an exec channel under `sh`, without sudo. Requires napalm-device-types 4.20.0; version 0.12.0. + ### Security - The SSH host key is verified (NetOrk/netork#1167). `open()` hands netmiko the key the caller expects for the port in `optional_args["ssh_host_keys"]` (as a `known_hosts` file that exists only while it connects, with `ssh_strict`), so any other key is refused before the login (`SshHostKeyMismatch`); without one netmiko accepts the first key. Every key seen goes to `optional_args["ssh_host_key_observer"]`. OpenMediaVault and QNAP get this through `LinuxDriver.open()`. Requires napalm-device-types 4.19.0; version 0.11.0. diff --git a/napalm_linux/linux.py b/napalm_linux/linux.py index d18fb45..409c3af 100644 --- a/napalm_linux/linux.py +++ b/napalm_linux/linux.py @@ -47,6 +47,7 @@ from napalm_device_types import ( OSDriver, PackageProxyMixin, ServiceClustersMixin, + ServicePackagesMixin, SnmpAgentMixin, SystemIdentityMixin, SystemdServicesMixin, @@ -294,6 +295,7 @@ class LinuxDriver( EnsurePackageMixin, PackageProxyMixin, KernelFactsMixin, + ServicePackagesMixin, ListeningSocketsMixin, VirtualIpsMixin, ServiceClustersMixin, @@ -1164,6 +1166,15 @@ class LinuxDriver( """The transport for ``KernelFactsMixin.get_kernel_facts``: read-only, no sudo.""" return self._send(command, read_timeout=60) + def _run_service_packages_command(self, command: str) -> str: + """The transport for ``ServicePackagesMixin.get_service_packages``. + + An exec channel rather than the PTY -- the command is about 1 kB -- under + ``sh``, which it is written for, whatever the login shell. Read-only, so + never through sudo. + """ + return self.run_command(f"sh -c {_shlex_quote(command)}", timeout=120).stdout + # ------------------------------------------------------------------ # ListeningSocketsMixin – the transport for get_listening_sockets # ------------------------------------------------------------------ diff --git a/pyproject.toml b/pyproject.toml index 952a2a2..58719df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-linux" -version = "0.11.0" +version = "0.12.0" description = "NAPALM driver for generic Linux systems via SSH" readme = "README.md" requires-python = ">=3.9" @@ -37,7 +37,7 @@ classifiers = [ ] dependencies = [ "napalm>=4.0", - "napalm-device-types>=4.19.0", + "napalm-device-types>=4.20.0", "netmiko>=4.0.0", "paramiko>=5.0.0", # CVE-2026-44405 ] diff --git a/tests/test_linux.py b/tests/test_linux.py index fbd07ba..ba9a9c6 100644 --- a/tests/test_linux.py +++ b/tests/test_linux.py @@ -1193,6 +1193,40 @@ def test_get_kernel_facts_raises_on_output_without_a_report(driver): driver.get_kernel_facts() +# --------------------------------------------------------------------------- +# get_service_packages -- the command and its parse live in napalm-device-types +# --------------------------------------------------------------------------- + +_SVCPKG = ( + "SVCPKG_BEGIN\n[units]\nId=nginx.service\n" + "FragmentPath=/lib/systemd/system/nginx.service\n" + "ExecStart={ path=/usr/sbin/nginx ; argv[]=/usr/sbin/nginx }\n\n" + "[dpkg]\nnginx-common: /lib/systemd/system/nginx.service\n" + "nginx-core: /usr/sbin/nginx\nSVCPKG_END\n" +) + + +def test_get_service_packages_runs_the_shared_command_under_sh_on_an_exec_channel(driver): + """Read-only, so never through sudo; ``sh`` because the login shell may be another.""" + from shlex import quote + + from napalm_device_types import CommandResult, ServicePackagesMixin + from napalm_device_types.service_packages import SERVICE_PACKAGES_COMMAND + + assert isinstance(driver, ServicePackagesMixin) + calls = [] + + def run_command(command, *, privileged=False, timeout=60, stdin=None): + calls.append((command, privileged)) + return CommandResult(_SVCPKG, "", 0) + + driver.run_command = run_command + + assert driver.get_service_packages() == {"nginx": ["nginx-common", "nginx-core"]} + assert calls == [(f"sh -c {quote(SERVICE_PACKAGES_COMMAND)}", False)] + driver._device.send_command.assert_not_called() + + # --------------------------------------------------------------------------- # Services: listed in one round trip, controlled through systemctl (#7) # --------------------------------------------------------------------------- -- 2.54.0