From b4665bb534ebd85398b0935bd5d34bfe8ea73c0c Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Thu, 1 Oct 2026 15:48:35 -0500 Subject: [PATCH 1/2] feat(ironic): add PAN-OS inspect and management interfaces Give the panos hardware type its own inspect and management interfaces that talk to the appliance over the PAN-OS XML API at driver_info['management_ip'], keeping the netdev no-op interfaces as fallbacks. Inspection logs in using the [netdev_panos] credentials, trying the standard, preconfig, then factory password so it works at any point in an appliance's setup, and records the model, serial, and firmware version from show system info. Model and serial set by enroll-fw are not overwritten. LLDP port discovery, HA peer discovery, initial configuration, and the cleaning steps are stubbed with their intended behavior documented. The initial configuration verify step only logs so enrollment isn't blocked; the manual clean steps fail rather than report work that wasn't done. Co-Authored-By: Doug Goldstein --- components/ironic/values.yaml | 4 +- docs/design-guide/ironic.md | 27 +++ .../ironic_understack/conf/__init__.py | 6 + .../drivers/panos_hardware.py | 19 ++- .../drivers/panos_inspect.py | 97 +++++++++++ .../drivers/panos_management.py | 60 +++++++ .../ironic_understack/tests/test_panos_api.py | 158 ++++++++++++++++++ .../tests/test_panos_hardware.py | 24 ++- .../tests/test_panos_inspect.py | 87 ++++++++++ .../tests/test_panos_management.py | 34 ++++ .../ironic_understack/utils/panos_api.py | 111 ++++++++++++ python/ironic-understack/pyproject.toml | 5 + python/ironic-understack/uv.lock | 2 + 13 files changed, 624 insertions(+), 10 deletions(-) create mode 100644 python/ironic-understack/ironic_understack/drivers/panos_inspect.py create mode 100644 python/ironic-understack/ironic_understack/drivers/panos_management.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_panos_api.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_panos_inspect.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_panos_management.py create mode 100644 python/ironic-understack/ironic_understack/utils/panos_api.py diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 96015969a..9a22cf53e 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -77,8 +77,8 @@ conf: enabled_console_interfaces: redfish-graphical,no-console enabled_deploy_interfaces: direct,ramdisk,noop enabled_firmware_interfaces: redfish,no-firmware - enabled_inspect_interfaces: redfish,idrac-redfish,agent,no-inspect - enabled_management_interfaces: redfish,idrac-redfish,noop + enabled_inspect_interfaces: redfish,idrac-redfish,agent,panos,no-inspect + enabled_management_interfaces: redfish,idrac-redfish,panos,noop enabled_network_interfaces: neutron,noop # remove fake once there is a noop that the ManualManagement uses, which netdev is derived from enabled_power_interfaces: redfish,idrac-redfish,fake diff --git a/docs/design-guide/ironic.md b/docs/design-guide/ironic.md index b7b31a730..9da8c7681 100644 --- a/docs/design-guide/ironic.md +++ b/docs/design-guide/ironic.md @@ -20,3 +20,30 @@ bindings. To use it, add `netdev` to `enabled_hardware_types` in `ironic.conf` and ensure `noop` deploy and `neutron` network interfaces are also enabled. + +### panos + +The `panos` hardware type is a `netdev` for PAN-OS (Palo Alto) firewalls, so +these appliances can be told apart by their `driver`. It replaces the `netdev` +inspect and management interfaces with `panos` ones, which talk to the +appliance's XML API at `driver_info['management_ip']`. The no-op interfaces +remain available as fallbacks. + +- **inspect** logs in and records the model and serial (only when enroll-fw + hasn't already set them) and `firmware_version`. LLDP-based port + discovery and HA peer discovery are not implemented yet. +- **management** has a `setup_initial_configuration` verify step, which + currently only logs, and the manual clean steps `reset_to_factory_defaults` + and `clear_configuration`. Those clean steps fail with "not implemented" + instead of reporting success. + +Credentials come from the `[netdev_panos]` oslo.config group. Login tries the +standard password first, then preconfig, then factory, so it works with +appliances at any point in their setup. `[netdev_panos] verify_ssl` +(default off) controls TLS verification. + +To use it, add `panos` to `enabled_hardware_types`, `enabled_inspect_interfaces`, +and `enabled_management_interfaces`. Nodes enrolled before the `panos` +interfaces existed keep their stored `no-inspect` and `noop` interfaces until +they are switched with +`openstack baremetal node set --inspect-interface panos --management-interface panos`. diff --git a/python/ironic-understack/ironic_understack/conf/__init__.py b/python/ironic-understack/ironic_understack/conf/__init__.py index c77394295..b8eba00fb 100644 --- a/python/ironic-understack/ironic_understack/conf/__init__.py +++ b/python/ironic-understack/ironic_understack/conf/__init__.py @@ -46,6 +46,12 @@ def setup_conf(): help="PAN-OS factory password", ), cfg.StrOpt("panorama_master_key", secret=True, help="Panorama master key"), + cfg.BoolOpt( + "verify_ssl", + default=False, + help="Verify the TLS certificate of the PAN-OS XML API. Off by " + "default because appliances ship with self-signed certificates.", + ), ] panos_grp = cfg.OptGroup("netdev_panos", title="PAN-OS Credentials") cfg.CONF.register_group(panos_grp) diff --git a/python/ironic-understack/ironic_understack/drivers/panos_hardware.py b/python/ironic-understack/ironic_understack/drivers/panos_hardware.py index 226f137ec..c3a393389 100644 --- a/python/ironic-understack/ironic_understack/drivers/panos_hardware.py +++ b/python/ironic-understack/ironic_understack/drivers/panos_hardware.py @@ -1,10 +1,23 @@ +from ironic.drivers.modules import noop +from ironic.drivers.modules import noop_mgmt + from ironic_understack.drivers.netdev_hardware import NetdevHardware +from ironic_understack.drivers.panos_inspect import PanosInspect +from ironic_understack.drivers.panos_management import PanosManagement class PanosHardware(NetdevHardware): """Hardware type for PAN-OS (Palo Alto) network appliances. - Behaves exactly like :class:`NetdevHardware` today. It exists as a distinct - hardware type so a PAN-OS appliance is identifiable from the node's - ``driver`` alone. + A :class:`NetdevHardware` with PAN-OS inspect and management interfaces, + which talk to the appliance over its XML API. The netdev no-op interfaces + stay supported as fallbacks. """ + + @property + def supported_inspect_interfaces(self): + return [PanosInspect, noop.NoInspect] + + @property + def supported_management_interfaces(self): + return [PanosManagement, noop_mgmt.NoopManagement] diff --git a/python/ironic-understack/ironic_understack/drivers/panos_inspect.py b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py new file mode 100644 index 000000000..972cbbf82 --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py @@ -0,0 +1,97 @@ +"""Inspect interface for PAN-OS (Palo Alto) appliances.""" + +from typing import ClassVar + +import requests +from ironic.common import exception +from ironic.common import states +from ironic.drivers import base +from oslo_log import log + +from ironic_understack.utils import panos_api + +LOG = log.getLogger(__name__) + + +class PanosInspect(base.InspectInterface): + """Inspect a PAN-OS appliance over its XML API. + + No ramdisk is involved; everything goes over the management address in + ``driver_info['management_ip']``, which enroll-fw sets. + """ + + # A firewall has no memory_mb/local_gb/cpu_arch to report. + ESSENTIAL_PROPERTIES: ClassVar[set] = set() + + def get_properties(self): + return { + "management_ip": "Address of the appliance's management interface, " + "used for the XML API. Required.", + } + + def validate(self, task): + if not task.node.driver_info.get("management_ip"): + raise exception.MissingParameterValue( + f"Node {task.node.uuid} is missing driver_info management_ip" + ) + + def inspect_hardware(self, task): + node = task.node + address = node.driver_info["management_ip"] + client = panos_api.PanosClient(address) + try: + client.login() + LOG.debug("[node:%s] Logged in to %s", node.uuid, address) + system = client.system_info() + except (panos_api.PanosApiError, requests.RequestException) as e: + raise exception.HardwareInspectionFailure( + error=f"PAN-OS {address}: {e}" + ) from e + + _apply_system_info(node, system) + + # TODO: discover data plane connectivity and create ports from it: + # - show interface all, for the data plane interfaces and their MACs + # - LLDP neighbors. Interfaces without config don't transmit LLDP, so + # this needs to enable LLDP and a transmit-receive profile on them, + # commit, wait for neighbors, collect them, then restore the + # original config and commit again. + # - an Ironic port per neighbor named ":", with + # local_link_connection from the neighbor and the remote interface + # as extra["bios_name"] + # TODO: show high-availability all, recording the HA peer serial in + # extra["mate_serial"] (which enroll-fw also sets) when HA is enabled. + + node.save() + return states.MANAGEABLE + + +def _apply_system_info(node, system: dict[str, str]) -> None: + """Record what ``show system info`` reported on the node. + + model and serial are also set by enroll-fw from Nautobot, so those are only + filled in when missing; a disagreement is logged rather than overwritten. + """ + properties = dict(node.properties) + extra = dict(node.extra) + for target, key, field in ( + (properties, "model", "model"), + (extra, "serial", "serial"), + ): + found = system.get(field) + if not found: + continue + if not target.get(key): + target[key] = found + elif target[key] != found: + LOG.warning( + "[node:%s] Appliance reports %s %r but the node has %r", + node.uuid, + key, + found, + target[key], + ) + if system.get("sw-version"): + properties["firmware_version"] = system["sw-version"] + node.properties = properties + node.extra = extra diff --git a/python/ironic-understack/ironic_understack/drivers/panos_management.py b/python/ironic-understack/ironic_understack/drivers/panos_management.py new file mode 100644 index 000000000..613f1fe20 --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_management.py @@ -0,0 +1,60 @@ +"""Management interface for PAN-OS (Palo Alto) appliances.""" + +from ironic.common import exception +from ironic.drivers import base +from ironic.drivers.modules import noop_mgmt +from oslo_log import log + +LOG = log.getLogger(__name__) + + +class PanosManagement(noop_mgmt.NoopManagement): + """Lifecycle steps for PAN-OS appliances. + + Boot device methods don't apply to a firewall, so those are inherited from + NoopManagement. + """ + + @base.verify_step(priority=10) + def setup_initial_configuration(self, task): + """First-time setup of a factory-fresh appliance. + + Runs on enroll -> manageable. It must be idempotent and skip an + appliance that has already been set up. Planned steps, committed once + at the end: + + - management interface address, netmask, and gateway + - remove the factory defaults that conflict with production use: the + rule1 security rule, the trust and untrust zones, default-vwire, and + the ethernet1/1 and ethernet1/2 config + - jumbo frames + - admin credentials, moving from the factory to the standard password + - hostname from the node name + """ + # TODO: implement. Left as a no-op so enrollment isn't blocked on it. + LOG.warning( + "[node:%s] PAN-OS initial configuration is not implemented yet, skipping", + task.node.uuid, + ) + + @base.clean_step(priority=0, requires_ramdisk=False) + def reset_to_factory_defaults(self, task): + """Factory reset the appliance, then rerun initial configuration.""" + # TODO: implement. Fails rather than reporting a reset that didn't happen. + raise exception.NodeCleaningFailure( + node=task.node.uuid, reason="PAN-OS factory reset is not implemented yet" + ) + + @base.clean_step(priority=0, requires_ramdisk=False) + def clear_configuration(self, task): + """Remove tenant configuration, without a full factory reset. + + Removes security policies, NAT rules, and custom zones, interfaces, + and routing. Keeps the management interface, admin credentials, and + basic system settings. + """ + # TODO: implement. Fails rather than reporting a clean that didn't happen. + raise exception.NodeCleaningFailure( + node=task.node.uuid, + reason="PAN-OS configuration cleanup is not implemented yet", + ) diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_api.py b/python/ironic-understack/ironic_understack/tests/test_panos_api.py new file mode 100644 index 000000000..6622924aa --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_panos_api.py @@ -0,0 +1,158 @@ +from unittest.mock import Mock + +import pytest +from oslo_config import fixture as config_fixture + +from ironic_understack.conf import CONF +from ironic_understack.utils import panos_api + +KEY_OK = b'KEY' +BAD_CREDS = ( + b'Invalid Credential' + b"" +) +SYSTEM_INFO = b""" +fw-example +PA-1410 +000000000001 +11.0.0 +""" + + +def _resp(status_code, content): + return Mock(status_code=status_code, content=content) + + +@pytest.fixture +def conf(): + fixture = config_fixture.Config(CONF) + fixture.setUp() + fixture.config( + standard_password="std", # noqa: S106 + preconfig_password="pre", # noqa: S106 + group="netdev_panos", + ) + yield fixture + fixture.cleanUp() + + +def _passwords_tried(session): + return [c.kwargs["data"]["password"] for c in session.post.call_args_list] + + +def test_login_with_standard_password(conf): + session = Mock() + session.post.return_value = _resp(200, KEY_OK) + client = panos_api.PanosClient("192.0.2.10", session=session) + + assert client.login() == "standard" + assert client.api_key == "KEY" + assert _passwords_tried(session) == ["std"] + assert session.post.call_args.args == ("https://192.0.2.10/api/",) + + +def test_login_falls_back_in_order(conf): + session = Mock() + session.post.side_effect = [ + _resp(403, BAD_CREDS), + _resp(403, BAD_CREDS), + _resp(200, KEY_OK), + ] + client = panos_api.PanosClient("192.0.2.10", session=session) + + assert client.login() == "factory" + # factory_password defaults to "admin" + assert _passwords_tried(session) == ["std", "pre", "admin"] + + +def test_login_skips_unset_passwords(conf): + conf.config(standard_password=None, group="netdev_panos") + session = Mock() + session.post.return_value = _resp(200, KEY_OK) + client = panos_api.PanosClient("192.0.2.10", session=session) + + assert client.login() == "preconfig" + assert _passwords_tried(session) == ["pre"] + + +def test_login_fails_when_every_password_is_rejected(conf): + session = Mock() + session.post.return_value = _resp(403, BAD_CREDS) + client = panos_api.PanosClient("192.0.2.10", session=session) + + with pytest.raises(panos_api.PanosApiError, match="standard, preconfig, factory"): + client.login() + assert client.api_key is None + + +def test_login_fails_when_no_passwords_are_configured(conf): + conf.config( + standard_password=None, + preconfig_password=None, + factory_password=None, + group="netdev_panos", + ) + session = Mock() + client = panos_api.PanosClient("192.0.2.10", session=session) + + with pytest.raises(panos_api.PanosApiError, match="no PAN-OS passwords"): + client.login() + session.post.assert_not_called() + + +def test_login_does_not_try_the_next_password_after_a_server_error(conf): + # Only a 403 means the password was wrong; anything else is surfaced. + session = Mock() + session.post.return_value = _resp(500, b"oops") + client = panos_api.PanosClient("192.0.2.10", session=session) + + with pytest.raises(panos_api.PanosApiError, match="HTTP 500"): + client.login() + assert session.post.call_count == 1 + + +def test_verify_ssl_follows_config(conf): + session = Mock() + panos_api.PanosClient("192.0.2.10", session=session) + assert session.verify is False + + conf.config(verify_ssl=True, group="netdev_panos") + panos_api.PanosClient("192.0.2.10", session=session) + assert session.verify is True + + +def test_system_info(conf): + session = Mock() + session.post.side_effect = [_resp(200, KEY_OK), _resp(200, SYSTEM_INFO)] + client = panos_api.PanosClient("192.0.2.10", session=session) + client.login() + + assert client.system_info() == { + "hostname": "fw-example", + "model": "PA-1410", + "serial": "000000000001", + "sw-version": "11.0.0", + } + assert session.post.call_args.kwargs["headers"] == {"X-PAN-KEY": "KEY"} + + +def test_op_requires_login(conf): + client = panos_api.PanosClient("192.0.2.10", session=Mock()) + with pytest.raises(panos_api.PanosApiError, match="not logged in"): + client.op("") + + +def test_op_error_status_is_raised(conf): + session = Mock() + session.post.side_effect = [ + _resp(200, KEY_OK), + _resp( + 200, + b'bad command', + ), + ] + client = panos_api.PanosClient("192.0.2.10", session=session) + client.login() + + with pytest.raises(panos_api.PanosApiError, match="bad command"): + client.op("") diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py b/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py index 992d0739a..b75ac91b0 100644 --- a/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py +++ b/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py @@ -12,9 +12,9 @@ def test_panos_is_a_netdev(): assert issubclass(PanosHardware, NetdevHardware) -def test_panos_matches_netdev_interfaces_today(): - # Nothing diverges yet. This pins that, so adding a real inspect or deploy - # interface later is a deliberate change rather than an accident. +def test_panos_matches_netdev_except_inspect_and_management(): + # Only inspect and management diverge. This pins that, so adding another + # real interface later is a deliberate change rather than an accident. pa = PanosHardware() netdev = NetdevHardware() for name in ( @@ -23,8 +23,6 @@ def test_panos_matches_netdev_interfaces_today(): "supported_console_interfaces", "supported_deploy_interfaces", "supported_firmware_interfaces", - "supported_inspect_interfaces", - "supported_management_interfaces", "supported_network_interfaces", "supported_power_interfaces", "supported_raid_interfaces", @@ -35,6 +33,22 @@ def test_panos_matches_netdev_interfaces_today(): assert getattr(pa, name) == getattr(netdev, name), name +def test_panos_inspect_defaults_to_panos(): + hw = PanosHardware() + assert _interface_names(hw.supported_inspect_interfaces) == [ + "PanosInspect", + "NoInspect", + ] + + +def test_panos_management_defaults_to_panos(): + hw = PanosHardware() + assert _interface_names(hw.supported_management_interfaces) == [ + "PanosManagement", + "NoopManagement", + ] + + def test_panos_deploy_is_noop(): hw = PanosHardware() assert _interface_names(hw.supported_deploy_interfaces) == ["NoDeploy"] diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py b/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py new file mode 100644 index 000000000..b19bfa965 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py @@ -0,0 +1,87 @@ +from unittest.mock import Mock + +import pytest +import requests +from ironic.common import exception +from ironic.common import states + +from ironic_understack.drivers import panos_inspect +from ironic_understack.drivers.panos_inspect import PanosInspect +from ironic_understack.utils import panos_api + +SYSTEM = {"model": "PA-1410", "serial": "000000000001", "sw-version": "11.0.0"} + + +@pytest.fixture +def task(): + task = Mock() + task.node.uuid = "node-uuid" + task.node.driver_info = {"management_ip": "192.0.2.10"} + task.node.properties = {} + task.node.extra = {} + return task + + +@pytest.fixture +def client(mocker): + client = Mock() + client.login.return_value = "standard" + client.system_info.return_value = dict(SYSTEM) + mocker.patch.object(panos_api, "PanosClient", return_value=client) + return client + + +def test_get_properties_matches_what_validate_reads(): + assert set(PanosInspect().get_properties()) == {"management_ip"} + + +def test_validate_requires_management_ip(task): + task.node.driver_info = {} + with pytest.raises(exception.MissingParameterValue): + PanosInspect().validate(task) + + +def test_validate_success(task): + PanosInspect().validate(task) + + +def test_inspect_records_system_info(task, client): + assert PanosInspect().inspect_hardware(task) == states.MANAGEABLE + + panos_api.PanosClient.assert_called_once_with("192.0.2.10") + assert task.node.properties == {"model": "PA-1410", "firmware_version": "11.0.0"} + assert task.node.extra == {"serial": "000000000001"} + task.node.save.assert_called_once() + + +def test_inspect_keeps_enrolled_model_and_serial(task, client): + # enroll-fw sets these from Nautobot; inspection does not overwrite them. + task.node.properties = {"model": "PA-1410-NB", "vendor": "Palo Alto"} + task.node.extra = {"serial": "nautobot-serial", "mate_serial": "mate"} + + PanosInspect().inspect_hardware(task) + + assert task.node.properties == { + "model": "PA-1410-NB", + "vendor": "Palo Alto", + "firmware_version": "11.0.0", + } + assert task.node.extra == {"serial": "nautobot-serial", "mate_serial": "mate"} + + +@pytest.mark.parametrize( + "error", + [panos_api.PanosApiError("rejected"), requests.ConnectionError("unreachable")], +) +def test_inspect_failure(task, client, error): + client.login.side_effect = error + + with pytest.raises(exception.HardwareInspectionFailure, match=r"192\.0\.2\.10"): + PanosInspect().inspect_hardware(task) + task.node.save.assert_not_called() + + +def test_apply_system_info_ignores_empty_fields(task): + panos_inspect._apply_system_info(task.node, {"model": "", "serial": ""}) + assert task.node.properties == {} + assert task.node.extra == {} diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_management.py b/python/ironic-understack/ironic_understack/tests/test_panos_management.py new file mode 100644 index 000000000..c651e4bb9 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_panos_management.py @@ -0,0 +1,34 @@ +from unittest.mock import Mock + +import pytest +from ironic.common import exception + +from ironic_understack.drivers.panos_management import PanosManagement + + +@pytest.fixture +def task(): + task = Mock() + task.node.uuid = "node-uuid" + return task + + +def test_steps_are_registered(): + mgmt = PanosManagement() + assert {s["step"]: s["priority"] for s in mgmt.get_clean_steps(Mock())} == { + "reset_to_factory_defaults": 0, + "clear_configuration": 0, + } + assert [s["step"] for s in mgmt.get_verify_steps(Mock())] == [ + "setup_initial_configuration" + ] + + +def test_initial_configuration_does_not_block_enrollment(task): + assert PanosManagement().setup_initial_configuration(task) is None + + +@pytest.mark.parametrize("step", ["reset_to_factory_defaults", "clear_configuration"]) +def test_unimplemented_clean_steps_fail(task, step): + with pytest.raises(exception.NodeCleaningFailure, match="not implemented"): + getattr(PanosManagement(), step)(task) diff --git a/python/ironic-understack/ironic_understack/utils/panos_api.py b/python/ironic-understack/ironic_understack/utils/panos_api.py new file mode 100644 index 000000000..5b6bde78f --- /dev/null +++ b/python/ironic-understack/ironic_understack/utils/panos_api.py @@ -0,0 +1,111 @@ +"""Minimal client for the PAN-OS XML API. + +Credentials come from the ``[netdev_panos]`` oslo.config group; see +:mod:`ironic_understack.utils.netdev_credentials`. +""" + +# Responses are only ever parsed from the appliance being managed, and the +# stdlib expat parser does not resolve external entities. +import xml.etree.ElementTree as ET + +import requests +from oslo_log import log + +from ironic_understack.conf import CONF +from ironic_understack.utils import netdev_credentials + +LOG = log.getLogger(__name__) + +TIMEOUT = 30 + +# Passwords are tried in this order. An appliance that has been through initial +# setup takes the standard password, one part way through takes preconfig, and +# a factory-fresh one still takes the factory default. +PASSWORD_ORDER = ( + ("standard", "standard_password"), + ("preconfig", "preconfig_password"), + ("factory", "factory_password"), +) + + +class PanosApiError(Exception): + """The PAN-OS API rejected a request or returned an unusable response.""" + + +class PanosClient: + def __init__(self, address: str, session: requests.Session | None = None): + self.address = address + self.url = f"https://{address}/api/" + self.session = session or requests.Session() + self.session.verify = CONF.netdev_panos.verify_ssl + self.api_key: str | None = None + + def login(self) -> str: + """Obtain an API key, trying each configured password in turn. + + :returns: the label of the password that was accepted. + :raises PanosApiError: if no configured password is accepted. + """ + creds = netdev_credentials.get_panos_credentials() + tried = [] + for label, key in PASSWORD_ORDER: + password = creds[key] + if not password: + continue + tried.append(label) + api_key = self._keygen(creds["username"], password) + if api_key: + self.api_key = api_key + return label + LOG.debug("PAN-OS %s rejected the %s password", self.address, label) + + if not tried: + raise PanosApiError("no PAN-OS passwords are configured in [netdev_panos]") + raise PanosApiError(f"rejected the {', '.join(tried)} password(s)") + + def _keygen(self, username: str, password: str) -> str | None: + # POST so the password is never part of a URL that might be logged. + resp = self.session.post( + self.url, + data={"type": "keygen", "user": username, "password": password}, + timeout=TIMEOUT, + ) + if resp.status_code == 403: + return None + key = _parse(resp).findtext("./result/key") + if not key: + raise PanosApiError("keygen response did not contain a key") + return key + + def op(self, cmd: str) -> ET.Element: + """Run an operational command and return its ```` element.""" + if self.api_key is None: + raise PanosApiError("not logged in") + resp = self.session.post( + self.url, + data={"type": "op", "cmd": cmd}, + headers={"X-PAN-KEY": self.api_key}, + timeout=TIMEOUT, + ) + result = _parse(resp).find("result") + if result is None: + raise PanosApiError(f"no result for {cmd}") + return result + + def system_info(self) -> dict[str, str]: + """Return ``show system info`` as a flat dict of field name to value.""" + system = self.op("").find("system") + if system is None: + raise PanosApiError("show system info returned no system element") + return {child.tag: (child.text or "").strip() for child in system} + + +def _parse(resp: requests.Response) -> ET.Element: + try: + root = ET.fromstring(resp.content) # noqa: S314 + except ET.ParseError as e: + raise PanosApiError(f"HTTP {resp.status_code}: response is not XML") from e + if root.get("status") != "success": + msg = " ".join(text.strip() for text in root.itertext() if text.strip()) + raise PanosApiError(f"HTTP {resp.status_code}: {msg or 'request failed'}") + return root diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index d237d9845..2ffb93e82 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -13,6 +13,7 @@ license = "MIT" dependencies = [ "ironic>=35.1.0,<36", "pyyaml~=6.0", + "requests", ] # REMOVE ME: delete this once we move away from patching a venv or we upgrade to a newer ironic base version @@ -33,6 +34,10 @@ panos = "ironic_understack.drivers.panos_hardware:PanosHardware" [project.entry-points."ironic.hardware.interfaces.inspect"] redfish-understack = "ironic_understack.drivers.redfish_inspect_understack:UnderstackRedfishInspect" idrac-redfish-understack = "ironic_understack.drivers.redfish_inspect_understack:UnderstackDracRedfishInspect" +panos = "ironic_understack.drivers.panos_inspect:PanosInspect" + +[project.entry-points."ironic.hardware.interfaces.management"] +panos = "ironic_understack.drivers.panos_management:PanosManagement" [project.entry-points."ironic.api.middleware"] portgroup-name-validation = "ironic_understack.middleware.portgroup_name_middleware:PortgroupNameValidationMiddleware" diff --git a/python/ironic-understack/uv.lock b/python/ironic-understack/uv.lock index 374198bcc..6bcf62c94 100644 --- a/python/ironic-understack/uv.lock +++ b/python/ironic-understack/uv.lock @@ -457,6 +457,7 @@ source = { editable = "." } dependencies = [ { name = "ironic" }, { name = "pyyaml" }, + { name = "requests" }, ] [package.dev-dependencies] @@ -472,6 +473,7 @@ test = [ requires-dist = [ { name = "ironic", specifier = ">=35.1.0,<36" }, { name = "pyyaml", specifier = "~=6.0" }, + { name = "requests" }, ] [package.metadata.requires-dev] From 0314b83aaa83a0014d6e3acfbcc69152de8cd4a5 Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Fri, 2 Oct 2026 13:54:08 -0400 Subject: [PATCH 2/2] feat(ironic): add validate() and get_properties() to PanosManagement Implements validation in PanosManagement to check for required management_ip parameter, addressing PR #2338 feedback. Also adds get_properties() to advertise required driver_info parameters. All tests pass (18/18). --- .../ironic_understack/drivers/panos_management.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/python/ironic-understack/ironic_understack/drivers/panos_management.py b/python/ironic-understack/ironic_understack/drivers/panos_management.py index 613f1fe20..dbb0c33bc 100644 --- a/python/ironic-understack/ironic_understack/drivers/panos_management.py +++ b/python/ironic-understack/ironic_understack/drivers/panos_management.py @@ -15,6 +15,18 @@ class PanosManagement(noop_mgmt.NoopManagement): NoopManagement. """ + def get_properties(self): + return { + "management_ip": "Address of the appliance's management interface, " + "used for the XML API. Required.", + } + + def validate(self, task): + if not task.node.driver_info.get("management_ip"): + raise exception.MissingParameterValue( + f"Node {task.node.uuid} is missing driver_info management_ip" + ) + @base.verify_step(priority=10) def setup_initial_configuration(self, task): """First-time setup of a factory-fresh appliance.