Merge pull request 'chore: adopt the NAPALM driver standard' (#20) from chore/driver-standard into main
driver / standard (push) Successful in 14s
driver / lint (push) Successful in 11s
driver / types (push) Successful in 42s
driver / test (3.10) (push) Successful in 45s
driver / test (3.11) (push) Successful in 44s
driver / test (3.12) (push) Successful in 44s
driver / test (3.13) (push) Successful in 41s
driver / lowest (push) Successful in 23s
driver / security (push) Successful in 46s
driver / build (push) Successful in 23s
driver / publish (push) Successful in 26s
CI / driver (push) Successful in 1h1m4s
driver / standard (push) Successful in 14s
driver / lint (push) Successful in 11s
driver / types (push) Successful in 42s
driver / test (3.10) (push) Successful in 45s
driver / test (3.11) (push) Successful in 44s
driver / test (3.12) (push) Successful in 44s
driver / test (3.13) (push) Successful in 41s
driver / lowest (push) Successful in 23s
driver / security (push) Successful in 46s
driver / build (push) Successful in 23s
driver / publish (push) Successful in 26s
CI / driver (push) Successful in 1h1m4s
This commit was merged in pull request #20.
This commit is contained in:
commit
7ca6827421
10 files changed
+129
-86
No files matched your search
@@ -0,0 +1,15 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
driver:
|
||||
uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@main
|
||||
with:
|
||||
standard-ref: main
|
||||
secrets:
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
@@ -1,48 +0,0 @@
|
||||
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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2026-10-23
|
||||
|
||||
The first release on PyPI.
|
||||
|
||||
### Changed
|
||||
- The drivers follow the NAPALM driver standard
|
||||
(https://git.netork.io/NAPALM/driver-standard): CI on Gitea runs ruff, mypy, the
|
||||
tests on Python 3.10–3.13 and at the lowest declared versions, bandit, pip-audit and
|
||||
a build check, and a release tag publishes to PyPI. The package now ships `py.typed`.
|
||||
Python 3.10 stays the floor. napalm-device-types now comes from PyPI, 4.21.1 or later.
|
||||
|
||||
### Security
|
||||
- The SSH host key is verified (NetOrk/netork#1167). `open()` takes the key to expect from `optional_args["ssh_host_key"]` and refuses any other one before it logs in (`SshHostKeyMismatch`); without one it accepts the first key. Every key it sees goes to `optional_args["ssh_host_key_observer"]`. Replaces paramiko's `AutoAddPolicy`. Requires napalm-device-types 4.19.0; version 0.4.0.
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ machine, also in `hrProcessorLoad`).
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pip install "napalm-device-types @ git+https://git.netork.io/NAPALM/napalm-device-types.git"
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
+26
-18
@@ -11,22 +11,21 @@ from __future__ import annotations
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from shlex import quote
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
|
||||
from napalm_device_types import EnsurePackageMixin, OSDriver, SnmpAgentMixin
|
||||
from napalm_device_types.models import FixStepOutcomeDict, SnmpAgentSettingsDict
|
||||
from napalm_device_types.host_status import HostStatusMixin
|
||||
from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch
|
||||
from napalm_device_types.channel import (
|
||||
ByteStream,
|
||||
CommandResult,
|
||||
open_stream_on_transport,
|
||||
run_on_transport,
|
||||
)
|
||||
from napalm_device_types.host_status import HostStatusMixin
|
||||
from napalm_device_types.models import FixStepOutcomeDict, SnmpAgentSettingsDict
|
||||
from napalm_device_types.ssh_host_keys import HostKeyCheck, SshHostKeyMismatch
|
||||
|
||||
from napalm_bsd import parse
|
||||
|
||||
@@ -56,13 +55,15 @@ _PRIVILEGED_STDIN = (
|
||||
|
||||
#: Stages content without privilege: a fresh ``mktemp`` file only the login user
|
||||
#: may read, never a fixed name another user could create first. Prints its path.
|
||||
_STAGE_TEMPLATE = "/tmp/napalm-bsd-stage.XXXXXX"
|
||||
#: The path is on the device, not a local temp file.
|
||||
_STAGE_TEMPLATE = "/tmp/napalm-bsd-stage.XXXXXX" # nosec B108
|
||||
_STAGE_PREFIX = _STAGE_TEMPLATE[: -len("XXXXXX")]
|
||||
_STAGE = (
|
||||
f"umask 077 && t=$(mktemp {_STAGE_TEMPLATE}) && "
|
||||
'{ cat > "$t" && printf %s "$t" || { s=$?; rm -f -- "$t"; exit $s; }; }'
|
||||
)
|
||||
|
||||
|
||||
def _source(value: str) -> str:
|
||||
"""An allowed source as an address or network, or ValueError."""
|
||||
return str(ipaddress.ip_network(value.strip(), strict=False))
|
||||
@@ -113,7 +114,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int = 60,
|
||||
optional_args: Optional[dict] = None,
|
||||
optional_args: dict | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.username = username
|
||||
@@ -121,14 +122,14 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
self.timeout = timeout
|
||||
args = optional_args or {}
|
||||
self.port: int = int(args.get("port", 22))
|
||||
self._key_file: Optional[str] = args.get("key_file")
|
||||
self._sudo_password: Optional[str] = args.get("sudo_password")
|
||||
self._key_file: str | None = args.get("key_file")
|
||||
self._sudo_password: str | None = args.get("sudo_password")
|
||||
self._allow_agent: bool = bool(args.get("allow_agent", False))
|
||||
self._look_for_keys: bool = bool(args.get("look_for_keys", False))
|
||||
# The host key to expect, and where the one seen is reported (netork#1167).
|
||||
self._host_key = HostKeyCheck(args, hostname, self.port)
|
||||
self._client: Optional[paramiko.SSHClient] = None
|
||||
self._root: Optional[bool] = None
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
self._root: bool | None = None
|
||||
|
||||
# -- connection --------------------------------------------------------------
|
||||
|
||||
@@ -147,7 +148,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
except SshHostKeyMismatch:
|
||||
client.close() # refused before any login; nothing was sent
|
||||
raise
|
||||
except (paramiko.SSHException, socket.error) as exc:
|
||||
except (OSError, paramiko.SSHException) as exc:
|
||||
client.close()
|
||||
raise ConnectionException(f"SSH to {self.hostname} failed: {exc}") from exc
|
||||
self._client = client
|
||||
@@ -175,7 +176,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
self._root = run_on_transport(self._transport(), "id -u").stdout.strip() == "0"
|
||||
return self._root
|
||||
|
||||
def _privileged(self, command: str, privileged: bool) -> tuple[str, Optional[bytes]]:
|
||||
def _privileged(self, command: str, privileged: bool) -> tuple[str, bytes | None]:
|
||||
"""The command line that runs *command* as asked, and what goes to stdin first.
|
||||
|
||||
With a sudo password, ``sudo -S`` reads it from stdin, so it never
|
||||
@@ -194,7 +195,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
*,
|
||||
privileged: bool = False,
|
||||
timeout: float = 60,
|
||||
stdin: Optional[bytes] = None,
|
||||
stdin: bytes | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run *command* on an exec channel: no PTY, stderr apart, a real exit code.
|
||||
|
||||
@@ -217,7 +218,11 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
staged = self.run_command(_STAGE, timeout=30, stdin=content.encode())
|
||||
lines = staged.stdout.strip().splitlines()
|
||||
tmp = lines[-1] if lines else ""
|
||||
if staged.exit_code != 0 or not tmp.startswith(_STAGE_PREFIX) or "/" in tmp[len(_STAGE_PREFIX):]:
|
||||
if (
|
||||
staged.exit_code != 0
|
||||
or not tmp.startswith(_STAGE_PREFIX)
|
||||
or "/" in tmp[len(_STAGE_PREFIX) :]
|
||||
):
|
||||
reason = (staged.stderr or staged.stdout).strip() or f"could not stage {path}"
|
||||
return CommandResult(reason, "", staged.exit_code or 1)
|
||||
tmp_q, part_q, path_q = (quote(p) for p in (tmp, f"{path}.napalm-tmp", path))
|
||||
@@ -474,7 +479,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
#: net-snmp's daemon; OpenBSD's base snmpd is /usr/sbin/snmpd.
|
||||
SNMPD_DAEMON = "/usr/local/sbin/snmpd"
|
||||
|
||||
def get_management_peer(self) -> Optional[str]:
|
||||
def get_management_peer(self) -> str | None:
|
||||
"""The address this host sees the SSH connection come from (``SSH_CONNECTION``)."""
|
||||
out = self.run_command(_PEER_COMMAND, timeout=15).stdout.split()
|
||||
try:
|
||||
@@ -488,7 +493,10 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
def _step(self, command: str, done: str) -> FixStepOutcomeDict:
|
||||
result = self.run_command(command, privileged=True, timeout=600)
|
||||
output = "\n".join(filter(None, (result.stdout.strip(), result.stderr.strip())))
|
||||
return {"ok": result.exit_code == 0, "output": done if result.exit_code == 0 else output[-300:]}
|
||||
return {
|
||||
"ok": result.exit_code == 0,
|
||||
"output": done if result.exit_code == 0 else output[-300:],
|
||||
}
|
||||
|
||||
def _install_snmp_agent(self) -> FixStepOutcomeDict:
|
||||
return self._step(self.INSTALL_COMMAND.format(name="net-snmp"), "net-snmp installed")
|
||||
@@ -507,7 +515,7 @@ class BsdDriver(SnmpAgentMixin, EnsurePackageMixin, HostStatusMixin, OSDriver):
|
||||
def _start_snmp_agent(self) -> FixStepOutcomeDict:
|
||||
return self._step(self.SNMPD_START, "snmpd enabled and running")
|
||||
|
||||
def get_snmp_config(self) -> Optional[dict[str, Any]]:
|
||||
def get_snmp_config(self) -> dict[str, Any] | None:
|
||||
"""net-snmp's community and port, if its daemon runs; None otherwise."""
|
||||
if not self._out(f"pgrep -f {self.SNMPD_DAEMON}"):
|
||||
return None
|
||||
|
||||
+15
-10
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
_IFACE_HEADER = re.compile(
|
||||
r"^(?P<name>[^\s:]+): flags=[0-9a-f]+<(?P<flags>[^>]*)>(?:.*?\bmtu (?P<mtu>\d+))?"
|
||||
@@ -26,7 +26,7 @@ def _prefix_from_netmask(mask: str) -> int:
|
||||
def ifconfig(text: str) -> dict[str, dict[str, Any]]:
|
||||
"""``ifconfig -a`` -> per interface: flags, MTU, MAC, state, speed, addresses."""
|
||||
interfaces: dict[str, dict[str, Any]] = {}
|
||||
current: Optional[dict[str, Any]] = None
|
||||
current: dict[str, Any] | None = None
|
||||
for line in text.splitlines():
|
||||
header = _IFACE_HEADER.match(line)
|
||||
if header:
|
||||
@@ -69,7 +69,7 @@ def ifconfig(text: str) -> dict[str, dict[str, Any]]:
|
||||
return interfaces
|
||||
|
||||
|
||||
def _network(destination: str, family: str) -> Optional[str]:
|
||||
def _network(destination: str, family: str) -> str | None:
|
||||
"""A netstat destination as a network: ``default``, ``10.0.2/24``, ``fe80::%em0/64``."""
|
||||
if destination == "default":
|
||||
return "0.0.0.0/0" if family == "ipv4" else "::/0"
|
||||
@@ -93,7 +93,7 @@ def routes(text: str) -> list[dict[str, str]]:
|
||||
"""
|
||||
result: list[dict[str, str]] = []
|
||||
family = ""
|
||||
iface_column: Optional[int] = None
|
||||
iface_column: int | None = None
|
||||
for line in text.splitlines():
|
||||
words = line.split()
|
||||
if not words:
|
||||
@@ -253,15 +253,20 @@ def crontab(text: str, *, system: bool = False, user: str = "") -> list[dict[str
|
||||
return jobs
|
||||
|
||||
|
||||
def boottime(text: str) -> Optional[int]:
|
||||
def boottime(text: str) -> int | None:
|
||||
"""``sysctl -n kern.boottime``: FreeBSD ``{ sec = N, … }``, OpenBSD ``N``."""
|
||||
match = re.search(r"sec = (\d+)", text) or re.fullmatch(r"\s*(\d+)\s*", text)
|
||||
return int(match[1]) if match else None
|
||||
|
||||
|
||||
#: The wildcard addresses a socket listens on: reported as data, never bound to.
|
||||
_ANY_V4 = "0.0.0.0" # nosec B104
|
||||
_ANY_V6 = "::"
|
||||
|
||||
|
||||
def _socket(
|
||||
proto: str, local: str, family: str, process: Optional[str], pid: Optional[int]
|
||||
) -> Optional[dict[str, Any]]:
|
||||
proto: str, local: str, family: str, process: str | None, pid: int | None
|
||||
) -> dict[str, Any] | None:
|
||||
"""A ListeningSocketDict from a local endpoint: ``*:22``, ``[::1]:25``,
|
||||
``127.0.0.1:25``, ``[fe80::1%lo0]:25`` (sockstat, fstat) or ``*.22``,
|
||||
``::1.25``, ``fe80::1%lo0.25`` (netstat)."""
|
||||
@@ -275,7 +280,7 @@ def _socket(
|
||||
return None
|
||||
address, _, zone = address.partition("%")
|
||||
if address == "*":
|
||||
address = "0.0.0.0" if family == "4" else "::"
|
||||
address = _ANY_V4 if family == "4" else _ANY_V6
|
||||
return {
|
||||
"proto": proto,
|
||||
"address": address,
|
||||
@@ -288,9 +293,9 @@ def _socket(
|
||||
}
|
||||
|
||||
|
||||
def _unique(sockets: list[Optional[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
def _unique(sockets: list[dict[str, Any] | None]) -> list[dict[str, Any]]:
|
||||
"""One entry per socket, the first process holding it, as ss reports it."""
|
||||
seen: set[tuple] = set()
|
||||
seen: set[tuple[Any, ...]] = set()
|
||||
result = []
|
||||
for s in sockets:
|
||||
if s is None:
|
||||
|
||||
Whitespace-only changes.
+43
-6
@@ -1,14 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "napalm-bsd"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
description = "NAPALM drivers for FreeBSD and OpenBSD hosts via SSH"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "Apache-2.0" }
|
||||
license = "Apache-2.0"
|
||||
authors = [
|
||||
{ name = "Christian Manivong", email = "christian@manivong.de" },
|
||||
]
|
||||
@@ -16,22 +16,23 @@ keywords = ["napalm", "network", "automation", "freebsd", "openbsd", "bsd", "ssh
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Systems Administration",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"napalm>=4.0",
|
||||
"napalm-device-types>=4.19.0", # fixes: SnmpAgentMixin, EnsurePackageMixin
|
||||
"napalm-device-types>=4.21.1",
|
||||
"paramiko>=5.0.0", # CVE-2026-44405
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest", "pytest-cov", "ruff", "mypy"]
|
||||
dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6"]
|
||||
|
||||
[project.entry-points."napalm.drivers"]
|
||||
freebsd = "napalm_bsd:FreeBSDDriver"
|
||||
@@ -39,11 +40,47 @@ openbsd = "napalm_bsd:OpenBSDDriver"
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.netork.io/NAPALM/napalm-bsd"
|
||||
Issues = "https://git.netork.io/NAPALM/napalm-bsd/issues"
|
||||
Changelog = "https://git.netork.io/NAPALM/napalm-bsd/src/branch/main/CHANGELOG.md"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["napalm_bsd*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
napalm_bsd = ["py.typed"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Recorded device output keeps its lines.
|
||||
"tests/**" = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
strict = true
|
||||
# napalm itself is untyped, so every driver subclasses Any.
|
||||
disallow_subclassing_any = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["napalm.*", "paramiko.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
# Not yet under strict mypy (quality baseline); may only shrink.
|
||||
module = ["napalm_bsd.base", "napalm_bsd.freebsd", "napalm_bsd.openbsd"]
|
||||
ignore_errors = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["napalm_bsd"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 94
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"coverage_floor": 94.0,
|
||||
"function_complexity": {
|
||||
"napalm_bsd/parse.py::ifconfig": 21,
|
||||
"napalm_bsd/parse.py::routes": 21,
|
||||
"napalm_bsd/parse.py::users": 11
|
||||
},
|
||||
"function_lines": {},
|
||||
"mypy_relaxed": [
|
||||
"napalm_bsd.base",
|
||||
"napalm_bsd.freebsd",
|
||||
"napalm_bsd.openbsd"
|
||||
],
|
||||
"python_file_lines": {},
|
||||
"untyped_public": []
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import pytest
|
||||
from napalm.base import NetworkDriver
|
||||
from napalm_device_types import OSDriver
|
||||
|
||||
from napalm_bsd import FreeBSDDriver, OpenBSDDriver
|
||||
from napalm_bsd import base
|
||||
from napalm_bsd import FreeBSDDriver, OpenBSDDriver, base
|
||||
from napalm_bsd.base import BsdDriver
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
@@ -600,7 +599,8 @@ class TestPrivilegedStdin:
|
||||
def _driver(self, monkeypatch, *, sudo_password, root=False):
|
||||
sent = []
|
||||
monkeypatch.setattr(
|
||||
base, "run_on_transport",
|
||||
base,
|
||||
"run_on_transport",
|
||||
lambda transport, line, stdin=None, timeout=60: sent.append((line, stdin)) or _Result(),
|
||||
)
|
||||
args = {"sudo_password": sudo_password} if sudo_password else {}
|
||||
|
||||
Reference in new issue
Block a user