An `amt` driver (napalm_amt.AmtDriver), a ManagementControllerDriver for the management controller of Intel vPro machines, with its own lean WS-Management client (requests + ElementTree, HTTP Digest, ports 16992/16993). It reads the machine's identity, hardware with the SMBIOS system UUID, firmware (AMT with build, BIOS), power state and the hardware event log, reports what AMT listens on (plain HTTP or TLS, KVM incl. port 5900, IDE-R), and switches the machine's power. AMT cannot be reset over WS-Management, so there is no reboot_host. Discovery recognises AMT by its ports and its Server header. Tested against Intel's published AMT 12 recordings (go-wsman-messages, Apache-2.0, see NOTICE). `python -m napalm_amt.record` records a real AMT read-only; the first test devices are two Lenovo ThinkCentre M910x.
328 lines
10 KiB
Python
328 lines
10 KiB
Python
"""The driver end to end against the recorded NUC9 (AMT 12.0.67), served over mocked HTTP."""
|
|
|
|
import pytest
|
|
import requests
|
|
import responses
|
|
from napalm.base.exceptions import ConnectionException
|
|
from napalm_device_types import ManagementControllerDriver
|
|
|
|
from napalm_amt import AmtDriver
|
|
|
|
from .conftest import HOST, URL, load, patched
|
|
|
|
|
|
@pytest.fixture
|
|
def driver(amt):
|
|
d = AmtDriver(HOST, "admin", "P@ssw0rd")
|
|
d.open()
|
|
yield d
|
|
d.close()
|
|
|
|
|
|
def test_is_a_management_controller():
|
|
assert issubclass(AmtDriver, ManagementControllerDriver)
|
|
assert AmtDriver.ROLE == "management_controller"
|
|
assert AmtDriver.DRIVER_NAME == "amt"
|
|
assert AmtDriver.default_port == 16992
|
|
|
|
|
|
def test_does_not_offer_a_controller_reset():
|
|
# AMT cannot be restarted over WS-Management; the role method stays absent so
|
|
# netOrk does not offer it.
|
|
assert not hasattr(AmtDriver, "reboot_host")
|
|
|
|
|
|
def test_tls_follows_the_port():
|
|
assert AmtDriver(HOST, "u", "p").transport.url == f"http://{HOST}:16992/wsman"
|
|
tls = AmtDriver(HOST, "u", "p", optional_args={"port": 16993, "verify_ssl": False})
|
|
assert tls.transport.url == f"https://{HOST}:16993/wsman"
|
|
assert tls.transport.session.verify is False
|
|
|
|
|
|
# --- connection ------------------------------------------------------------------------
|
|
|
|
|
|
def test_open_reads_the_general_settings(amt):
|
|
d = AmtDriver(HOST, "admin", "P@ssw0rd")
|
|
d.open()
|
|
assert [r.resource for r in amt.requests] == ["AMT_GeneralSettings"]
|
|
assert d.is_alive() == {"is_alive": True}
|
|
|
|
|
|
def test_open_with_wrong_credentials_fails():
|
|
with responses.RequestsMock() as rsps:
|
|
rsps.add(
|
|
responses.POST,
|
|
URL,
|
|
status=401,
|
|
headers={"WWW-Authenticate": 'Digest realm="Digest:0", nonce="n", qop="auth"'},
|
|
)
|
|
with pytest.raises(ConnectionException, match="credentials"):
|
|
AmtDriver(HOST, "admin", "wrong", optional_args={"retries": 0}).open()
|
|
|
|
|
|
def test_open_without_an_answer_fails():
|
|
with responses.RequestsMock() as rsps:
|
|
rsps.add(responses.POST, URL, body=requests.ConnectionError("refused"))
|
|
with pytest.raises(ConnectionException):
|
|
AmtDriver(HOST, "admin", "pw", optional_args={"retries": 0}).open()
|
|
|
|
|
|
def test_is_alive_asks_amt(driver, amt):
|
|
amt.override("AMT_GeneralSettings", "Get", 500, "<html>gone</html>")
|
|
assert driver.is_alive() == {"is_alive": False}
|
|
|
|
|
|
def test_is_alive_before_open_is_false():
|
|
assert AmtDriver(HOST, "u", "p").is_alive() == {"is_alive": False}
|
|
|
|
|
|
# --- NAPALM getters --------------------------------------------------------------------
|
|
|
|
|
|
def test_facts(driver):
|
|
assert driver.get_facts() == {
|
|
"uptime": -1.0,
|
|
"vendor": "Intel(R) Client Systems",
|
|
"model": "NUC9V7QNX",
|
|
"hostname": "Test Host Name",
|
|
"fqdn": "Test Host Name.Test Domain Name",
|
|
"os_version": "12.0.67.1579",
|
|
"serial_number": "JRQN0243007J",
|
|
"interface_list": ["wired"],
|
|
}
|
|
|
|
|
|
def test_facts_without_an_amt_hostname_use_the_address(amt):
|
|
amt.override(
|
|
"AMT_GeneralSettings",
|
|
"Get",
|
|
200,
|
|
patched(
|
|
"AMT_GeneralSettings.get.xml",
|
|
"<g:HostName>Test Host Name</g:HostName>",
|
|
"<g:HostName></g:HostName>",
|
|
),
|
|
)
|
|
d = AmtDriver(HOST, "admin", "pw")
|
|
d.open()
|
|
facts = d.get_facts()
|
|
assert facts["hostname"] == HOST
|
|
assert facts["fqdn"] == HOST
|
|
|
|
|
|
def test_interfaces(driver):
|
|
assert driver.get_interfaces() == {
|
|
"wired": {
|
|
"is_up": True,
|
|
"is_enabled": True,
|
|
"description": "Intel AMT, wired, DHCP, shares the host's IP address",
|
|
"last_flapped": -1.0,
|
|
"speed": -1.0,
|
|
"mtu": -1,
|
|
"mac_address": "00:be:43:d8:22:a4",
|
|
}
|
|
}
|
|
|
|
|
|
def test_interfaces_ip_without_an_address_is_empty(driver):
|
|
# In DHCP mode the NUC9 reports no address of its own.
|
|
assert driver.get_interfaces_ip() == {}
|
|
|
|
|
|
def test_interfaces_ip_with_a_static_address(amt):
|
|
pull = patched(
|
|
"AMT_EthernetPortSettings.pull.xml",
|
|
"<h:DHCPEnabled>true</h:DHCPEnabled>",
|
|
"<h:DHCPEnabled>false</h:DHCPEnabled><h:IPAddress>172.22.8.61</h:IPAddress>",
|
|
1,
|
|
)
|
|
amt.override("AMT_EthernetPortSettings", "Pull", 200, pull)
|
|
d = AmtDriver(HOST, "admin", "pw")
|
|
d.open()
|
|
assert d.get_interfaces_ip() == {"wired": {"ipv4": {"172.22.8.61": {"prefix_length": 24}}}}
|
|
assert "static" in d.get_interfaces()["wired"]["description"]
|
|
|
|
|
|
def test_environment_is_empty(driver):
|
|
# AMT on a client platform reports no temperatures, fans or power supplies.
|
|
assert driver.get_environment() == {
|
|
"fans": {},
|
|
"temperature": {},
|
|
"power": {},
|
|
"cpu": {},
|
|
"memory": {},
|
|
}
|
|
|
|
|
|
# --- role methods ----------------------------------------------------------------------
|
|
|
|
|
|
def test_power_state(driver):
|
|
assert driver.get_power_state() == {
|
|
"state": "On",
|
|
"actions": [
|
|
"GracefulShutdown",
|
|
"ForceOff",
|
|
"GracefulRestart",
|
|
"ForceRestart",
|
|
"PowerCycle",
|
|
],
|
|
}
|
|
|
|
|
|
def test_set_power_state_asks_amt_for_the_cim_state(driver, amt):
|
|
driver.set_power_state("ForceOff")
|
|
(request,) = amt.calls("RequestPowerStateChange")
|
|
assert "<h:PowerState>8</h:PowerState>" in request.xml
|
|
assert '<Selector Name="Name">ManagedSystem</Selector>' in request.xml
|
|
|
|
|
|
def test_set_power_state_refuses_an_unknown_action(driver):
|
|
with pytest.raises(ValueError):
|
|
driver.set_power_state("Explode")
|
|
|
|
|
|
def test_set_power_state_refuses_on_for_a_running_machine(driver, amt):
|
|
with pytest.raises(RuntimeError, match="not accepted"):
|
|
driver.set_power_state("On")
|
|
assert amt.calls("RequestPowerStateChange") == []
|
|
|
|
|
|
def test_set_power_state_reports_amt_refusing(driver, amt):
|
|
amt.override(
|
|
"CIM_PowerManagementService",
|
|
"RequestPowerStateChange",
|
|
200,
|
|
patched(
|
|
"CIM_PowerManagementService.RequestPowerStateChange.xml",
|
|
"<g:ReturnValue>0</g:ReturnValue>",
|
|
"<g:ReturnValue>2</g:ReturnValue>",
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match="refused.*2"):
|
|
driver.set_power_state("ForceRestart")
|
|
|
|
|
|
def test_set_power_state_accepts_a_started_job(driver, amt):
|
|
amt.override(
|
|
"CIM_PowerManagementService",
|
|
"RequestPowerStateChange",
|
|
200,
|
|
patched(
|
|
"CIM_PowerManagementService.RequestPowerStateChange.xml",
|
|
"<g:ReturnValue>0</g:ReturnValue>",
|
|
"<g:ReturnValue>4096</g:ReturnValue>",
|
|
),
|
|
)
|
|
driver.set_power_state("PowerCycle")
|
|
|
|
|
|
def test_hardware_inventory(driver):
|
|
inventory = driver.get_hardware_inventory()
|
|
assert {k: v for k, v in inventory.items() if not isinstance(v, list)} == {
|
|
"manufacturer": "Intel(R) Client Systems",
|
|
"model": "NUC9V7QNX",
|
|
"serial_number": "JRQN0243007J",
|
|
"system_uuid": "55e3ae13-bfd2-61bb-17a0-88aedd7037ea",
|
|
}
|
|
assert [c["model"] for c in inventory["cpus"]] == ["Intel(R) Core(TM) i7-9850H CPU @ 2.60GHz"]
|
|
assert len(inventory["memory"]) == 2
|
|
assert len(inventory["drives"]) == 2
|
|
assert inventory["power_supplies"] == []
|
|
assert inventory["nics"] == [
|
|
{"name": "wired", "mac_address": "00:be:43:d8:22:a4", "speed_mbps": None, "link_up": True}
|
|
]
|
|
|
|
|
|
def test_hardware_inventory_survives_a_class_amt_lacks(driver, amt):
|
|
# Older AMT versions know fewer CIM classes; a missing one leaves its part empty.
|
|
amt.override(
|
|
"CIM_MediaAccessDevice", "Enumerate", 400, load("fault_destination_unreachable.xml")
|
|
)
|
|
inventory = driver.get_hardware_inventory()
|
|
assert inventory["drives"] == []
|
|
assert len(inventory["memory"]) == 2
|
|
|
|
|
|
def test_event_log_newest_first(driver, amt):
|
|
log = driver.get_event_log()
|
|
assert [e["id"] for e in log] == ["1", "2", "3"]
|
|
assert log[0]["severity"] == "Critical"
|
|
assert log[0]["timestamp"] > log[1]["timestamp"]
|
|
(position,) = amt.calls("PositionToFirstRecord")
|
|
(records,) = amt.calls("GetRecords")
|
|
assert "<h:IterationIdentifier>1</h:IterationIdentifier>" in records.xml
|
|
|
|
|
|
def test_event_log_limit(driver):
|
|
assert len(driver.get_event_log(limit=1)) == 1
|
|
|
|
|
|
def test_event_log_empty(driver, amt):
|
|
amt.override(
|
|
"AMT_MessageLog",
|
|
"PositionToFirstRecord",
|
|
200,
|
|
patched(
|
|
"AMT_MessageLog.PositionToFirstRecord.xml",
|
|
"<g:ReturnValue>0</g:ReturnValue>",
|
|
"<g:ReturnValue>2</g:ReturnValue>",
|
|
),
|
|
)
|
|
assert driver.get_event_log() == []
|
|
assert amt.calls("GetRecords") == []
|
|
|
|
|
|
def test_event_log_reads_on_until_no_more_records(driver, amt):
|
|
first_page = patched(
|
|
"AMT_MessageLog.GetRecords.xml",
|
|
"<g:NoMoreRecords>true</g:NoMoreRecords>",
|
|
"<g:NoMoreRecords>false</g:NoMoreRecords>",
|
|
)
|
|
pages = iter([first_page, load("AMT_MessageLog.GetRecords.xml")])
|
|
|
|
original = amt._answer
|
|
|
|
def answer(req):
|
|
if req.operation == "GetRecords":
|
|
return 200, next(pages)
|
|
return original(req)
|
|
|
|
amt._answer = answer
|
|
log = driver.get_event_log()
|
|
assert len(log) == 6
|
|
second = amt.calls("GetRecords")[1]
|
|
assert "<h:IterationIdentifier>3</h:IterationIdentifier>" in second.xml
|
|
|
|
|
|
def test_firmware_inventory(driver):
|
|
assert driver.get_firmware_inventory() == [
|
|
{"name": "Intel AMT", "version": "12.0.67.1579", "updateable": None},
|
|
{"name": "BIOS", "version": "QNCFLX70.0054.2020.0810.2227", "updateable": None},
|
|
]
|
|
|
|
|
|
def test_management_protocols(driver):
|
|
protocols = driver.get_management_protocols()
|
|
assert protocols["http"] == {"enabled": True, "port": 16992}
|
|
assert protocols["https"] == {"enabled": False, "port": 16993}
|
|
assert protocols["kvm"] == {"enabled": True, "port": 16994}
|
|
|
|
|
|
def test_reads_are_cached_per_connection(driver, amt):
|
|
driver.get_facts()
|
|
driver.get_hardware_inventory()
|
|
before = len(amt.requests)
|
|
driver.get_facts()
|
|
assert len(amt.requests) == before
|
|
driver.close()
|
|
driver.open()
|
|
driver.get_facts()
|
|
assert len(amt.requests) > before
|
|
|
|
|
|
def test_getters_before_open_raise():
|
|
with pytest.raises(ConnectionException):
|
|
AmtDriver(HOST, "u", "p").get_facts()
|