diff --git a/python/ironic-understack/ironic_understack/drivers/panos_inspect.py b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py index 972cbbf82..c20939aba 100644 --- a/python/ironic-understack/ironic_understack/drivers/panos_inspect.py +++ b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py @@ -1,14 +1,18 @@ """Inspect interface for PAN-OS (Palo Alto) appliances.""" +import time +from contextlib import suppress from typing import ClassVar import requests from ironic.common import exception from ironic.common import states from ironic.drivers import base +from ironic.objects import port from oslo_log import log from ironic_understack.utils import panos_api +from ironic_understack.utils import vlan_group_name_convention LOG = log.getLogger(__name__) @@ -41,7 +45,6 @@ def inspect_hardware(self, task): 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( @@ -50,15 +53,18 @@ def inspect_hardware(self, task): _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"] + # Discover data plane connectivity via LLDP + try: + _discover_and_update_ports(task, client) + except Exception as e: + LOG.warning( + "[node:%s] Could not discover ports via LLDP: %s", + node.uuid, + e, + exc_info=True, + ) + # Don't fail inspect if port discovery fails + # TODO: show high-availability all, recording the HA peer serial in # extra["mate_serial"] (which enroll-fw also sets) when HA is enabled. @@ -95,3 +101,277 @@ def _apply_system_info(node, system: dict[str, str]) -> None: properties["firmware_version"] = system["sw-version"] node.properties = properties node.extra = extra + + +def _discover_and_update_ports(task, client: panos_api.PanosClient) -> None: + """Discover interfaces and LLDP neighbors, then update/create Ironic ports. + + This temporarily enables LLDP on all interfaces, collects neighbors, + then restores the original configuration. + """ + node = task.node + + # Get all physical interfaces based on model + model = node.properties.get("model", "PA-1410") + all_interfaces = _get_all_physical_interfaces(model) + + # Enable LLDP on all interfaces + _enable_lldp_discovery(client, all_interfaces) + + # Wait for LLDP neighbors + time.sleep(30) + + # Collect interface MACs and LLDP neighbors + interface_macs = client.get_interface_macs() + lldp_neighbors = client.get_lldp_neighbors() + LOG.info("[node:%s] Discovered %d LLDP neighbors", node.uuid, len(lldp_neighbors)) + + # Restore original configuration (delete all interface config we added) + _restore_interface_config(client, all_interfaces) + + # Update Ironic ports + _update_ports_from_neighbors(task, lldp_neighbors, interface_macs) + + +def _get_all_physical_interfaces(model: str) -> list[str]: + """Get list of all physical ethernet interfaces based on model.""" + if "1410" in model: + return [f"ethernet1/{i}" for i in range(1, 23)] # PA-1410 has 22 ports + # Default to 22 ports for unknown models + return [f"ethernet1/{i}" for i in range(1, 23)] + + +def _enable_lldp_discovery( + client: panos_api.PanosClient, interfaces: list[str] +) -> None: + """Enable LLDP globally and configure it on specified interfaces. + + Destructively removes any existing config that would block LLDP setup. + """ + # Remove all blocking config: security rules -> zones -> virtual-wires + rules_deleted = False + zones_deleted = False + vwires_deleted = False + + try: + xpath = ( + "/config/devices/entry[@name='localhost.localdomain']" + "/vsys/entry[@name='vsys1']/rulebase/security/rules" + ) + client.config("delete", xpath, None) + rules_deleted = True + except panos_api.PanosApiError: + pass # No security rules to delete + + try: + xpath = ( + "/config/devices/entry[@name='localhost.localdomain']" + "/vsys/entry[@name='vsys1']/zone" + ) + client.config("delete", xpath, None) + zones_deleted = True + except panos_api.PanosApiError: + pass # No zone config to delete + + try: + xpath = ( + "/config/devices/entry[@name='localhost.localdomain']" + "/network/virtual-wire" + ) + client.config("delete", xpath, None) + vwires_deleted = True + except panos_api.PanosApiError: + pass # No virtual-wire config to delete + + if rules_deleted or zones_deleted or vwires_deleted: + LOG.debug( + "Deleted factory config: rules=%s zones=%s vwires=%s", + rules_deleted, + zones_deleted, + vwires_deleted, + ) + + # Remove virtual-wire config from all interfaces (no per-interface logs) + for iface in interfaces: + with suppress(panos_api.PanosApiError): + xpath = ( + f"/config/devices/entry[@name='localhost.localdomain']" + f"/network/interface/ethernet/entry[@name='{iface}']/virtual-wire" + ) + client.config("delete", xpath, None) + + # Only commit if we actually deleted something + if rules_deleted or zones_deleted or vwires_deleted: + job_id = client.commit("Inspect: Remove factory default config") + if job_id and not client.wait_for_commit(job_id): + raise panos_api.PanosApiError( + "Failed to commit factory default cleanup configuration" + ) + + # Enable LLDP globally + xpath = "/config/devices/entry[@name='localhost.localdomain']/network/lldp" + element = "yes" + client.config("set", xpath, element) + + # Create LLDP profile + xpath = ( + "/config/devices/entry[@name='localhost.localdomain']" + "/network/profiles/lldp-profile/entry[@name='lldp-inspect']" + ) + element = "transmit-receive" + client.config("set", xpath, element) + + # Commit LLDP profile + job_id = client.commit("Inspect: Enable LLDP profile") + if job_id and not client.wait_for_commit(job_id): + raise panos_api.PanosApiError("Failed to commit LLDP profile configuration") + + # Configure interfaces with LLDP + for iface in interfaces: + xpath = ( + f"/config/devices/entry[@name='localhost.localdomain']" + f"/network/interface/ethernet/entry[@name='{iface}']" + ) + element = ( + "" + "yeslldp-inspect" + "" + "auto" + "up" + ) + client.config("set", xpath, element) + + # Commit interface changes + job_id = client.commit("Inspect: Enable interfaces for LLDP discovery") + if job_id and not client.wait_for_commit(job_id): + raise panos_api.PanosApiError( + "Failed to commit interface configuration for LLDP" + ) + + +def _restore_interface_config( + client: panos_api.PanosClient, interfaces: list[str] +) -> None: + """Remove temporary LLDP configuration from interfaces.""" + for iface in interfaces: + xpath = ( + f"/config/devices/entry[@name='localhost.localdomain']" + f"/network/interface/ethernet/entry[@name='{iface}']" + ) + try: + client.config("delete", xpath) + except panos_api.PanosApiError as e: + LOG.warning("Could not delete interface %s config: %s", iface, e) + + # Commit restore + job_id = client.commit("Inspect: Restore original interface configuration") + if job_id and not client.wait_for_commit(job_id): + raise panos_api.PanosApiError( + "Failed to commit interface restoration configuration" + ) + + +def _update_ports_from_neighbors( + task, neighbors: list[dict[str, str]], interface_macs: dict[str, str] +) -> None: + """Create or update Ironic ports based on LLDP neighbor data.""" + from ironic_understack import conf + from ironic_understack.utils.inspected_port import InspectedPort + + node = task.node + CONF = conf.CONF + + def _normalize_switch_name(name: str) -> str: + """Normalize switch name to FQDN format matching baremetal servers.""" + suffix = ".rackspace.net" + name = str(name).lower() + return name if name.endswith(suffix) else name + suffix + + # First, create InspectedPort objects for all neighbors with MACs + inspected_ports = [] + neighbor_by_mac = {} + + for neighbor in neighbors: + local_interface = neighbor["local_interface"] + mac_address = interface_macs.get(local_interface) + + if not mac_address: + LOG.warning( + "[node:%s] No MAC address for %s, skipping port creation", + node.uuid, + local_interface, + ) + continue + + normalized_switch_name = _normalize_switch_name(neighbor["system_name"]) + + inspected_port = InspectedPort( + mac_address=mac_address, + name=local_interface, + switch_system_name=normalized_switch_name, + switch_port_id=neighbor["port_id"], + switch_chassis_id=neighbor["chassis_id"], + ) + inspected_ports.append(inspected_port) + neighbor_by_mac[mac_address.lower()] = neighbor + + if not inspected_ports: + LOG.info("[node:%s] No ports with MAC addresses to create", node.uuid) + return + + # Get VLAN group names for all ports + try: + vlan_group_mapping = vlan_group_name_convention.vlan_group_names( + inspected_ports, CONF.ironic_understack.switch_name_vlan_group_mapping + ) + except Exception as e: + LOG.warning( + "[node:%s] Could not derive VLAN groups: %s. " + "Creating ports without physical_network.", + node.uuid, + e, + ) + vlan_group_mapping = {} + + # Now create or update ports + for inspected_port in inspected_ports: + mac_address = inspected_port.mac_address + neighbor = neighbor_by_mac[mac_address.lower()] + local_interface = inspected_port.name + + physical_network = vlan_group_mapping.get(neighbor["system_name"]) + port_name = f"{node.name}:{local_interface}" + + # Check if port already exists (by MAC or name) + existing_port = None + for p in task.ports: + if p.address.lower() == mac_address.lower(): + existing_port = p + break + if p.name == port_name: + existing_port = p + break + + if existing_port: + # Update existing port + existing_port.address = mac_address + existing_port.local_link_connection = inspected_port.local_link_connection + existing_port.extra = {"bios_name": local_interface} + existing_port.physical_network = physical_network + existing_port.name = port_name + existing_port.save() + LOG.debug("[node:%s] Updated port %s", node.uuid, port_name) + else: + # Create new port + port_obj = port.Port(task.context) + port_obj.node_id = node.id + port_obj.address = mac_address + port_obj.name = port_name + port_obj.local_link_connection = inspected_port.local_link_connection + port_obj.extra = {"bios_name": local_interface} + port_obj.physical_network = physical_network + port_obj.pxe_enabled = True + port_obj.create() + LOG.info( + "[node:%s] Created port %s (%s)", node.uuid, port_name, mac_address + ) diff --git a/python/ironic-understack/ironic_understack/utils/panos_api.py b/python/ironic-understack/ironic_understack/utils/panos_api.py index 5b6bde78f..613351920 100644 --- a/python/ironic-understack/ironic_understack/utils/panos_api.py +++ b/python/ironic-understack/ironic_understack/utils/panos_api.py @@ -28,6 +28,33 @@ ) +def _xml_to_dict(element: ET.Element) -> dict | str | list: + """Convert an XML element tree to nested dicts/lists/strings. + + Handles the PAN-OS XML response format where repeated elements + become lists and single elements become dicts. + """ + children = list(element) + if not children: + # Leaf node - return the text content + return (element.text or "").strip() + + # Group children by tag name + grouped = {} + for child in children: + tag = child.tag + if tag not in grouped: + grouped[tag] = [] + grouped[tag].append(_xml_to_dict(child)) + + # Convert single-element lists to scalars + result = {} + for tag, values in grouped.items(): + result[tag] = values[0] if len(values) == 1 else values + + return result + + class PanosApiError(Exception): """The PAN-OS API rejected a request or returned an unusable response.""" @@ -99,6 +126,215 @@ def system_info(self) -> dict[str, str]: raise PanosApiError("show system info returned no system element") return {child.tag: (child.text or "").strip() for child in system} + def config(self, action: str, xpath: str, element: str | None = None) -> ET.Element: + """Execute a config API command (set, edit, delete, get, show). + + :param action: Config action (set, edit, delete, get, show) + :param xpath: XPath to the config element + :param element: XML element string for set/edit actions + :returns: The response result element + """ + if self.api_key is None: + raise PanosApiError("not logged in") + + params = { + "type": "config", + "key": self.api_key, + "action": action, + "xpath": xpath, + } + if element and action in ("set", "edit"): + params["element"] = element + + resp = self.session.get( + self.url, + params=params, + timeout=TIMEOUT, + ) + root = _parse(resp) + result = root.find("result") + + if result is None: + # For delete/set operations, success response may not have a result + return root + return result + + def commit(self, description: str | None = None) -> str | None: + """Commit configuration changes. + + :param description: Optional commit description + :returns: Job ID or None if no changes to commit + :raises PanosApiError: If commit fails + """ + if self.api_key is None: + raise PanosApiError("not logged in") + + cmd = "" + if description: + cmd = f"{description}" + + resp = self.session.get( + self.url, + params={"type": "commit", "key": self.api_key, "cmd": cmd}, + timeout=120, + ) + root = _parse(resp) + + # Find job ID first + job = root.findtext(".//job") or root.findtext("result/job") + + # Check for "no changes to commit" (code 19 with specific message) + if root.get("code") == "19": + msg = root.findtext("msg") or "" + if "no changes" in msg.lower(): + return None + if not job: + LOG.error( + "PAN-OS %s commit response missing job ID. Root: %s", + self.address, + ET.tostring(root, encoding="unicode"), + ) + raise PanosApiError("Could not find job ID in commit response") + + return job + + def check_job(self, job_id: str) -> dict[str, str]: + """Check the status of a commit job. + + :param job_id: Job ID from commit() + :returns: Dict with status, result, progress, details + """ + result = self.op(f"{job_id}") + job = result.find("job") + if job is None: + return { + "status": "unknown", + "result": "unknown", + "progress": "0", + "details": "", + } + + return { + "status": job.findtext("status") or "unknown", + "result": job.findtext("result") or "unknown", + "progress": job.findtext("progress") or "0", + "details": job.findtext("details") or "", + } + + def wait_for_commit(self, job_id: str, timeout: int = 60) -> bool: + """Wait for a commit job to complete. + + :param job_id: Job ID from commit() + :param timeout: Max seconds to wait + :returns: True if commit succeeded, False otherwise + """ + import time + + for _ in range(timeout // 2): + time.sleep(2) + status = self.check_job(job_id) + + if status["status"] == "FIN": + if status["result"] == "OK": + return True + else: + LOG.error( + "PAN-OS %s commit failed: %s", self.address, status["result"] + ) + return False + + LOG.error("PAN-OS %s commit timed out", self.address) + return False + + def get_interface_macs(self) -> dict[str, str]: + """Get MAC addresses for all interfaces. + + :returns: Dict mapping interface name to MAC address + """ + result = self.op("all") + macs = {} + + hw = result.find("hw") + if hw is not None: + for entry in hw.findall("entry"): + name = entry.findtext("name") + mac = entry.findtext("mac") + if name and mac: + macs[name] = mac + + return macs + + def get_lldp_neighbors(self) -> list[dict[str, str]]: + """Get LLDP neighbor information for all interfaces. + + :returns: List of dicts with keys: local_interface, chassis_id, + port_id, system_name + """ + # Try both command formats + result = None + for cmd in [ + "all", + "", + ]: + try: + result = self.op(cmd) + break + except PanosApiError: + continue + + if result is None: + return [] + + # Convert XML to dict for easier parsing (same as panos.py script) + result_dict = _xml_to_dict(result) + + # Extract entries - handle both single entry (dict) and multiple (list) + entries = [] + if isinstance(result_dict, dict) and "entry" in result_dict: + entry_data = result_dict["entry"] + entries = entry_data if isinstance(entry_data, list) else [entry_data] + + neighbors = [] + for entry in entries: + if not isinstance(entry, dict): + continue + + # Get local interface info + local_info = entry.get("local", {}) + if not isinstance(local_info, dict): + continue + + port_id = local_info.get("port-id", "") + if not port_id: + continue + + local_interface = f"ethernet1/{port_id}" + + # Get neighbor info + neighbor_info = entry.get("neighbors", {}) + if not neighbor_info or neighbor_info == "": + # No neighbor on this interface - skip it + continue + + # neighbors contains an entry element + if not isinstance(neighbor_info, dict): + continue + + neighbor_entry = neighbor_info.get("entry", {}) + if not neighbor_entry or not isinstance(neighbor_entry, dict): + continue + + neighbors.append( + { + "local_interface": local_interface, + "chassis_id": neighbor_entry.get("chassis-id", ""), + "port_id": neighbor_entry.get("port-id", ""), + "system_name": neighbor_entry.get("system-name", ""), + } + ) + + return neighbors + def _parse(resp: requests.Response) -> ET.Element: try: