chore: adopt the NAPALM driver standard #2

Merged
christianmanivong merged 4 commits from chore/driver-standard into main 2026-10-23 09:28:20 +00:00
9 changed files with 135 additions and 33 deletions

No files matched your search

+15
View File
@@ -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 }}
+3
View File
@@ -5,3 +5,6 @@ build/
dist/ dist/
.venv/ .venv/
.pytest_cache/ .pytest_cache/
.coverage
.mypy_cache/
.ruff_cache/
+25 -2
View File
@@ -1,12 +1,35 @@
# Changelog # Changelog
## 0.1.1 All notable changes to this driver. The format follows
[Keep a Changelog](https://keepachangelog.com/), versions follow
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.2.0] - 2026-10-23
The first release on PyPI.
### Changed
- The driver follows 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. Python 3.10 stays the floor; the
license is declared as the SPDX string `Apache-2.0`, next to the LICENSE file and
`py.typed` the package already shipped. napalm-device-types now comes from PyPI
(4.21.1 or later).
- AMT's answers are parsed with defusedxml (a new dependency). An answer that carries a
DTD is refused in any encoding; before, one in UTF-16 got through.
## [0.1.1] - 2026-10-18
### Fixed
- `SNMP_CAPABLE = False`: AMT runs no SNMP agent, so netOrk does not poll it over SNMP - `SNMP_CAPABLE = False`: AMT runs no SNMP agent, so netOrk does not poll it over SNMP
as it does other management controllers. as it does other management controllers.
## 0.1.0 ## [0.1.0] - 2026-10-18
### Added
- `amt` driver (`napalm_amt.AmtDriver`), a `ManagementControllerDriver` for Intel AMT over - `amt` driver (`napalm_amt.AmtDriver`), a `ManagementControllerDriver` for Intel AMT over
WS-Management: facts, AMT's network port, hardware inventory with the SMBIOS system WS-Management: facts, AMT's network port, hardware inventory with the SMBIOS system
UUID, firmware (AMT with build, BIOS), power state and power control, the hardware event UUID, firmware (AMT with build, BIOS), power state and power control, the hardware event
+7 -6
View File
@@ -29,8 +29,8 @@ nothing with napalm-redfish beyond the role, so it lives here.
`optional_args`: `port` (16992; 16993 means TLS), `tls` (overrides what the port `optional_args`: `port` (16992; 16993 means TLS), `tls` (overrides what the port
implies), `verify_ssl` (`True`, a CA file path, or `False`), `retries` (1). implies), `verify_ssl` (`True`, a CA file path, or `False`), `retries` (1).
The WS-Management client in `napalm_amt/wsman.py` is self-contained (requests and The WS-Management client in `napalm_amt/wsman.py` is self-contained (requests, and
ElementTree). Neither `pywsman` (a C binding) nor `python-amt` (unmaintained, power only) defusedxml to parse the answers). Neither `pywsman` (a C binding) nor `python-amt` (unmaintained, power only)
is used. is used.
## What AMT knows, and what it does not ## What AMT knows, and what it does not
@@ -54,12 +54,13 @@ of its own in the MEBx ([docs/lenovo-m910x.md](docs/lenovo-m910x.md)).
## Development ## Development
The rules are in [STANDARDS.md](https://git.netork.io/NAPALM/driver-standard/src/branch/main/STANDARDS.md).
```bash ```bash
python -m venv .venv python -m venv .venv
.venv/bin/pip install -e ../napalm-device-types # not on PyPI .venv/bin/pip install -e '.[dev]'
.venv/bin/pip install -e '.[test]' ruff mypy types-requests .venv/bin/pytest --cov
.venv/bin/python -m pytest .venv/bin/ruff format --check . && .venv/bin/ruff check . && .venv/bin/mypy napalm_amt
.venv/bin/ruff check . && .venv/bin/mypy napalm_amt
``` ```
## Conventions ## Conventions
+3 -1
View File
@@ -107,7 +107,9 @@ def _landing_page(host: str, port: int, tls: bool, out: Path) -> None:
"""The page discovery fetches, without credentials, with its status line and headers.""" """The page discovery fetches, without credentials, with its status line and headers."""
url = f"{'https' if tls else 'http'}://{host}:{port}/" url = f"{'https' if tls else 'http'}://{host}:{port}/"
try: try:
response = requests.get(url, timeout=10, verify=False, allow_redirects=True) # Recorded as discovery sees it: no credentials go out, and AMT's own certificate
# on 16993 is self-signed unless one was provisioned.
response = requests.get(url, timeout=10, verify=False, allow_redirects=True) # nosec B501
except requests.RequestException as exc: except requests.RequestException as exc:
print(f"landing page: {exc}", file=sys.stderr) print(f"landing page: {exc}", file=sys.stderr)
return return
+15 -9
View File
@@ -13,14 +13,21 @@ The envelopes follow Intel's own go-wsman-messages, which every AMT since versio
from __future__ import annotations from __future__ import annotations
import itertools import itertools
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Mapping from collections.abc import Callable, Iterable, Mapping
from typing import Any from typing import TYPE_CHECKING, Any
from xml.sax.saxutils import escape, quoteattr
# escape and quoteattr build the request envelopes; nothing here parses with SAX.
from xml.sax.saxutils import escape, quoteattr # nosec B406
import requests import requests
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from requests.auth import HTTPDigestAuth from requests.auth import HTTPDigestAuth
if TYPE_CHECKING:
# For the annotations only; answers are parsed by defusedxml.
import xml.etree.ElementTree as ET # nosec B405
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope" SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing" ADDRESSING_NS = "http://schemas.xmlsoap.org/ws/2004/08/addressing"
WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd" WSMAN_NS = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"
@@ -267,13 +274,12 @@ class WsmanTransport:
"""The SOAP body of a response; a fault, a refusal or anything else raises.""" """The SOAP body of a response; a fault, a refusal or anything else raises."""
if response.status_code == 401: if response.status_code == 401:
raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)") raise WsmanAuthError(f"{self.url}: credentials refused (HTTP 401)")
body = None # A DTD has no business in a WS-Management response; refusing one, in whatever
# A DTD has no business in a WS-Management response; refusing one keeps entity # encoding, keeps entity expansion out of the parser.
# expansion out of the parser. body: ET.Element | None
if b"<!DOCTYPE" not in response.content:
try: try:
body = ET.fromstring(response.content).find(f"{{{SOAP_NS}}}Body") body = fromstring(response.content, forbid_dtd=True).find(f"{{{SOAP_NS}}}Body")
except ET.ParseError: except (ParseError, DefusedXmlException):
body = None body = None
if body is not None: if body is not None:
fault = body.find(f"{{{SOAP_NS}}}Fault") fault = body.find(f"{{{SOAP_NS}}}Fault")
+32 -15
View File
@@ -1,22 +1,21 @@
[build-system] [build-system]
requires = ["setuptools>=64"] requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[project] [project]
name = "napalm-amt" name = "napalm-amt"
version = "0.1.1" version = "0.2.0"
description = "NAPALM driver for Intel Active Management Technology (AMT) over WS-Management" description = "NAPALM driver for Intel Active Management Technology (AMT) over WS-Management"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = "Apache-2.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"napalm>=5.0", "napalm>=5.0",
# Not on PyPI: install from git before this package (see README). "napalm-device-types>=4.21.1",
"napalm-device-types>=4.16",
"requests>=2.31", "requests>=2.31",
"defusedxml>=0.7",
] ]
classifiers = [ classifiers = [
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Topic :: System :: Hardware", "Topic :: System :: Hardware",
"Topic :: System :: Networking", "Topic :: System :: Networking",
@@ -24,12 +23,12 @@ classifiers = [
[project.urls] [project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-amt" Repository = "https://git.netork.io/NAPALM/napalm-amt"
Issues = "https://git.netork.io/NAPALM/napalm-amt/issues"
Changelog = "https://git.netork.io/NAPALM/napalm-amt/src/branch/main/CHANGELOG.md"
[project.optional-dependencies] [project.optional-dependencies]
test = [ # responses mocks AMT's HTTP answers in the tests.
"pytest>=7", dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.11", "ruff>=0.6", "responses>=0.23"]
"responses>=0.23",
]
[project.entry-points."napalm.drivers"] [project.entry-points."napalm.drivers"]
amt = "napalm_amt:AmtDriver" amt = "napalm_amt:AmtDriver"
@@ -40,19 +39,37 @@ include = ["napalm_amt*"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
napalm_amt = ["py.typed"] napalm_amt = ["py.typed"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py310" target-version = "py310"
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP"] select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Recorded device output keeps its lines.
"tests/**" = ["E501"]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
strict = true strict = true
ignore_missing_imports = true
# napalm itself is untyped, so every driver subclasses Any. # napalm itself is untyped, so every driver subclasses Any.
disallow_subclassing_any = false disallow_subclassing_any = false
[[tool.mypy.overrides]]
module = ["napalm.*", "requests.*", "defusedxml.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
# Not yet under strict mypy (quality baseline); may only shrink.
module = ["napalm_amt.driver"]
ignore_errors = true
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
source = ["napalm_amt"]
[tool.coverage.report]
fail_under = 95
+12
View File
@@ -0,0 +1,12 @@
{
"coverage_floor": 95.0,
"function_complexity": {
"napalm_amt/driver.py::AmtDriver.get_event_log": 11
},
"function_lines": {},
"mypy_relaxed": [
"napalm_amt.driver"
],
"python_file_lines": {},
"untyped_public": []
}
+23
View File
@@ -214,6 +214,29 @@ def test_a_non_soap_error_raises():
_transport(retries=0).get("AMT_GeneralSettings") _transport(retries=0).get("AMT_GeneralSettings")
_WITH_DTD = (
'<?xml version="1.0" encoding="{encoding}"?>'
'<!DOCTYPE Envelope [<!ENTITY a "aaaa">]>'
'<Envelope xmlns="http://www.w3.org/2003/05/soap-envelope"><Body>&a;</Body></Envelope>'
)
@pytest.mark.parametrize("encoding", ["utf-8", "utf-16"])
def test_an_answer_with_a_dtd_is_refused(encoding):
"""A DTD has no business in a WS-Management answer, in whatever encoding it comes."""
with responses.RequestsMock() as rsps:
rsps.add(responses.POST, URL, body=_WITH_DTD.format(encoding=encoding).encode(encoding))
with pytest.raises(WsmanError, match="no WS-Management answer"):
_transport(retries=0).get("AMT_GeneralSettings")
def test_an_answer_that_is_no_xml_raises():
with responses.RequestsMock() as rsps:
rsps.add(responses.POST, URL, status=200, body=b"<Envelope><Body>")
with pytest.raises(WsmanError, match="HTTP 200"):
_transport(retries=0).get("AMT_GeneralSettings")
def test_a_connection_error_is_retried_then_raised(): def test_a_connection_error_is_retried_then_raised():
with responses.RequestsMock() as rsps: with responses.RequestsMock() as rsps:
rsps.add(responses.POST, URL, body=requests.ConnectionError("refused")) rsps.add(responses.POST, URL, body=requests.ConnectionError("refused"))