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..dbb0c33bc --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_management.py @@ -0,0 +1,72 @@ +"""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. + """ + + 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. + + 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]