feat: NAPALM driver for Windows over PowerShell Remoting

Talks PSRP over WinRM via pypsrp, one runspace pool per connection. Each
getter sends one PowerShell script that projects cmdlet results onto flat
fields and ends in ConvertTo-Json, so the Python side parses JSON, not text.

Covers facts, interfaces, IP addresses, ARP, routes and services, plus
service start/stop/restart/enable/disable. Service names are validated and
quoted as PowerShell verbatim strings, typographic quotes included.

Fixtures are synthetic: they pin down the JSON the scripts are designed to
emit. tools/harvest.py records the real output from a host.

Refs christianmanivong/netork#300
This commit is contained in:
Christian Manivong
2026-09-24 09:23:25 +02:00
commit 1ce42ef099
18 changed files with 1435 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Unit tests for the PSRP transport.
pypsrp is patched out at the module boundary: these tests pin down how the
transport configures it and how its failures reach the driver, not pypsrp itself.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
import requests
from napalm.base.exceptions import ConnectionClosedException, ConnectionException
from pypsrp.exceptions import AuthenticationError, WinRMTransportError
from napalm_windows.transport import PowerShellError, PsrpTransport
def _transport(**overrides):
kwargs = {
"port": 5986,
"ssl": True,
"cert_validation": True,
"auth": "negotiate",
"timeout": 60,
}
kwargs.update(overrides)
return PsrpTransport("win01", "admin", "secret", **kwargs)
@pytest.fixture()
def psrp():
"""Patch WSMan, RunspacePool and PowerShell as the transport sees them."""
with (
patch("napalm_windows.transport.WSMan") as wsman,
patch("napalm_windows.transport.RunspacePool") as pool,
patch("napalm_windows.transport.PowerShell") as ps,
):
yield {"wsman": wsman, "pool": pool, "ps": ps}
class TestOpen:
def test_passes_connection_settings_to_wsman(self, psrp):
_transport(port=5985, ssl=False, cert_validation=False, auth="ntlm").open()
kwargs = psrp["wsman"].call_args.kwargs
assert psrp["wsman"].call_args.args == ("win01",)
assert kwargs["port"] == 5985
assert kwargs["ssl"] is False
assert kwargs["cert_validation"] is False
assert kwargs["auth"] == "ntlm"
assert kwargs["username"] == "admin"
assert kwargs["password"] == "secret" # noqa: S105
def test_opens_one_runspace_pool_on_the_connection(self, psrp):
_transport().open()
psrp["pool"].assert_called_once_with(psrp["wsman"].return_value)
psrp["pool"].return_value.open.assert_called_once_with()
def test_authentication_failure_is_a_connection_exception(self, psrp):
psrp["pool"].return_value.open.side_effect = AuthenticationError("bad creds")
with pytest.raises(ConnectionException, match="Authentication failed"):
_transport().open()
def test_http_401_is_an_authentication_failure(self, psrp):
psrp["pool"].return_value.open.side_effect = WinRMTransportError(
"http", 401, "Unauthorized"
)
with pytest.raises(ConnectionException, match="Authentication failed"):
_transport().open()
def test_unreachable_host_is_a_connection_exception(self, psrp):
psrp["pool"].return_value.open.side_effect = requests.ConnectionError("refused")
with pytest.raises(ConnectionException, match="win01:5986"):
_transport().open()
def test_is_open_only_after_open(self, psrp):
t = _transport()
assert t.is_open is False
t.open()
assert t.is_open is True
class TestRun:
def test_returns_output_objects_joined_by_newline(self, psrp):
psrp["ps"].return_value.invoke.return_value = ['{"a":', "1}"]
psrp["ps"].return_value.had_errors = False
t = _transport()
t.open()
assert t.run("Get-Thing") == '{"a":\n1}'
psrp["ps"].return_value.add_script.assert_called_once_with("Get-Thing")
def test_none_objects_are_skipped(self, psrp):
psrp["ps"].return_value.invoke.return_value = [None, "x", None]
psrp["ps"].return_value.had_errors = False
t = _transport()
t.open()
assert t.run("Get-Thing") == "x"
def test_error_stream_raises_powershell_error_with_its_text(self, psrp):
psrp["ps"].return_value.invoke.return_value = []
psrp["ps"].return_value.had_errors = True
psrp["ps"].return_value.streams.error = [
"Cannot find any service with service name 'nope'."
]
t = _transport()
t.open()
with pytest.raises(PowerShellError, match="service name 'nope'"):
t.run("Start-Service nope")
def test_run_before_open_raises_connection_closed(self):
with pytest.raises(ConnectionClosedException):
_transport().run("Get-Thing")
class TestClose:
def test_close_closes_pool_and_connection(self, psrp):
t = _transport()
t.open()
t.close()
psrp["pool"].return_value.close.assert_called_once_with()
psrp["wsman"].return_value.close.assert_called_once_with()
assert t.is_open is False
def test_close_swallows_errors_from_a_dead_connection(self, psrp):
psrp["pool"].return_value.close.side_effect = requests.ConnectionError("gone")
t = _transport()
t.open()
t.close()
assert t.is_open is False
def test_close_without_open_is_a_no_op(self):
_transport().close()
def test_transport_does_not_print_password_in_repr():
assert "secret" not in repr(_transport())