feat: packages, services and updates
CI / test (3.10) (push) Successful in 26s
CI / test (3.11) (push) Successful in 24s
CI / test (3.12) (push) Successful in 26s
CI / test (3.10) (pull_request) Successful in 25s
CI / test (3.11) (pull_request) Successful in 24s
CI / test (3.12) (pull_request) Successful in 25s

The write side NetOrk/netork#799 needs, on both drivers.

- Packages: get_packages (FreeBSD `pkg query` with the repository as
  source, nothing on a classic system without pkg; OpenBSD `pkg_info`),
  install_package / uninstall_package as root (`pkg install`/`pkg
  delete`, `pkg_add -I`/`pkg_delete`), names checked before anything is
  sent, a failure raised with what the tool printed.
- Updates: get_available_updates. FreeBSD `pkg upgrade -n`, with
  `security` from VuXML (`pkg audit`: True for a listed package, False for
  any other, None when the audit could not run); OpenBSD `pkg_add -u -n
  -v` (no security verdict). Base-system patches are one `base-system`
  entry, as decided in #799: OpenBSD's `syspatch -c`, and on a classic
  FreeBSD what `freebsd-update` has fetched; on pkgbase the base system is
  packages from FreeBSD-base.
- Services: get_services lists the enabled ones (FreeBSD `service ...
  status` as root, as root-only pidfiles hide daemons otherwise, without
  root when sudo refuses; OpenBSD `rcctl check`), manage_service runs
  start/stop/restart/enable/disable as root and returns success and
  output.

Fixtures recorded on the FreeBSD 15.1 and OpenBSD 7.9 VMs with genuinely
outdated packages (FreeBSD pointed at the latest branch, an OpenBSD
package taken back to its release build). Checked live on both, including
a service restart and installing and removing a package.
This commit is contained in:
2026-10-08 09:59:04 +02:00
parent 045f809602
commit e8468a292a
24 changed files with 1703 additions and 3 deletions
+67
View File
@@ -9,6 +9,7 @@ stdin, as napalm-linux does it.
from __future__ import annotations
import logging
import re
import socket
from shlex import quote
from typing import Any, Optional
@@ -27,6 +28,14 @@ from napalm_bsd import parse
logger = logging.getLogger(__name__)
#: The update-list entry for base-system patches (OpenBSD syspatch, classic
#: FreeBSD freebsd-update): one entry, as they are applied together (#799).
BASE_SYSTEM = "base-system"
_SERVICE_ACTIONS = frozenset({"start", "stop", "restart", "enable", "disable"})
_SERVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
_PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")
class BsdDriver(OSDriver):
"""Base for the BSD drivers; a concrete one names the commands that differ."""
@@ -277,6 +286,64 @@ class BsdDriver(OSDriver):
"sockets": self._parse_listening(plain.stdout, attributed=False),
}
# -- packages -----------------------------------------------------------------
#: ``{name}`` is the package (with its version, when one is asked for).
INSTALL_COMMAND = ""
UNINSTALL_COMMAND = ""
def _package_command(self, template: str, name: str) -> None:
"""Run a package command as root; a failure raises with what it printed."""
result = self.run_command(template.format(name=name), privileged=True, timeout=600)
if result.exit_code != 0:
raise RuntimeError(
(result.stderr or result.stdout).strip() or f"{name}: exit {result.exit_code}"
)
def install_package(self, name: str, version: str = "") -> None:
if not _PACKAGE_NAME.match(name) or (version and not _PACKAGE_NAME.match(version)):
raise ValueError(f"Not a package name: {name!r}")
self._package_command(self.INSTALL_COMMAND, f"{name}-{version}" if version else name)
def uninstall_package(self, name: str) -> None:
if not _PACKAGE_NAME.match(name):
raise ValueError(f"Not a package name: {name!r}")
self._package_command(self.UNINSTALL_COMMAND, name)
# -- services -----------------------------------------------------------------
#: Prints ``name<TAB>status`` per enabled service; parsed by :meth:`_parse_services`.
SERVICE_STATUS_COMMAND = ""
#: ``{name}`` and ``{action}`` (start, stop, restart, enable, disable).
SERVICE_ACTION_COMMAND = ""
def _parse_services(self, output: str) -> list[dict[str, Any]]:
raise NotImplementedError
def get_services(self) -> list[dict[str, Any]]:
"""The enabled services, whether each runs, and its PID where known.
Read as root where the status needs it, and without root otherwise.
"""
result = self.run_command(self.SERVICE_STATUS_COMMAND, privileged=True, timeout=120)
if result.exit_code != 0 or not result.stdout.strip(): # no root: sudo refused
result = self.run_command(self.SERVICE_STATUS_COMMAND, timeout=120)
return self._parse_services(result.stdout)
def manage_service(self, name: str, action: str) -> dict[str, Any]:
"""Applies *action* to the service *name*, as root.
:returns: ``{"success": bool, "output": str}``
:raises ValueError: for an unknown action or an invalid name, before
anything is sent.
"""
if action not in _SERVICE_ACTIONS or not _SERVICE_NAME.match(name):
raise ValueError(f"Cannot {action!r} service {name!r}")
command = self.SERVICE_ACTION_COMMAND.format(name=name, action=action)
result = self.run_command(command, privileged=True, timeout=120)
output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
return {"success": result.exit_code == 0, "output": output}
# -- accounts, processes, cron -----------------------------------------------
def get_users(self) -> list[dict[str, Any]]: