From b2aa4780194b5aad48b129b3dbc99147fa299883 Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Fri, 11 Sep 2026 15:45:00 -0400 Subject: [PATCH 1/4] building panos driver scaffolding --- .../drivers/panos_hardware.py | 94 +++++ .../drivers/panos_inspect.py | 335 ++++++++++++++++++ .../drivers/panos_management.py | 286 +++++++++++++++ .../tests/fixtures/ha_state.xml | 1 + .../tests/fixtures/interface_management.xml | 37 ++ .../tests/fixtures/lldp_neighbors.xml | 1 + .../tests/fixtures/system_info.xml | 28 ++ .../tests/test_panos_hardware.py | 58 +++ .../tests/test_panos_inspect.py | 137 +++++++ python/ironic-understack/pyproject.toml | 5 + 10 files changed, 982 insertions(+) create mode 100644 python/ironic-understack/ironic_understack/drivers/panos_hardware.py 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/fixtures/ha_state.xml create mode 100644 python/ironic-understack/ironic_understack/tests/fixtures/interface_management.xml create mode 100644 python/ironic-understack/ironic_understack/tests/fixtures/lldp_neighbors.xml create mode 100644 python/ironic-understack/ironic_understack/tests/fixtures/system_info.xml create mode 100644 python/ironic-understack/ironic_understack/tests/test_panos_hardware.py create mode 100644 python/ironic-understack/ironic_understack/tests/test_panos_inspect.py diff --git a/python/ironic-understack/ironic_understack/drivers/panos_hardware.py b/python/ironic-understack/ironic_understack/drivers/panos_hardware.py new file mode 100644 index 000000000..012900981 --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_hardware.py @@ -0,0 +1,94 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""PAN-OS Hardware Type for Palo Alto firewalls.""" + +from ironic.drivers import generic +from ironic.drivers.modules import noop +from ironic.drivers.modules.network import neutron +from ironic.drivers.modules.storage import noop as noop_storage + +from ironic_understack.drivers import panos_inspect +from ironic_understack.drivers import panos_management + + +class PanosHardware(generic.ManualManagementHardware): + """Hardware type for Palo Alto (PAN-OS) firewalls. + + Intended for nodes that represent Palo Alto firewall appliances + (PA-1410, PA-5410, etc.). These devices: + - Have no deploy ramdisk + - Are managed via PAN-OS XML API + - Use Neutron for network port binding + - Require custom inspect and management interfaces + + All other interfaces (boot, power, deploy, raid, etc.) use no-op + implementations since they don't apply to firewall appliances. + + Boot and power are inherited from ManualManagementHardware, which + uses FakePower (power state controlled by manual operator) since + there's no automated power management for these devices. + """ + + @property + def supported_bios_interfaces(self): + """No BIOS on firewall appliances.""" + return [noop.NoBIOS] + + @property + def supported_console_interfaces(self): + """Console access not supported via Ironic for PAN-OS.""" + return [noop.NoConsole] + + @property + def supported_deploy_interfaces(self): + """No image deployment for firewall appliances.""" + return [noop.NoDeploy] + + @property + def supported_firmware_interfaces(self): + """Firmware updates handled separately.""" + return [noop.NoFirmware] + + @property + def supported_inspect_interfaces(self): + """Use custom PAN-OS inspect interface for hardware discovery.""" + return [panos_inspect.PanosInspect] + + @property + def supported_management_interfaces(self): + """Use custom PAN-OS management interface for lifecycle operations.""" + return [panos_management.PanosManagement] + + @property + def supported_network_interfaces(self): + """Use Neutron for network port binding.""" + return [neutron.NeutronNetwork] + + @property + def supported_raid_interfaces(self): + """No RAID on firewall appliances.""" + return [noop.NoRAID] + + @property + def supported_rescue_interfaces(self): + """Rescue mode not applicable to firewall appliances.""" + return [noop.NoRescue] + + @property + def supported_storage_interfaces(self): + """No storage management for firewall appliances.""" + return [noop_storage.NoopStorage] + + @property + def supported_vendor_interfaces(self): + """No vendor-specific passthrough needed.""" + return [noop.NoVendor] 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..bf2b54a5b --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py @@ -0,0 +1,335 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""PAN-OS Inspect Interface for Palo Alto firewalls.""" + +from typing import ClassVar + +from ironic.common import exception +from ironic.common import states +from ironic.drivers import base +from oslo_log import log + +LOG = log.getLogger(__name__) + + +class PanosInspect(base.InspectInterface): + """Inspect interface for PAN-OS firewalls. + + Performs discovery and inspection of Palo Alto firewall hardware: + - Queries system information (model, serial, version) + - Discovers physical connectivity via LLDP + - Creates Ironic Port objects for data plane interfaces + - Discovers HA configuration and peer information + + This interface does NOT require a ramdisk - all operations are performed + via the PAN-OS XML API over the management interface. + """ + + # Override default essential properties - firewalls don't have memory_mb/cpu_arch + # in the traditional server sense + ESSENTIAL_PROPERTIES: ClassVar[set] = set() + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return { + "panos_address": "Management IP address or hostname for API access. " + "Required. Example: 10.15.149.46", + "panos_username": 'API username. Required. Usually "admin".', + "panos_password": "API password. Required.", + "panos_verify_ssl": "Whether to verify SSL certificates. " + "Optional, defaults to False.", + "panos_api_port": "API port. Optional, defaults to 443.", + } + + def validate(self, task): + """Validate the driver_info contains required credentials. + + :param task: a TaskManager instance. + :raises: MissingParameterValue if required parameters are missing. + :raises: InvalidParameterValue if parameters are invalid. + """ + driver_info = task.node.driver_info + + # TODO: Implement validation + # Check that panos_address, panos_username, panos_password are present + # Optionally validate that we can connect to the device + + missing = [] + if not driver_info.get("panos_address"): + missing.append("panos_address") + if not driver_info.get("panos_username"): + missing.append("panos_username") + if not driver_info.get("panos_password"): + missing.append("panos_password") + + if missing: + raise exception.MissingParameterValue( + f'Missing required driver_info parameters: {", ".join(missing)}' + ) + + def inspect_hardware(self, task): + """Inspect hardware to discover firewall properties. + + This method: + 1. Connects to the firewall via PAN-OS XML API + 2. Queries system information (show system info) + 3. Discovers interfaces and MAC addresses (show interface all) + 4. Performs LLDP discovery to identify physical connectivity: + - Enables LLDP if not configured + - Temporarily brings up unconfigured interfaces + - Collects LLDP neighbor data + - Restores original interface states + 5. Queries HA configuration (show high-availability all) + 6. Creates Ironic Port objects for discovered data plane interfaces + 7. Updates node.properties with discovered data + + :param task: a TaskManager instance. + :raises: HardwareInspectionFailure if inspection fails. + :returns: states.MANAGEABLE + """ + node = task.node + address = node.driver_info.get("management_ip") + if not address: + raise exception.InvalidParameterValue( + "Node %s missing management_ip in driver_info" + ) + + LOG.info("[node:%s] Starting PAN-OS inspection", node.uuid) + + # TODO: Get connection details from driver_info + # username = admin + # password = from secrets https://github.com/RSS-Engineering/undercloud-deploy/pull/2141 + # verify_ssl = False + + try: + # TODO: Connect to PAN-OS API + # session = _build_panos_session(verify_ssl) + # api_key = _get_api_key(address, username, password, session) + # will need to try multiple creds options until one works + + # TODO: Collect system information + # system_info = _collect_system_info(address, api_key, session) + # LOG.debug('[node:%s] System info: %s', node.uuid, system_info) + + # TODO: Collect interface data + # interfaces = _collect_interfaces(address, api_key, session) + # LOG.debug('[node:%s] Found %d interfaces', node.uuid, len(interfaces)) + + # TODO: Perform LLDP discovery + # lldp_neighbors = _discover_lldp_neighbors( + # address, api_key, session, interfaces + # ) + # LOG.info('[node:%s] Discovered %d LLDP neighbors', + # node.uuid, len(lldp_neighbors)) + + # TODO: Query HA configuration + # ha_config = _collect_ha_config(address, api_key, session) + # if ha_config.get('enabled'): + # LOG.info('[node:%s] HA enabled, peer: %s', + # node.uuid, ha_config.get('peer_serial')) + + # TODO: Update node properties + # node.properties['serial'] = system_info.get('serial') + # node.properties['model'] = system_info.get('model') + # node.properties['vendor'] = 'Palo Alto Networks' + # node.properties['firmware_version'] = system_info.get('sw-version') + # if ha_config.get('enabled'): + # node.extra['ha_peer_serial'] = ha_config.get('peer_serial') + # node.save() + + # TODO: Create/update Ironic Port objects from LLDP neighbors + # _create_ports_from_lldp(task, lldp_neighbors) + + LOG.info("[node:%s] PAN-OS inspection completed successfully", node.uuid) + # TODO: Remove when implemented + + except Exception as e: + msg = f"PAN-OS inspection failed for node {node.uuid}: {e}" + LOG.exception(msg) + raise exception.HardwareInspectionFailure(error=msg) from e + + return states.MANAGEABLE + + +# TODO: Implement helper functions for PAN-OS API operations +# These should be pure functions that take connection params and return data + + +def _build_panos_session(verify_ssl=False): + """Build HTTP session for PAN-OS API. + + :param verify_ssl: Whether to verify SSL certificates + :returns: requests.Session configured with retries + """ + # TODO: Implement + # - Create requests.Session + # - Configure retries + # - Set verify=verify_ssl + + +def _get_api_key(address, username, password, session): + """Obtain API key from PAN-OS device. + + :param address: Management IP or hostname + :param username: API username + :param password: API password + :param session: requests.Session + :returns: API key string + :raises: Exception if authentication fails + """ + # TODO: Implement + # - Call /api/?type=keygen + # - Parse XML response for key + + +def _collect_system_info(address, api_key, session): + """Collect system information from PAN-OS device. + + :param address: Management IP or hostname + :param api_key: API key + :param session: requests.Session + :returns: dict with system info (hostname, serial, model, sw-version, etc.) + """ + # TODO: Implement + # - Execute: + # - Parse XML response + # - Return dict with all fields + # Sample Data + """ + admin@PA-1410> show system info + hostname: PA-1410 + ip-address: 10.15.149.107 + public-ip-address: unknown + netmask: 255.255.255.0 + default-gateway: 10.15.149.1 + ip-assignment: static + ipv6-address: unknown + ipv6-link-local-address: fe80::8e36:7aff:fe23:3a3a/64 + ipv6-default-gateway: + mac-address: 8c:36:7a:23:3a:3a + time: Fri Sep 11 12:13:31 2026 + uptime: 15 days, 21:59:46 + family: 1400 + model: PA-1410 + serial: 026701009879 + base_mac: 60:15:2b:61:1a:00 + mac_count: 254 + cloud-mode: non-cloud + sw-version: 11.0.0 + global-protect-client-package-version: 0.0.0 + device-dictionary-version: 0 + device-dictionary-release-date: + app-version: 8635-7675 + app-release-date: + av-version: 0 + av-release-date: + threat-version: 0 + threat-release-date: + wf-private-version: 0 + wf-private-release-date: unknown + url-db: paloaltonetworks + wildfire-version: 0 + wildfire-release-date: + wildfire-rt: Disabled + url-filtering-version: 0000.00.00.000 + global-protect-datafile-version: unknown + global-protect-datafile-release-date: unknown + global-protect-clientless-vpn-version: 0 + global-protect-clientless-vpn-release-date: + logdb-version: 11.0.0 + dlp: dlp-4.0.0 + platform-family: 1400 + vpn-disable-mode: off + multi-vsys: off + zero-touch-provisioning: Disabled + operational-mode: normal + advanced-routing: off + device-certificate-status: None + """ + + +def _collect_interfaces(address, api_key, session): + """Collect interface information including MAC addresses. + + :param address: Management IP or hostname + :param api_key: API key + :param session: requests.Session + :returns: list of dicts with interface data (name, mac, state, speed, etc.) + """ + # TODO: Implement + # - Execute: all + # - Parse hw entries for MAC addresses + # - Return list of interface dicts + + +def _discover_lldp_neighbors(address, api_key, session, interfaces): + """Perform LLDP discovery to identify physical connectivity. + + This is the "full discovery" mode that temporarily modifies configuration: + 1. Check if LLDP is already configured + 2. If not, enable LLDP globally + 3. Create LLDP profile (transmit-receive mode) + 4. Configure unconfigured interfaces with LLDP + 5. Wait for LLDP neighbors (30 seconds) + 6. Query LLDP neighbor data + 7. Restore original interface states + 8. Commit restoration + + :param address: Management IP or hostname + :param api_key: API key + :param session: requests.Session + :param interfaces: list of interface dicts + :returns: list of LLDP neighbor dicts (local_interface, local_mac, + remote_chassis_id, remote_port_id, remote_system_name, etc.) + """ + # TODO: Implement full LLDP discovery flow + # This is where the logic from your current panos.py goes + # - Get configured interfaces + # - Determine which need LLDP config + # - Enable LLDP, create profile, configure interfaces + # - Commit, wait, query neighbors + # - Restore and commit again + + +def _collect_ha_config(address, api_key, session): + """Collect HA configuration and status. + + :param address: Management IP or hostname + :param api_key: API key + :param session: requests.Session + :returns: dict with HA info (enabled, state, peer_serial, mode, etc.) + Returns {'enabled': False} if HA is not configured + """ + # TODO: Implement + # - Execute: all + # - Parse response + # - Return HA configuration dict + + +def _create_ports_from_lldp(task, lldp_neighbors): + """Create or update Ironic Port objects from LLDP neighbor data. + + :param task: TaskManager instance + :param lldp_neighbors: list of LLDP neighbor dicts + """ + # TODO: Implement + # For each LLDP neighbor: + # - Create port_name as "node_name:interface_name" + # - Check if port already exists + # - Create or update port with: + # - address (MAC) + # - local_link_connection (switch_id, switch_info, port_id) + # - extra (bios_name = remote interface) + # - physical_network (from node or default) 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..46fa26f86 --- /dev/null +++ b/python/ironic-understack/ironic_understack/drivers/panos_management.py @@ -0,0 +1,286 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +"""PAN-OS Management Interface for Palo Alto firewalls.""" + +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): + """Management interface for PAN-OS firewalls. + + Provides lifecycle management operations for Palo Alto firewalls: + - Initial configuration setup (verify step during enroll->manageable) + - Factory reset / configuration cleanup (clean step) + - Future: firmware updates, configuration backups, etc. + + Inherits from NoopManagement to get sensible defaults for boot device + methods (which don't apply to network appliances). + """ + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return { + "panos_address": "Management IP address or hostname for API access. " + "Required. Example: 10.15.149.46", + "panos_username": 'API username. Required. Usually "admin".', + "panos_password": "API password. Required.", + "panos_verify_ssl": "Whether to verify SSL certificates. " + "Optional, defaults to False.", + # Initial setup parameters (used by verify step) + "panos_management_ip": "Management IP to configure on device. " + "Optional, only used during initial setup.", + "panos_management_netmask": "Management netmask. " + "Optional, only used during initial setup.", + "panos_management_gateway": "Management gateway. " + "Optional, only used during initial setup.", + } + + def validate(self, task): + """Validate that required credentials are present. + + :param task: a TaskManager instance. + :raises: MissingParameterValue if required parameters are missing. + """ + # TODO: Implement validation + # Check panos_address, panos_username, panos_password + + @base.verify_step(priority=10) + def setup_initial_configuration(self, task): + """Perform initial configuration setup for a new firewall. + + This verify step runs during the enroll->manageable transition + for brand new firewalls. It performs first-time setup: + + 1. Configure management interface (IP, netmask, gateway) + 2. Remove factory default configurations that conflict: + - Default security rule 'rule1' + - Default zones 'trust' and 'untrust' + - Default virtual-wire 'default-vwire' + - Default interface configs on ethernet1/1, ethernet1/2 + 3. Configure jumbo frames (if needed) + 4. Set admin credentials + 5. Set hostname + 6. Commit all changes + + This step is ONLY for initial setup. It should be idempotent + (safe to run multiple times) and should detect if setup is + already complete. + + :param task: a TaskManager instance. + :returns: None (synchronous operation) + :raises: Exception if setup fails + """ + node = task.node + LOG.info("[node:%s] Starting initial PAN-OS configuration setup", node.uuid) + + # TODO: Get connection and setup parameters from driver_info + # address = node.driver_info.get('panos_address') + # username = node.driver_info.get('panos_username') + # password = node.driver_info.get('panos_password') + # mgmt_ip = node.driver_info.get('panos_management_ip') + # mgmt_netmask = node.driver_info.get('panos_management_netmask') + # mgmt_gateway = node.driver_info.get('panos_management_gateway') + + try: + # TODO: Connect to device + # session = _build_panos_session(verify_ssl) + # api_key = _get_api_key(address, username, password, session) + + # TODO: Check if initial setup is already complete + # If device is already configured, skip + # is_configured = _check_if_configured(address, api_key, session) + # if is_configured: + # LOG.info('[node:%s] Device already configured, skipping setup', + # node.uuid) + # return + + # TODO: Configure management interface + # if mgmt_ip and mgmt_netmask and mgmt_gateway: + # _configure_management_interface( + # address, api_key, session, mgmt_ip, mgmt_netmask, mgmt_gateway + # ) + # LOG.info('[node:%s] Configured management interface', node.uuid) + + # TODO: Remove factory defaults + # _remove_factory_defaults(address, api_key, session) + # LOG.info('[node:%s] Removed factory default configuration', node.uuid) + + # TODO: Configure jumbo frames + # _configure_jumbo_frames(address, api_key, session) + # LOG.info('[node:%s] Configured jumbo frames', node.uuid) + + # TODO: Set hostname (from node name or properties) + # hostname = node.name or node.properties.get('hostname') + # if hostname: + # _set_hostname(address, api_key, session, hostname) + # LOG.info('[node:%s] Set hostname to %s', node.uuid, hostname) + + # TODO: Commit all changes + # _commit_config(address, api_key, session, + # description='Initial device setup') + # LOG.info('[node:%s] Committed initial configuration', node.uuid) + + LOG.info("[node:%s] Initial configuration setup completed", node.uuid) + # TODO: Remove when implemented + + except Exception as e: + msg = f"Initial configuration setup failed for node {node.uuid}: {e}" + LOG.exception(msg) + raise + + @base.clean_step(priority=10, requires_ramdisk=False) + def reset_to_factory_defaults(self, task): + """Reset firewall to factory defaults. + + This clean step runs during the cleaning phase (between tenant uses + or on-demand). It performs a full configuration reset: + + 1. Backup current configuration (optional) + 2. Execute factory reset command + 3. Wait for reboot + 4. Re-run initial setup (via setup_initial_configuration) + + WARNING: This is destructive and should only run during cleaning. + + :param task: a TaskManager instance. + :returns: None (synchronous) or states.CLEANWAIT (asynchronous) + :raises: Exception if reset fails + """ + node = task.node + LOG.info("[node:%s] Starting factory reset", node.uuid) + + # TODO: Implement factory reset + # This is a future feature - not needed for initial implementation + # For now, just log and skip + + LOG.warning("[node:%s] Factory reset not yet implemented, skipping", node.uuid) + + @base.clean_step(priority=5, requires_ramdisk=False, abortable=True, argsinfo={}) + def clear_configuration(self, task): + """Clear specific configuration sections without full factory reset. + + This clean step removes configuration that should not persist + between tenants: + - Security policies + - NAT rules + - Custom zones + - Custom interfaces + - Custom routing + + Preserves: + - Management interface configuration + - Admin credentials + - Basic system settings + + :param task: a TaskManager instance. + :returns: None + :raises: Exception if cleaning fails + """ + node = task.node + LOG.info("[node:%s] Starting configuration cleanup", node.uuid) + + # TODO: Implement selective configuration cleanup + # This is a future feature for between-tenant cleaning + + LOG.warning( + "[node:%s] Configuration cleanup not yet implemented, skipping", node.uuid + ) + + +# TODO: Implement helper functions for configuration operations + + +def _check_if_configured(address, api_key, session): + """Check if device has already been configured (not factory fresh). + + :returns: bool, True if configured, False if factory fresh + """ + # TODO: Implement + # - Query for presence of specific config elements + # - Check if management IP is set + # - Check if default rules/zones are gone + + +def _configure_management_interface( + address, api_key, session, mgmt_ip, netmask, gateway +): + """Configure management interface with IP/netmask/gateway. + + :param mgmt_ip: Management IP address + :param netmask: Netmask + :param gateway: Default gateway + """ + # TODO: Implement + # - Set management IP via config API + # - Set netmask + # - Set default gateway + # - DO NOT commit (caller will commit all changes together) + + +def _remove_factory_defaults(address, api_key, session): + """Remove factory default configuration that conflicts with production use. + + Removes: + - Default security rule 'rule1' + - Default zones 'trust' and 'untrust' + - Default virtual-wire 'default-vwire' + - Default interface configs on ethernet1/1, ethernet1/2 + """ + # TODO: Implement + # For each default config element: + # - Build xpath + # - Execute delete command + # - Log success/skip + # - DO NOT commit (caller will commit all changes together) + + +def _configure_jumbo_frames(address, api_key, session): + """Configure jumbo frame support on all interfaces. + + :param address: Management IP or hostname + :param api_key: API key + :param session: requests.Session + """ + # TODO: Implement + # - Query interfaces + # - For each interface, set MTU to 9000 (or configured value) + # - DO NOT commit (caller will commit all changes together) + + +def _set_hostname(address, api_key, session, hostname): + """Set device hostname. + + :param hostname: Hostname to set + """ + # TODO: Implement + # - Set hostname via config API + # - DO NOT commit (caller will commit all changes together) + + +def _commit_config(address, api_key, session, description=None): + """Commit configuration changes and wait for completion. + + :param description: Optional commit description + :raises: Exception if commit fails or times out + """ + # TODO: Implement + # - Execute commit command with description + # - Get job ID + # - Poll job status until complete + # - Raise exception if commit fails diff --git a/python/ironic-understack/ironic_understack/tests/fixtures/ha_state.xml b/python/ironic-understack/ironic_understack/tests/fixtures/ha_state.xml new file mode 100644 index 000000000..12b211429 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/fixtures/ha_state.xml @@ -0,0 +1 @@ +nono diff --git a/python/ironic-understack/ironic_understack/tests/fixtures/interface_management.xml b/python/ironic-understack/ironic_understack/tests/fixtures/interface_management.xml new file mode 100644 index 000000000..07ba04a2b --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/fixtures/interface_management.xml @@ -0,0 +1,37 @@ + + + auto + auto + auto + 1000 + full + up + RJ45 + 8c:36:7a:23:3a:3a + 10.15.149.107 + 255.255.255.0 + 10.15.149.1 + unknown + fe80::8e36:7aff:fe23:3a3a/64 + + Management Interface + + + 18607536 + 0 + 0 + 0 + 0 + 0 + 0 + 130042 + 1256501 + 0 + 0 + 0 + 0 + 0 + 0 + 4063 + + diff --git a/python/ironic-understack/ironic_understack/tests/fixtures/lldp_neighbors.xml b/python/ironic-understack/ironic_understack/tests/fixtures/lldp_neighbors.xml new file mode 100644 index 000000000..3ed082291 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/fixtures/lldp_neighbors.xml @@ -0,0 +1 @@ + diff --git a/python/ironic-understack/ironic_understack/tests/fixtures/system_info.xml b/python/ironic-understack/ironic_understack/tests/fixtures/system_info.xml new file mode 100644 index 000000000..9c8d8342a --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/fixtures/system_info.xml @@ -0,0 +1,28 @@ +PA-141010.15.149.107unknown255.255.255.010.15.149.1nounknownfe80::8e36:7aff:fe23:3a3a/648c:36:7a:23:3a:3a +15 days, 22:09:12 +PA-1410 +1400PA-141002670100987960:15:2b:61:1a:00 +254 +non-cloud +11.0.0 +0.0.0 +0 +8635-7675 +0 +0 +0 +unknown +paloaltonetworks +0 +Disabled +0000.00.00.000 +unknown +unknown0 +11.0.0 +dlp-4.0.01400 +off +off +Disablednormal +off +None diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py b/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py new file mode 100644 index 000000000..5d77e77cb --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_panos_hardware.py @@ -0,0 +1,58 @@ +"""Tests for PAN-OS hardware type.""" + +from ironic.drivers.modules import noop +from ironic.drivers.modules.storage import noop as noop_storage + +from ironic_understack.drivers.panos_hardware import PanosHardware + + +def _interface_names(ifaces): + """Extract class names from interface list.""" + return [cls.__name__ for cls in ifaces] + + +def test_panos_hardware_type_exists(): + """Test that PanosHardware can be instantiated.""" + hw = PanosHardware() + assert hw is not None + + +def test_panos_inspect_interface(): + """Test that PanosInspect is the supported inspect interface.""" + hw = PanosHardware() + assert _interface_names(hw.supported_inspect_interfaces) == ["PanosInspect"] + + +def test_panos_management_interface(): + """Test that PanosManagement is the supported management interface.""" + hw = PanosHardware() + assert _interface_names(hw.supported_management_interfaces) == ["PanosManagement"] + + +def test_panos_deploy_interface(): + """Test that NoDeploy is used (firewalls don't deploy).""" + hw = PanosHardware() + assert _interface_names(hw.supported_deploy_interfaces) == ["NoDeploy"] + + +def test_panos_network_interface(): + """Test that NeutronNetwork is the supported network interface.""" + hw = PanosHardware() + assert _interface_names(hw.supported_network_interfaces) == ["NeutronNetwork"] + + +def test_panos_bios_interface(): + """Test that NoBIOS is used (firewalls don't have BIOS config).""" + hw = PanosHardware() + assert _interface_names(hw.supported_bios_interfaces) == ["NoBIOS"] + + +def test_panos_noop_interfaces(): + """Test that appropriate noop interfaces are used.""" + hw = PanosHardware() + assert hw.supported_console_interfaces == [noop.NoConsole] + assert hw.supported_firmware_interfaces == [noop.NoFirmware] + assert hw.supported_raid_interfaces == [noop.NoRAID] + assert hw.supported_rescue_interfaces == [noop.NoRescue] + assert hw.supported_storage_interfaces == [noop_storage.NoopStorage] + assert hw.supported_vendor_interfaces == [noop.NoVendor] 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..f43d106f3 --- /dev/null +++ b/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py @@ -0,0 +1,137 @@ +"""Tests for PAN-OS inspect interface.""" + +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest.mock import Mock + +import pytest +from ironic.common import exception + +from ironic_understack.drivers.panos_inspect import PanosInspect + + +@pytest.fixture +def mock_task(): + """Create a mock Ironic task object.""" + task = Mock() + task.node = Mock() + task.node.uuid = "test-node-uuid" + task.node.driver_info = { + "management_ip": "10.15.149.107", + } + # Credentials will come from the panos-credentials secret + # via the credential() helper, not from driver_info + return task + + +@pytest.fixture +def fixtures_dir(): + """Return path to test fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def system_info_xml(fixtures_dir): + """Load system_info.xml fixture.""" + return (fixtures_dir / "system_info.xml").read_text() + + +@pytest.fixture +def interface_mgmt_xml(fixtures_dir): + """Load interface_management.xml fixture.""" + return (fixtures_dir / "interface_management.xml").read_text() + + +@pytest.fixture +def ha_state_xml(fixtures_dir): + """Load ha_state.xml fixture.""" + return (fixtures_dir / "ha_state.xml").read_text() + + +class TestPanosInspect: + """Test cases for PanosInspect interface.""" + + def test_instantiate(self): + """Test that PanosInspect can be instantiated.""" + inspector = PanosInspect() + assert inspector is not None + + def test_get_properties(self): + """Test get_properties returns property descriptions.""" + inspector = PanosInspect() + props = inspector.get_properties() + # Should return dict with property descriptions + assert isinstance(props, dict) + # Currently returns properties but we don't strictly require any + + def test_validate_missing_management_ip(self, mock_task): + """Test validate raises error when management_ip is missing.""" + mock_task.node.driver_info = {} + inspector = PanosInspect() + + # Should raise since panos_address (or management_ip) is missing + with pytest.raises(exception.MissingParameterValue): + inspector.validate(mock_task) + + def test_validate_success(self, mock_task): + """Test validate succeeds with required fields present.""" + # Set the fields that the driver actually needs + mock_task.node.driver_info = { + "panos_address": "10.15.149.107", + "panos_username": "admin", + "panos_password": "password", + } + inspector = PanosInspect() + # Should not raise + inspector.validate(mock_task) + + def test_parse_system_info(self, system_info_xml): + """Test parsing system info XML.""" + root = ET.fromstring(system_info_xml) # noqa: S314 + system = root.find(".//system") + + assert system is not None + assert system.find("serial").text == "026701009879" + assert system.find("model").text == "PA-1410" + assert system.find("hostname").text == "PA-1410" + assert system.find("sw-version").text == "11.0.0" + assert system.find("mac-address").text == "8c:36:7a:23:3a:3a" + assert system.find("ip-address").text == "10.15.149.107" + assert system.find("netmask").text == "255.255.255.0" + assert system.find("default-gateway").text == "10.15.149.1" + + def test_parse_interface_management(self, interface_mgmt_xml): + """Test parsing management interface XML.""" + root = ET.fromstring(interface_mgmt_xml) # noqa: S314 + info = root.find(".//info") + + assert info is not None + assert info.find("state").text == "up" + assert info.find("ip").text == "10.15.149.107" + assert info.find("netmask").text == "255.255.255.0" + assert info.find("gw").text == "10.15.149.1" + assert info.find("hwaddr").text == "8c:36:7a:23:3a:3a" + + def test_parse_ha_state_disabled(self, ha_state_xml): + """Test parsing HA state when HA is disabled.""" + root = ET.fromstring(ha_state_xml) # noqa: S314 + enabled = root.find(".//enabled") + + assert enabled is not None + assert enabled.text == "no" + + def test_credential_fallback_order(self): + """Test credentials tried in order: standard -> preconfig -> factory.""" + # This test documents the expected credential fallback behavior + expected_order = [ + "standard_password", + "preconfig_password", + "factory_password", + ] + + # Implementation will need to try these in order + assert expected_order == [ + "standard_password", + "preconfig_password", + "factory_password", + ] diff --git a/python/ironic-understack/pyproject.toml b/python/ironic-understack/pyproject.toml index ed15a9443..32d96eb4f 100644 --- a/python/ironic-understack/pyproject.toml +++ b/python/ironic-understack/pyproject.toml @@ -28,10 +28,15 @@ chassis_model = "ironic_understack.hooks.inspect_hook_chassis_model:InspectHookC [project.entry-points."ironic.hardware.types"] netdev = "ironic_understack.drivers.netdev_hardware:NetdevHardware" +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" From ba9cef56d46243da9e922e3b3486afe819381581 Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Mon, 14 Sep 2026 08:55:39 -0400 Subject: [PATCH 2/4] - Loading the panos_creds into ironic - soft failures if undercloud_deploy hasn't been released for that site - password guessing option integrated (one of three that it could be) --- .../drivers/panos_inspect.py | 148 ++++++++++++++---- .../tests/test_panos_inspect.py | 15 +- 2 files changed, 123 insertions(+), 40 deletions(-) diff --git a/python/ironic-understack/ironic_understack/drivers/panos_inspect.py b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py index bf2b54a5b..556d80451 100644 --- a/python/ironic-understack/ironic_understack/drivers/panos_inspect.py +++ b/python/ironic-understack/ironic_understack/drivers/panos_inspect.py @@ -54,7 +54,7 @@ def get_properties(self): } def validate(self, task): - """Validate the driver_info contains required credentials. + """Validate the driver_info contains required connection information. :param task: a TaskManager instance. :raises: MissingParameterValue if required parameters are missing. @@ -62,21 +62,34 @@ def validate(self, task): """ driver_info = task.node.driver_info - # TODO: Implement validation - # Check that panos_address, panos_username, panos_password are present - # Optionally validate that we can connect to the device - - missing = [] - if not driver_info.get("panos_address"): - missing.append("panos_address") - if not driver_info.get("panos_username"): - missing.append("panos_username") - if not driver_info.get("panos_password"): - missing.append("panos_password") - - if missing: + # Validate that we have the management IP (set by enroll-fw) + # Credentials come from the panos-credentials K8s secret mounted at + # /etc/panos-credentials/, not from driver_info + if not driver_info.get("management_ip"): raise exception.MissingParameterValue( - f'Missing required driver_info parameters: {", ".join(missing)}' + f"Node {task.node.uuid} missing required driver_info parameter: " + "management_ip" + ) + + # Check if credentials exist (but don't fail - just log warning) + # This allows understack to deploy before undercloud-deploy + import os + + cred_path = "/etc/panos-credentials" + if not os.path.exists(cred_path): + LOG.warning( + "[node:%s] PAN-OS credentials directory %s does not exist. " + "Inspection will fail until panos-credentials secret is deployed.", + task.node.uuid, + cred_path, + ) + elif not os.path.exists(f"{cred_path}/standard_password"): + LOG.warning( + "[node:%s] PAN-OS credentials are incomplete in %s. " + "Expected files: standard_password, preconfig_password, " + "factory_password", + task.node.uuid, + cred_path, ) def inspect_hardware(self, task): @@ -103,21 +116,45 @@ def inspect_hardware(self, task): address = node.driver_info.get("management_ip") if not address: raise exception.InvalidParameterValue( - "Node %s missing management_ip in driver_info" + f"Node {node.uuid} missing management_ip in driver_info" ) - LOG.info("[node:%s] Starting PAN-OS inspection", node.uuid) - - # TODO: Get connection details from driver_info - # username = admin - # password = from secrets https://github.com/RSS-Engineering/undercloud-deploy/pull/2141 - # verify_ssl = False + LOG.info("[node:%s] Starting PAN-OS inspection for %s", node.uuid, address) try: - # TODO: Connect to PAN-OS API - # session = _build_panos_session(verify_ssl) - # api_key = _get_api_key(address, username, password, session) - # will need to try multiple creds options until one works + # Get credentials from Kubernetes secret + # (mounted at /etc/panos-credentials/) + # Try in order: standard -> preconfig -> factory + # If credentials don't exist yet, fail gracefully + try: + from understack_workflows.helpers import credential + + passwords = [ + credential("panos-credentials", "standard_password"), + credential("panos-credentials", "preconfig_password"), + credential("panos-credentials", "factory_password"), + ] + except FileNotFoundError as e: + raise exception.HardwareInspectionFailure( + f"PAN-OS credentials not found for node {node.uuid}. " + "The panos-credentials secret may not be deployed yet. " + f"Missing file: {e}" + ) from e + + username = "admin" # PAN-OS always uses 'admin' + verify_ssl = False # Devices have self-signed certs + + # Build session and authenticate + session = _build_panos_session(verify_ssl) + api_key, password_type = _try_authenticate( + address, username, passwords, session + ) + LOG.info( + "[node:%s] Connected to %s with %s password", + node.uuid, + address, + password_type, + ) # TODO: Collect system information # system_info = _collect_system_info(address, api_key, session) @@ -183,15 +220,64 @@ def _get_api_key(address, username, password, session): """Obtain API key from PAN-OS device. :param address: Management IP or hostname - :param username: API username - :param password: API password + :param username: API username (always 'admin') + :param password: API password to try :param session: requests.Session - :returns: API key string - :raises: Exception if authentication fails + :returns: API key string or None if authentication fails """ # TODO: Implement - # - Call /api/?type=keygen + # - Call /api/?type=keygen&user={username}&password={password} # - Parse XML response for key + # - Return key on success, None on auth failure + # - Raise exception for connection errors (not auth failures) + + +def _try_authenticate(address, username, passwords, session): + """Try to authenticate with PAN-OS device using credential fallback. + + Tries passwords in order: standard -> preconfig -> factory + + :param address: Management IP or hostname + :param username: API username (always 'admin') + :param passwords: list of passwords to try [standard, preconfig, factory] + :param session: requests.Session + :returns: tuple (api_key, password_used) on success + :raises: Exception if all passwords fail or connection error + """ + last_error = None + + for idx, password in enumerate(passwords): + password_type = ["standard", "preconfig", "factory"][idx] + LOG.info( + "[%s] Attempting authentication with %s password", + address, + password_type, + ) + + try: + api_key = _get_api_key(address, username, password, session) + if api_key: + LOG.info( + "[%s] Authentication successful with %s password", + address, + password_type, + ) + return api_key, password_type + except Exception as e: + LOG.debug( + "[%s] Authentication failed with %s password: %s", + address, + password_type, + e, + ) + last_error = e + continue + + # All passwords failed + raise exception.HardwareInspectionFailure( + f"Failed to authenticate to {address} with any of the configured " + f"passwords. Last error: {last_error}" + ) def _collect_system_info(address, api_key, session): diff --git a/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py b/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py index f43d106f3..556064c0c 100644 --- a/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py +++ b/python/ironic-understack/ironic_understack/tests/test_panos_inspect.py @@ -69,18 +69,15 @@ def test_validate_missing_management_ip(self, mock_task): mock_task.node.driver_info = {} inspector = PanosInspect() - # Should raise since panos_address (or management_ip) is missing - with pytest.raises(exception.MissingParameterValue): + # Should raise since management_ip is missing + with pytest.raises(exception.MissingParameterValue) as exc_info: inspector.validate(mock_task) + assert "management_ip" in str(exc_info.value) + def test_validate_success(self, mock_task): - """Test validate succeeds with required fields present.""" - # Set the fields that the driver actually needs - mock_task.node.driver_info = { - "panos_address": "10.15.149.107", - "panos_username": "admin", - "panos_password": "password", - } + """Test validate succeeds with management_ip present.""" + # management_ip is already in the mock_task fixture inspector = PanosInspect() # Should not raise inspector.validate(mock_task) From 98a5eff15df7b308a3cf19b27f0b51019cf72d47 Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Mon, 14 Sep 2026 12:08:32 -0400 Subject: [PATCH 3/4] Mounting panos-credentials --- components/ironic/values.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/ironic/values.yaml b/components/ironic/values.yaml index 198195dc4..9f9cf58cc 100644 --- a/components/ironic/values.yaml +++ b/components/ironic/values.yaml @@ -265,6 +265,9 @@ pod: - name: ironic-inspection-rules mountPath: /etc/ironic/inspection-rules/ readOnly: true + - name: panos-credentials + mountPath: /etc/panos-credentials + readOnly: true volumes: - name: dnsmasq-ironic persistentVolumeClaim: @@ -279,6 +282,11 @@ pod: configMap: name: ironic-inspection-rules optional: true + - name: panos-credentials + secret: + secretName: panos-credentials + defaultMode: 0400 + optional: true replicas: api: 4 conductor: 1 From 014cf52896b6dfbb406c73852472d858c629bcce Mon Sep 17 00:00:00 2001 From: Kenny Hackworth Date: Thu, 17 Sep 2026 07:38:37 -0400 Subject: [PATCH 4/4] Updating the clean_step priority --- .../ironic_understack/drivers/panos_management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/ironic-understack/ironic_understack/drivers/panos_management.py b/python/ironic-understack/ironic_understack/drivers/panos_management.py index 46fa26f86..1cdf81757 100644 --- a/python/ironic-understack/ironic_understack/drivers/panos_management.py +++ b/python/ironic-understack/ironic_understack/drivers/panos_management.py @@ -144,7 +144,7 @@ def setup_initial_configuration(self, task): LOG.exception(msg) raise - @base.clean_step(priority=10, requires_ramdisk=False) + @base.clean_step(priority=0, requires_ramdisk=False) def reset_to_factory_defaults(self, task): """Reset firewall to factory defaults. @@ -171,7 +171,7 @@ def reset_to_factory_defaults(self, task): LOG.warning("[node:%s] Factory reset not yet implemented, skipping", node.uuid) - @base.clean_step(priority=5, requires_ramdisk=False, abortable=True, argsinfo={}) + @base.clean_step(priority=0, requires_ramdisk=False, abortable=True, argsinfo={}) def clear_configuration(self, task): """Clear specific configuration sections without full factory reset.