Compare commits
34
Commits
1cee26823e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a76b6d47f | ||
|
|
3333d8e8f5 | ||
|
|
fe29b507b0 | ||
|
|
51ee33eebe | ||
|
|
0f5e2a1d37 | ||
|
|
b7a13d5153 | ||
|
|
007590b9bf | ||
|
|
79e52f060e | ||
|
|
3571149639 | ||
|
|
60b56c37e0 | ||
|
|
e82f99df7b | ||
|
|
2fed2f73e2 | ||
|
|
b49acb8ed7 | ||
|
|
a6f9a17858 | ||
|
|
e31bc2a3bf | ||
|
|
84717ff53b | ||
|
|
31b8a37895 | ||
|
|
a6e5568e0b | ||
|
|
b6b1827f96 | ||
|
|
ac288823a7 | ||
|
|
b4e6bbf79f | ||
|
|
b45444c831 | ||
|
|
e8eadb46c6 | ||
|
|
55635ab551 | ||
|
|
549e8c01e0 | ||
|
|
d33739832b | ||
|
|
7faaafb7a3 | ||
|
|
799d1ce749 | ||
|
|
ce40299033 | ||
|
|
27027eec56 | ||
|
|
c8fc46c373 | ||
|
|
661d56074c | ||
|
|
2f049338b5 | ||
|
|
07dcdbfe50 |
@@ -0,0 +1,48 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12"]
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install package with dev extras
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# napalm-device-types lives in git.netork.io/NAPALM, not on PyPI: without this
|
||||||
|
# pip looks there, finds an unrelated 0.1.0 and the job dies before any test.
|
||||||
|
python -m pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: |
|
||||||
|
python -m pytest -q --tb=short
|
||||||
|
|
||||||
|
- name: Build wheel and sdist
|
||||||
|
run: |
|
||||||
|
python -m pip install build
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
- name: Upload dist artifacts
|
||||||
|
# v4 refuses to run on Gitea ("not currently supported on GHES").
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: dist-${{ matrix.python-version }}
|
||||||
|
path: dist/*
|
||||||
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-10-08
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `get_docker_info()`, `get_docker_outdated()`, `reconstruct_docker_run()` and
|
||||||
|
the `_docker_bin()` hook, with the image-ID helpers they used. netOrk reads
|
||||||
|
and handles containers itself over the Engine API, through
|
||||||
|
`open_container_engine()` (NetOrk/netork#765), and no longer calls them.
|
||||||
|
`run_device_action("fix_docker_permissions")` stays: letting the login user
|
||||||
|
use Docker is an OS action.
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-10-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `run_command()` and `open_stream()`, the public command channel from
|
||||||
|
napalm-device-types 2.6.0: an exec channel on the existing SSH transport, so
|
||||||
|
no PTY, separate stderr and a real exit code. `privileged=True` runs as root
|
||||||
|
directly, through `sudo -S` with the password on stdin (never on the command
|
||||||
|
line), or through `sudo -n`, which fails instead of prompting.
|
||||||
|
- `ContainerEngineMixin`: `container_engines()` and `open_container_engine()`,
|
||||||
|
whose `open_api()` streams the Docker Engine API over `docker system
|
||||||
|
dial-stdio` (NetOrk/netork#765). The existing Docker methods are unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Requires napalm-device-types >= 2.6.0.
|
||||||
|
|
||||||
## [0.1.0] – 2026-05-29
|
## [0.1.0] – 2026-05-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
+383
-447
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "napalm-linux"
|
name = "napalm-linux"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "NAPALM driver for generic Linux systems via SSH"
|
description = "NAPALM driver for generic Linux systems via SSH"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -37,7 +37,7 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"napalm>=4.0",
|
"napalm>=4.0",
|
||||||
"napalm-device-types>=0.3.0",
|
"napalm-device-types>=2.6.0",
|
||||||
"netmiko>=4.0.0",
|
"netmiko>=4.0.0",
|
||||||
"paramiko>=5.0.0", # CVE-2026-44405
|
"paramiko>=5.0.0", # CVE-2026-44405
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""The public command channel and container engine access (napalm-device-types 2.6.0).
|
||||||
|
|
||||||
|
netOrk used to reach a Linux host's shell through the private ``_send``: an
|
||||||
|
interactive PTY, stdout and stderr merged, no exit code. ``run_command`` and
|
||||||
|
``open_stream`` go through an exec channel on the same SSH transport instead,
|
||||||
|
and ``open_container_engine`` builds on them (NetOrk/netork#765). Privileges
|
||||||
|
work as they do everywhere else in this driver: root runs directly, a sudo
|
||||||
|
password goes to ``sudo -S`` on stdin and never onto a command line, and
|
||||||
|
without one ``sudo -n`` fails at once instead of hanging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from napalm.base.exceptions import ConnectionClosedException
|
||||||
|
|
||||||
|
from napalm_linux import LinuxDriver
|
||||||
|
|
||||||
|
|
||||||
|
class FakeChannel:
|
||||||
|
def __init__(self):
|
||||||
|
self.command = None
|
||||||
|
self.sent = b""
|
||||||
|
|
||||||
|
def exec_command(self, command):
|
||||||
|
self.command = command
|
||||||
|
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def sendall(self, data):
|
||||||
|
self.sent += data
|
||||||
|
|
||||||
|
def shutdown_write(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recv_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def recv_stderr_ready(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def exit_status_ready(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def recv_exit_status(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTransport:
|
||||||
|
def __init__(self):
|
||||||
|
self.channels = []
|
||||||
|
|
||||||
|
def open_session(self):
|
||||||
|
self.channels.append(FakeChannel())
|
||||||
|
return self.channels[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _driver(*, root=False, sudo_password=None):
|
||||||
|
driver = LinuxDriver("h", "u", "p", optional_args={"sudo_password": sudo_password})
|
||||||
|
transport = FakeTransport()
|
||||||
|
driver._device = SimpleNamespace(remote_conn_pre=SimpleNamespace(get_transport=lambda: transport))
|
||||||
|
driver._root = root
|
||||||
|
return driver, transport
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unprivileged_command_runs_as_given():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
result = driver.run_command("docker version", timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "docker version"
|
||||||
|
assert transport.channels[-1].sent == b""
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_command_with_a_sudo_password_reads_it_from_stdin():
|
||||||
|
driver, transport = _driver(sudo_password="s3cr3t")
|
||||||
|
|
||||||
|
driver.run_command("usermod -aG docker u", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
channel = transport.channels[-1]
|
||||||
|
assert channel.command == "sudo -S -p '' sh -c 'usermod -aG docker u'"
|
||||||
|
assert channel.sent == b"s3cr3t\n"
|
||||||
|
assert "s3cr3t" not in channel.command
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_command_without_a_password_fails_fast_instead_of_prompting():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
driver.run_command("id", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "sudo -n sh -c id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_root_login_needs_no_sudo():
|
||||||
|
driver, transport = _driver(root=True, sudo_password="s3cr3t")
|
||||||
|
|
||||||
|
driver.run_command("id", privileged=True, timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "id"
|
||||||
|
assert transport.channels[-1].sent == b""
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdin_follows_the_sudo_password():
|
||||||
|
driver, transport = _driver(sudo_password="pw")
|
||||||
|
|
||||||
|
driver.run_command("tee /etc/x", privileged=True, stdin=b"data", timeout=5)
|
||||||
|
|
||||||
|
assert transport.channels[-1].sent == b"pw\ndata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_privileged_stream_gets_the_password_first():
|
||||||
|
driver, transport = _driver(sudo_password="pw")
|
||||||
|
|
||||||
|
driver.open_stream("cat > /tmp/x", privileged=True)
|
||||||
|
|
||||||
|
channel = transport.channels[-1]
|
||||||
|
assert channel.command == "sudo -S -p '' sh -c 'cat > /tmp/x'"
|
||||||
|
assert channel.sent == b"pw\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_container_engine_api_is_a_stream_over_dial_stdio():
|
||||||
|
driver, transport = _driver()
|
||||||
|
|
||||||
|
driver.open_container_engine("docker").open_api()
|
||||||
|
|
||||||
|
assert transport.channels[-1].command == "docker system dial-stdio"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_channel_needs_an_open_connection():
|
||||||
|
driver = LinuxDriver("h", "u", "p")
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionClosedException):
|
||||||
|
driver.run_command("true")
|
||||||
+787
-12
@@ -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
|
||||||
@@ -22,6 +24,11 @@ def driver():
|
|||||||
d._secret = "pass" # noqa: S105
|
d._secret = "pass" # noqa: S105
|
||||||
d._forced_pkg_manager = None
|
d._forced_pkg_manager = None
|
||||||
d._pkg_manager = "apt"
|
d._pkg_manager = "apt"
|
||||||
|
# Set by __init__, which this fixture bypasses via __new__. Without it every
|
||||||
|
# call through _sudo() raises AttributeError, which the callers' broad
|
||||||
|
# `except Exception` turns into a plain {"success": False} -- so the tests
|
||||||
|
# failed for a reason that had nothing to do with what they were testing.
|
||||||
|
d._sudo_password = None
|
||||||
d.netmiko_optional_args = {}
|
d.netmiko_optional_args = {}
|
||||||
d._device = MagicMock()
|
d._device = MagicMock()
|
||||||
return d
|
return d
|
||||||
@@ -148,9 +155,22 @@ def test_parse_uptime_invalid(driver):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
#: What `dpkg-query` actually returns for the format this driver asks for.
|
||||||
|
#:
|
||||||
|
#: The previous fixture carried four fields against a format string asking for
|
||||||
|
#: five, so `source_package` was silently receiving the description and no
|
||||||
|
#: assertion noticed. A fixture simpler than the data cannot fail the way the
|
||||||
|
#: data does.
|
||||||
APT_PKG_OUTPUT = (
|
APT_PKG_OUTPUT = (
|
||||||
"openssh-server\t1:9.2p1-2+deb12u2\t512\tsecure shell server\n"
|
"openssh-server\t1:9.2p1-2+deb12u2\t512\topenssh\t1:9.2p1-2+deb12u2"
|
||||||
"curl\t7.88.1-10+deb12u5\t1024\tcommand line tool for transferring data\n"
|
"\tsecure shell server\n"
|
||||||
|
"curl\t7.88.1-10+deb12u5\t1024\tcurl\t7.88.1-10+deb12u5"
|
||||||
|
"\tcommand line tool for transferring data\n"
|
||||||
|
# The shape that matters: a binary package whose own upstream version has
|
||||||
|
# nothing to do with its source package's. ldb 2.11.0 is built from samba
|
||||||
|
# 4.22.11, and OSV states Debian ranges in source versions.
|
||||||
|
"libldb2\t2:2.11.0+samba4.22.11+dfsg-0+deb13u1\t2048\tsamba"
|
||||||
|
"\t2:4.22.11+dfsg-0+deb13u1\tLDB shared library\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,15 +178,49 @@ def test_get_packages_apt(driver):
|
|||||||
driver._pkg_manager = "apt"
|
driver._pkg_manager = "apt"
|
||||||
with patch.object(driver, "_send", return_value=APT_PKG_OUTPUT):
|
with patch.object(driver, "_send", return_value=APT_PKG_OUTPUT):
|
||||||
pkgs = driver.get_packages()
|
pkgs = driver.get_packages()
|
||||||
assert len(pkgs) == 2
|
assert len(pkgs) == 3
|
||||||
assert pkgs[0]["name"] == "openssh-server"
|
assert pkgs[0]["name"] == "openssh-server"
|
||||||
assert pkgs[0]["version"] == "1:9.2p1-2+deb12u2"
|
assert pkgs[0]["version"] == "1:9.2p1-2+deb12u2"
|
||||||
assert pkgs[0]["installed"] is True
|
assert pkgs[0]["installed"] is True
|
||||||
assert pkgs[0]["source"] == "apt"
|
assert pkgs[0]["source"] == "apt"
|
||||||
|
assert pkgs[0]["description"] == "secure shell server"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_packages_apt_keeps_the_source_package_and_its_version(driver):
|
||||||
|
"""OSV states Debian ranges in *source* package versions.
|
||||||
|
|
||||||
|
A consumer that matches on the source package and then compares the binary
|
||||||
|
package's version is comparing two unrelated numbers. On a Debian 13 host
|
||||||
|
that reported four Samba libraries as vulnerable to CVE-2022-44640 while
|
||||||
|
running samba 4.22.11 — five releases past the fix — because dpkg reads
|
||||||
|
ldb's own `2.11.0` as older than samba's `2:4.17.4+dfsg-1`.
|
||||||
|
|
||||||
|
The driver cannot fix the comparison, but it is the only place that can
|
||||||
|
supply the number to compare.
|
||||||
|
"""
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
with patch.object(driver, "_send", return_value=APT_PKG_OUTPUT):
|
||||||
|
pkgs = {p["name"]: p for p in driver.get_packages()}
|
||||||
|
|
||||||
|
assert pkgs["libldb2"]["source_package"] == "samba"
|
||||||
|
assert pkgs["libldb2"]["source_version"] == "2:4.22.11+dfsg-0+deb13u1"
|
||||||
|
assert pkgs["libldb2"]["version"] == "2:2.11.0+samba4.22.11+dfsg-0+deb13u1"
|
||||||
|
assert pkgs["libldb2"]["description"] == "LDB shared library"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_packages_apt_falls_back_when_dpkg_gives_no_source(driver):
|
||||||
|
"""`source:Package` is empty for a package whose source name equals its own.
|
||||||
|
Older dpkg builds leave `source:Version` empty in that case too."""
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
with patch.object(driver, "_send", return_value="curl\t7.88.1-10\t1024\t\t\ttool\n"):
|
||||||
|
(pkg,) = driver.get_packages()
|
||||||
|
|
||||||
|
assert pkg["source_package"] == "curl"
|
||||||
|
assert pkg["source_version"] == "7.88.1-10"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# get_pending_updates (apt)
|
# get_available_updates (apt)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -174,13 +228,14 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_pending_updates_apt(driver):
|
def test_get_available_updates_apt(driver):
|
||||||
driver._pkg_manager = "apt"
|
driver._pkg_manager = "apt"
|
||||||
with patch.object(driver, "_send", side_effect=["", APT_UPGRADABLE]):
|
with patch.object(driver, "_send", return_value=APT_UPGRADABLE):
|
||||||
updates = driver.get_pending_updates()
|
updates = driver.get_available_updates()
|
||||||
assert len(updates) == 2
|
assert len(updates) == 2
|
||||||
assert updates[0]["name"] == "openssh-server"
|
assert updates[0]["name"] == "openssh-server"
|
||||||
assert updates[0]["current_version"] == "1:9.2p1-2+deb12u1"
|
assert updates[0]["current_version"] == "1:9.2p1-2+deb12u1"
|
||||||
@@ -312,7 +367,10 @@ def test_apply_updates_apt_all_packages(driver):
|
|||||||
driver._pkg_manager = "apt"
|
driver._pkg_manager = "apt"
|
||||||
sent_commands = []
|
sent_commands = []
|
||||||
|
|
||||||
def capture_send(cmd):
|
def capture_send(cmd, **kwargs):
|
||||||
|
# _sudo() passes read_timeout as a keyword; without **kwargs this raises
|
||||||
|
# TypeError, which the caller's `except Exception` reports as a failed
|
||||||
|
# upgrade rather than a broken test double.
|
||||||
sent_commands.append(cmd)
|
sent_commands.append(cmd)
|
||||||
return APT_UPGRADE_SUCCESS
|
return APT_UPGRADE_SUCCESS
|
||||||
|
|
||||||
@@ -631,13 +689,16 @@ def test_get_facts_baremetal_vendor_model_serial(driver):
|
|||||||
platform = {"vendor": "Dell Inc.", "model": "PowerEdge R720", "serial": "ABC123", "is_vm": False}
|
platform = {"vendor": "Dell Inc.", "model": "PowerEdge R720", "serial": "ABC123", "is_vm": False}
|
||||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||||
patch.object(driver, "_parse_uptime", return_value=86400), \
|
patch.object(driver, "_parse_uptime", return_value=86400), \
|
||||||
patch.object(driver, "_send", side_effect=["myhost", "myhost.example.com", "Debian GNU/Linux 12", "eth0\neth1"]):
|
patch.object(driver, "_send", side_effect=["myhost", "myhost.example.com", "Debian GNU/Linux 12", "eth0\neth1",
|
||||||
|
"6.1.0-18-amd64"]):
|
||||||
facts = driver.get_facts()
|
facts = driver.get_facts()
|
||||||
assert facts["vendor"] == "Dell Inc."
|
assert facts["vendor"] == "Dell Inc."
|
||||||
assert facts["model"] == "PowerEdge R720"
|
assert facts["model"] == "PowerEdge R720"
|
||||||
assert facts["serial_number"] == "ABC123"
|
assert facts["serial_number"] == "ABC123"
|
||||||
assert facts["hostname"] == "myhost"
|
assert facts["hostname"] == "myhost"
|
||||||
assert facts["uptime"] == 86400
|
assert facts["uptime"] == 86400
|
||||||
|
# Booted kernel, not the newest installed one — kernel CVE relevance needs it.
|
||||||
|
assert facts["running_kernel"] == "6.1.0-18-amd64"
|
||||||
|
|
||||||
|
|
||||||
def test_get_facts_vm_kvm(driver):
|
def test_get_facts_vm_kvm(driver):
|
||||||
@@ -647,7 +708,8 @@ def test_get_facts_vm_kvm(driver):
|
|||||||
}
|
}
|
||||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||||
patch.object(driver, "_parse_uptime", return_value=3600), \
|
patch.object(driver, "_parse_uptime", return_value=3600), \
|
||||||
patch.object(driver, "_send", side_effect=["vmhost", "vmhost.local", "Ubuntu 22.04 LTS", "eth0"]):
|
patch.object(driver, "_send", side_effect=["vmhost", "vmhost.local", "Ubuntu 22.04 LTS", "eth0",
|
||||||
|
"5.15.0-91-generic"]):
|
||||||
facts = driver.get_facts()
|
facts = driver.get_facts()
|
||||||
assert facts["vendor"] == "KVM"
|
assert facts["vendor"] == "KVM"
|
||||||
assert facts["model"] == "Virtual Machine"
|
assert facts["model"] == "Virtual Machine"
|
||||||
@@ -658,7 +720,7 @@ def test_get_facts_fallback_vendor_when_dmi_empty(driver):
|
|||||||
platform = {"vendor": "", "model": "", "serial": "", "is_vm": False}
|
platform = {"vendor": "", "model": "", "serial": "", "is_vm": False}
|
||||||
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
with patch.object(driver, "_collect_platform_info", return_value=platform), \
|
||||||
patch.object(driver, "_parse_uptime", return_value=0), \
|
patch.object(driver, "_parse_uptime", return_value=0), \
|
||||||
patch.object(driver, "_send", side_effect=["host", "host.local", "Alpine Linux 3.19", "eth0"]):
|
patch.object(driver, "_send", side_effect=["host", "host.local", "Alpine Linux 3.19", "eth0", "6.6.7-0-lts"]):
|
||||||
facts = driver.get_facts()
|
facts = driver.get_facts()
|
||||||
assert facts["vendor"] == "Linux" # fallback to VENDOR class attribute
|
assert facts["vendor"] == "Linux" # fallback to VENDOR class attribute
|
||||||
|
|
||||||
@@ -776,7 +838,9 @@ class TestActionAptUpdateUpgrade:
|
|||||||
assert "[upgrade]" in result["output"]
|
assert "[upgrade]" in result["output"]
|
||||||
update_call, upgrade_call = mock_sudo.call_args_list
|
update_call, upgrade_call = mock_sudo.call_args_list
|
||||||
assert "apt-get update" in update_call.args[0]
|
assert "apt-get update" in update_call.args[0]
|
||||||
assert "apt-get upgrade" in upgrade_call.args[0]
|
# full-upgrade (not plain upgrade) — plain upgrade silently holds back
|
||||||
|
# packages whose newer version needs to install/remove dependencies.
|
||||||
|
assert "apt-get full-upgrade" in upgrade_call.args[0]
|
||||||
|
|
||||||
def test_exception_during_upgrade_returns_failure(self, driver):
|
def test_exception_during_upgrade_returns_failure(self, driver):
|
||||||
driver._pkg_manager = "apt"
|
driver._pkg_manager = "apt"
|
||||||
@@ -800,3 +864,714 @@ class TestRunDeviceActionDispatch:
|
|||||||
) as mock_action:
|
) as mock_action:
|
||||||
driver.run_device_action("apt_update_upgrade")
|
driver.run_device_action("apt_update_upgrade")
|
||||||
mock_action.assert_called_once()
|
mock_action.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoDockerOfItsOwn:
|
||||||
|
"""Containers are read and handled by netOrk over the Engine API, through
|
||||||
|
``open_container_engine`` (NetOrk/netork#765). The old CLI methods are gone;
|
||||||
|
only the OS action that lets the login user use Docker stays."""
|
||||||
|
|
||||||
|
def test_the_cli_methods_are_gone(self):
|
||||||
|
for name in ("get_docker_info", "get_docker_outdated", "reconstruct_docker_run", "_docker_bin"):
|
||||||
|
assert not hasattr(LinuxDriver, name), name
|
||||||
|
|
||||||
|
def test_the_engine_access_and_the_permission_fix_stay(self):
|
||||||
|
assert hasattr(LinuxDriver, "open_container_engine")
|
||||||
|
assert hasattr(LinuxDriver, "_action_fix_docker_permissions")
|
||||||
|
|
||||||
|
|
||||||
|
class TestUninstallPackage:
|
||||||
|
"""Removing a package that does not want to go.
|
||||||
|
|
||||||
|
Both cases here were found during a fleet-wide Wazuh rollback. Of thirteen
|
||||||
|
hosts carrying the agent, seven sat at `install ok unpacked` with the unit
|
||||||
|
failed — an upgrade whose postinst could not reach a manager that no longer
|
||||||
|
existed. `apt-get remove` cannot help there: apt configures a package before
|
||||||
|
removing it, and configuring is exactly what was broken.
|
||||||
|
|
||||||
|
And `remove` leaves the configuration behind by design, which for the Wazuh
|
||||||
|
agent means its apt source keeps being fetched on every update, long after
|
||||||
|
the package is gone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_remove_is_still_the_default(self, driver):
|
||||||
|
"""Callers that did not ask for a purge must not get one: configuration
|
||||||
|
somebody may want back is not this function's to delete."""
|
||||||
|
_mock_send(driver, "Removing wazuh-agent ...")
|
||||||
|
|
||||||
|
driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
sent = driver._device.send_command.call_args[0][0]
|
||||||
|
assert "apt-get remove" in sent
|
||||||
|
assert "purge" not in sent
|
||||||
|
|
||||||
|
def test_purge_is_asked_for_explicitly(self, driver):
|
||||||
|
_mock_send(driver, "Purging configuration files for wazuh-agent ...")
|
||||||
|
|
||||||
|
driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert "apt-get purge" in driver._device.send_command.call_args[0][0]
|
||||||
|
|
||||||
|
def test_a_half_configured_package_falls_back_to_dpkg(self, driver):
|
||||||
|
"""`install ok unpacked` is the state apt cannot get out of. On one host
|
||||||
|
only `dpkg --purge --force-all` removed it."""
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"E: Sub-process /usr/bin/dpkg returned an error code (1)",
|
||||||
|
"Removing wazuh-agent (4.14.7-1) ...",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
second = driver._device.send_command.call_args_list[1][0][0]
|
||||||
|
assert "dpkg --purge --force-all" in second
|
||||||
|
|
||||||
|
def test_the_fallback_is_not_tried_when_the_first_pass_worked(self, driver):
|
||||||
|
"""A forced dpkg purge is a bigger hammer than apt and must stay a last
|
||||||
|
resort, not a routine second step."""
|
||||||
|
_mock_send(driver, "Removing wazuh-agent ...")
|
||||||
|
|
||||||
|
driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert driver._device.send_command.call_count == 1
|
||||||
|
|
||||||
|
def test_a_package_manager_without_purge_still_removes(self, driver):
|
||||||
|
"""apk and pacman have no separate purge; asking for one must not turn
|
||||||
|
into a failure or a command they do not understand."""
|
||||||
|
driver._pkg_manager = "apk"
|
||||||
|
_mock_send(driver, "(1/1) Purging wazuh-agent")
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "apk del" in driver._device.send_command.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# uninstall_package – success from the exit status, not from prose (netork#267)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _with_rc(output: str, rc: int) -> str:
|
||||||
|
"""What the shell prints for a command run through ``_sudo_status``."""
|
||||||
|
return f"{output}\n__NETORK_RC={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSudoStatus:
|
||||||
|
"""``_sudo_status`` keeps the exit status that ``|| true`` throws away."""
|
||||||
|
|
||||||
|
def test_returns_output_and_exit_status(self, driver):
|
||||||
|
_mock_send(driver, _with_rc("Removing wazuh-agent ...", 0))
|
||||||
|
|
||||||
|
assert driver._sudo_status("apt-get remove -y wazuh-agent") == (
|
||||||
|
"Removing wazuh-agent ...",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_non_zero_exit_status_is_reported(self, driver):
|
||||||
|
_mock_send(driver, _with_rc("E: Unable to locate package nope", 100))
|
||||||
|
|
||||||
|
assert driver._sudo_status("apt-get remove -y nope")[1] == 100
|
||||||
|
|
||||||
|
def test_the_status_is_read_right_after_sudo_returns(self, driver):
|
||||||
|
"""``$?`` must be read straight after the sudo pipeline — with an
|
||||||
|
``|| true`` in between, every command would report 0."""
|
||||||
|
driver._sudo_password = "pw" # noqa: S105
|
||||||
|
_mock_send(driver, _with_rc("", 0))
|
||||||
|
|
||||||
|
driver._sudo_status("apt-get remove -y x 2>&1")
|
||||||
|
|
||||||
|
sent = driver._device.send_command.call_args[0][0]
|
||||||
|
assert sent.startswith("echo pw | sudo -S")
|
||||||
|
assert sent.endswith("apt-get remove -y x 2>&1; echo __NETORK_RC=$?")
|
||||||
|
assert "|| true" not in sent
|
||||||
|
|
||||||
|
def test_a_missing_marker_means_unknown_not_success(self, driver):
|
||||||
|
"""Output cut short before the marker arrived says nothing about the
|
||||||
|
exit status; ``None`` says so instead of guessing 0."""
|
||||||
|
_mock_send(driver, "Removing wazuh-agent ...")
|
||||||
|
|
||||||
|
assert driver._sudo_status("apt-get remove -y wazuh-agent") == (
|
||||||
|
"Removing wazuh-agent ...",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_the_command_echo_is_not_mistaken_for_the_marker(self, driver):
|
||||||
|
"""A terminal may echo the command line back; its literal ``$?`` is not
|
||||||
|
a number, and only the marker on a line of its own counts."""
|
||||||
|
_mock_send(
|
||||||
|
driver,
|
||||||
|
"sudo apt-get remove -y x; echo __NETORK_RC=$?\nRemoving x ...\n__NETORK_RC=1",
|
||||||
|
)
|
||||||
|
|
||||||
|
output, rc = driver._sudo_status("apt-get remove -y x")
|
||||||
|
|
||||||
|
assert rc == 1
|
||||||
|
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:
|
||||||
|
"""Whether a removal worked is what the package manager's exit status says.
|
||||||
|
|
||||||
|
Reading it out of human-readable output was guesswork in both directions:
|
||||||
|
apt's commonest failure (``E: Sub-process /usr/bin/dpkg returned an error
|
||||||
|
code (1)``) read as success until #240, and a successful removal whose
|
||||||
|
prerm merely *mentions* a failure read as a failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_a_non_zero_exit_is_a_failure_whatever_the_output_says(self, driver):
|
||||||
|
"""Nothing in this output matches a failure keyword; only the exit
|
||||||
|
status knows."""
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
_mock_send(driver, _with_rc("Removing: wazuh-agent", 1))
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_a_zero_exit_is_a_success_even_if_the_output_mentions_failure(self, driver):
|
||||||
|
"""A prerm that cannot stop an already-dead unit prints "Failed" and
|
||||||
|
still lets the removal complete."""
|
||||||
|
_mock_send(
|
||||||
|
driver,
|
||||||
|
_with_rc(
|
||||||
|
"Removing wazuh-agent (4.14.7-1) ...\n"
|
||||||
|
"Failed to stop wazuh-agent.service: Unit wazuh-agent.service not loaded.",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_the_marker_does_not_reach_the_caller(self, driver):
|
||||||
|
_mock_send(driver, _with_rc("Removing wazuh-agent ...", 0))
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
assert result["output"] == "Removing wazuh-agent ..."
|
||||||
|
|
||||||
|
def test_the_uninstall_command_keeps_its_exit_status(self, driver):
|
||||||
|
_mock_send(driver, _with_rc("Removing wazuh-agent ...", 0))
|
||||||
|
|
||||||
|
driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
sent = driver._device.send_command.call_args[0][0]
|
||||||
|
assert "|| true" not in sent
|
||||||
|
assert sent.endswith("; echo __NETORK_RC=$?")
|
||||||
|
|
||||||
|
def test_apt_failing_by_exit_status_falls_back_to_dpkg(self, driver):
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
_with_rc("E: Sub-process /usr/bin/dpkg returned an error code (1)", 100),
|
||||||
|
_with_rc("Removing wazuh-agent (4.14.7-1) ...", 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
second = driver._device.send_command.call_args_list[1][0][0]
|
||||||
|
assert "dpkg --purge --force-all" in second
|
||||||
|
assert "|| true" not in second
|
||||||
|
assert "__NETORK_RC" not in result["output"]
|
||||||
|
|
||||||
|
def test_the_dpkg_fallback_failing_is_a_failure(self, driver):
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
_with_rc("E: Sub-process /usr/bin/dpkg returned an error code (1)", 100),
|
||||||
|
_with_rc("dpkg: error processing package wazuh-agent (--purge):", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent", purge=True)
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "dpkg --purge --force-all" in result["output"]
|
||||||
|
|
||||||
|
def test_a_zero_exit_does_not_trigger_the_fallback(self, driver):
|
||||||
|
"""Even when the output contains words that used to mean failure: apt
|
||||||
|
exits 0 for a package that is already gone, which is the state the
|
||||||
|
caller asked for."""
|
||||||
|
_mock_send(driver, _with_rc("Package 'x' is not installed, so not removed", 0))
|
||||||
|
|
||||||
|
result = driver.uninstall_package("x", purge=True)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert driver._device.send_command.call_count == 1
|
||||||
|
|
||||||
|
def test_without_an_exit_status_the_output_is_read_as_before(self, driver):
|
||||||
|
"""If the marker never arrived, the keyword check is still the best
|
||||||
|
answer available — and it errs towards failure on apt's ``E:``."""
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
_mock_send(driver, "E: Sub-process /usr/bin/dpkg returned an error code (1)")
|
||||||
|
|
||||||
|
result = driver.uninstall_package("wazuh-agent")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_kernel_facts -- the command and its parse live in napalm-device-types
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _kernel_wire(report: str) -> str:
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
return "KFACTS_BEGIN\n" + base64.encodebytes(gzip.compress(report.encode())).decode() + "KFACTS_END"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_kernel_facts_carries_the_shared_command_across(driver):
|
||||||
|
from napalm_device_types import KernelFactsMixin
|
||||||
|
from napalm_device_types.kernel import KERNEL_FACTS_COMMAND
|
||||||
|
|
||||||
|
assert isinstance(driver, KernelFactsMixin)
|
||||||
|
|
||||||
|
report = "[release]\n6.1.0-25-amd64\n[loaded]\ntipc\n[available]\nkernel/net/tipc/tipc.ko.xz\n"
|
||||||
|
with patch.object(driver, "_send", return_value=_kernel_wire(report)) as send:
|
||||||
|
facts = driver.get_kernel_facts()
|
||||||
|
|
||||||
|
assert send.call_args.args[0] == KERNEL_FACTS_COMMAND
|
||||||
|
assert facts["release"] == "6.1.0-25-amd64"
|
||||||
|
assert facts["loaded"] == ["tipc"]
|
||||||
|
assert facts["available"] == ["tipc"]
|
||||||
|
assert facts["builtin"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_kernel_facts_raises_on_output_without_a_report(driver):
|
||||||
|
with patch.object(driver, "_send", return_value="sh: base64: not found"):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
driver.get_kernel_facts()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Services: listed in one round trip, controlled through systemctl (#7)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_REPORT = (
|
||||||
|
"SVC_BEGIN\n[files]\ncron.service enabled enabled\n[units]\n"
|
||||||
|
"MainPID=640\nId=cron.service\nNames=cron.service\nLoadState=loaded\n"
|
||||||
|
"ActiveState=active\nSubState=running\nUnitFileState=enabled\n"
|
||||||
|
"[generated]\nSVC_END\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetServices:
|
||||||
|
def test_one_command_lists_every_service(self, driver):
|
||||||
|
driver._device.send_command.return_value = _REPORT
|
||||||
|
|
||||||
|
services = driver.get_services()
|
||||||
|
|
||||||
|
assert services == [{"name": "cron", "running": True, "enabled": True, "pid": 640}]
|
||||||
|
assert driver._device.send_command.call_count == 1
|
||||||
|
assert "systemctl show" in driver._device.send_command.call_args[0][0]
|
||||||
|
|
||||||
|
def test_a_host_without_systemd_falls_back_to_service(self, driver):
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"SVC_BEGIN\n[no-systemd]\n[files]\n[units]\n[generated]\nSVC_END\n",
|
||||||
|
" [ + ] cron\n [ - ] rsync\n",
|
||||||
|
]
|
||||||
|
|
||||||
|
services = driver.get_services()
|
||||||
|
|
||||||
|
assert {s["name"]: s["running"] for s in services} == {"cron": True, "rsync": False}
|
||||||
|
assert "service --status-all" in driver._device.send_command.call_args[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestManageService:
|
||||||
|
def _sent(self, driver) -> list[str]:
|
||||||
|
return [c[0][0] for c in driver._device.send_command.call_args_list]
|
||||||
|
|
||||||
|
def test_as_root_the_command_runs_as_it_is(self, driver):
|
||||||
|
driver._device.send_command.side_effect = ["0", "__SVC_RC=0"]
|
||||||
|
|
||||||
|
assert driver.manage_service("cron", "restart") == {"success": True, "output": ""}
|
||||||
|
uid, action = self._sent(driver)
|
||||||
|
assert uid == "id -u"
|
||||||
|
assert action.startswith("timeout 45 systemctl --no-ask-password restart -- cron.service")
|
||||||
|
|
||||||
|
def test_with_a_sudo_password_it_goes_through_sudo(self, driver):
|
||||||
|
driver._sudo_password = "pw" # noqa: S105
|
||||||
|
driver._device.send_command.side_effect = ["1000", "__SVC_RC=0"]
|
||||||
|
|
||||||
|
assert driver.manage_service("cron", "stop")["success"] is True
|
||||||
|
action = self._sent(driver)[1]
|
||||||
|
assert action.startswith("echo pw | sudo -S")
|
||||||
|
assert "timeout 45 systemctl --no-ask-password stop -- cron.service" in action
|
||||||
|
|
||||||
|
def test_without_one_sudo_never_waits_for_a_password(self, driver):
|
||||||
|
driver._device.send_command.side_effect = ["1000", "__SVC_RC=0"]
|
||||||
|
|
||||||
|
driver.manage_service("cron", "enable")
|
||||||
|
|
||||||
|
assert self._sent(driver)[1].startswith("sudo -n timeout 45 systemctl")
|
||||||
|
|
||||||
|
def test_a_missing_sudo_password_is_explained(self, driver):
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"1000",
|
||||||
|
"sudo: a password is required\n__SVC_RC=1",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = driver.manage_service("cron", "restart")
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "sudo password" in result["output"]
|
||||||
|
assert "NOPASSWD" in result["output"]
|
||||||
|
|
||||||
|
def test_a_failure_keeps_systemctls_message(self, driver):
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"0",
|
||||||
|
"Failed to start nope.service: Unit nope.service not found.\n__SVC_RC=5",
|
||||||
|
]
|
||||||
|
|
||||||
|
result = driver.manage_service("nope", "start")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"success": False,
|
||||||
|
"output": "Failed to start nope.service: Unit nope.service not found.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_who_the_user_is_is_asked_once(self, driver):
|
||||||
|
driver._device.send_command.side_effect = ["0", "__SVC_RC=0", "__SVC_RC=0"]
|
||||||
|
|
||||||
|
driver.manage_service("cron", "stop")
|
||||||
|
driver.manage_service("cron", "start")
|
||||||
|
|
||||||
|
assert self._sent(driver).count("id -u") == 1
|
||||||
|
|
||||||
|
def test_an_invalid_name_is_refused_before_anything_is_sent(self, driver):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
driver.manage_service("cron; reboot", "stop")
|
||||||
|
|
||||||
|
assert driver._device.send_command.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Updates: origin and security, refresh, host status (netOrk MVP 5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
APT_WITH_SECURITY = (
|
||||||
|
"openssl/noble-updates,noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable from: 3.0.13-0ubuntu3.5]\n"
|
||||||
|
"docker-compose-plugin/noble 5.6.0-1~ubuntu.24.04~noble amd64 [upgradable from: 5.5.1-1~ubuntu.24.04~noble]\n"
|
||||||
|
"__APT_RC=0\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAvailableUpdates:
|
||||||
|
def test_apt_reports_origin_and_security(self, driver):
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
_mock_send(driver, APT_WITH_SECURITY)
|
||||||
|
|
||||||
|
updates = {u["name"]: u for u in driver.get_available_updates()}
|
||||||
|
|
||||||
|
assert updates["openssl"]["security"] is True
|
||||||
|
assert updates["openssl"]["origin"] == "noble-updates,noble-security"
|
||||||
|
assert updates["docker-compose-plugin"]["security"] is False
|
||||||
|
|
||||||
|
def test_apt_that_could_not_read_raises_instead_of_reporting_nothing(self, driver):
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
_mock_send(driver, "E: Could not open lock file\n__APT_RC=100\n")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
def test_apt_without_an_exit_status_raises(self, driver):
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
_mock_send(driver, "openssl/noble-security 3.0.13-0ubuntu3.6 amd64 [upgradable fro")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
def test_dnf_marks_what_a_security_advisory_covers(self, driver):
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"0", # id -u
|
||||||
|
"openssl-libs.x86_64 1:3.1.4-2.fc40 updates\n"
|
||||||
|
"vim-enhanced.x86_64 2:9.1.083-1.fc40 updates\n__NETORK_RC=100",
|
||||||
|
"FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64\n__NETORK_RC=0",
|
||||||
|
]
|
||||||
|
|
||||||
|
updates = {u["name"]: u for u in driver.get_available_updates()}
|
||||||
|
|
||||||
|
assert updates["openssl-libs"]["security"] is True
|
||||||
|
assert updates["vim-enhanced"]["security"] is False
|
||||||
|
|
||||||
|
def test_dnf_without_advisories_leaves_security_unknown(self, driver):
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
driver._device.send_command.side_effect = [
|
||||||
|
"0",
|
||||||
|
"vim-enhanced.x86_64 2:9.1.083-1.fc40 updates\n__NETORK_RC=100",
|
||||||
|
"Error: updateinfo metadata missing\n__NETORK_RC=1",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert driver.get_available_updates()[0]["security"] is None
|
||||||
|
|
||||||
|
def test_dnf_that_failed_raises(self, driver):
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
driver._device.send_command.side_effect = ["0", "Error: Failed to download metadata\n__NETORK_RC=1"]
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
driver.get_available_updates()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefreshAvailableUpdates:
|
||||||
|
def _sent(self, driver) -> list:
|
||||||
|
return [c[0][0] for c in driver._device.send_command.call_args_list]
|
||||||
|
|
||||||
|
def test_apt_refreshes_its_index_as_root(self, driver):
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
driver._device.send_command.side_effect = ["0", "Hit:1 http://archive.ubuntu.com noble InRelease\n__NETORK_RC=0"]
|
||||||
|
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert "apt-get update" in self._sent(driver)[1]
|
||||||
|
|
||||||
|
def test_without_a_sudo_password_it_never_waits_for_one(self, driver):
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
driver._device.send_command.side_effect = ["1000", "sudo: a password is required\n__NETORK_RC=1"]
|
||||||
|
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert self._sent(driver)[1].startswith("sudo -n apt-get update")
|
||||||
|
|
||||||
|
def test_dnf_refreshes_its_metadata(self, driver):
|
||||||
|
driver._pkg_manager = "dnf"
|
||||||
|
driver._device.send_command.side_effect = ["0", "Metadata cache created.\n__NETORK_RC=0"]
|
||||||
|
|
||||||
|
assert driver.refresh_available_updates()["success"] is True
|
||||||
|
assert "dnf makecache" in self._sent(driver)[1]
|
||||||
|
|
||||||
|
def test_pacman_is_not_refreshed_on_its_own(self, driver):
|
||||||
|
"""pacman -Sy without -u invites a partial upgrade on the next install."""
|
||||||
|
driver._pkg_manager = "pacman"
|
||||||
|
|
||||||
|
result = driver.refresh_available_updates()
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
driver._device.send_command.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostStatus:
|
||||||
|
def test_the_driver_carries_the_shared_command(self, driver):
|
||||||
|
from napalm_device_types.host_status import HOST_STATUS_COMMAND
|
||||||
|
|
||||||
|
_mock_send(
|
||||||
|
driver,
|
||||||
|
"HSTAT_BEGIN\n[reboot-required]\n[kernel]\n6.8.0-142-generic\n[modules]\n"
|
||||||
|
"6.8.0-142-generic\n[timers]\napt-daily-upgrade.timer enabled\nHSTAT_END\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = driver.get_host_status()
|
||||||
|
|
||||||
|
assert driver._device.send_command.call_args[0][0] == HOST_STATUS_COMMAND
|
||||||
|
assert status["reboot_required"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminalCodes:
|
||||||
|
def test_a_status_marker_behind_a_terminal_code_is_still_read(self, driver):
|
||||||
|
"""apt-get on a pseudo-terminal leaves keypad codes in front of the marker."""
|
||||||
|
driver._pkg_manager = "apt"
|
||||||
|
driver._device.send_command.side_effect = ["0", "Hit:1 noble InRelease\n\x1b>__NETORK_RC=0"]
|
||||||
|
|
||||||
|
assert driver.refresh_available_updates()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
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