From 8421f2486a3aba1f4e6dacd0c5445231fa373fe0 Mon Sep 17 00:00:00 2001 From: haseeb Date: Mon, 13 Jul 2026 18:30:53 +0530 Subject: [PATCH] PUC-1757: Audit Neutron ports missing physical_network in binding_profile --- docs/operator-guide/scripts.md | 26 ++ .../audit_ports_missing_physical_network.py | 349 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100755 scripts/audit_ports_missing_physical_network.py diff --git a/docs/operator-guide/scripts.md b/docs/operator-guide/scripts.md index 153d2b472..5b5aa37e1 100644 --- a/docs/operator-guide/scripts.md +++ b/docs/operator-guide/scripts.md @@ -59,3 +59,29 @@ For example, to enroll any missing servers in the rack named "F20-3", you can ru ``` bash ./enroll-missing-nodes.sh F20-3 ``` + +## audit_ports_missing_physical_network.py + +Audits Neutron ports whose `binding_profile` contains `local_link_information` +but is missing `physical_network`. + +The script defaults to report-only mode. Add `--execute` to write the derived +`physical_network` back into the port's `binding_profile`. + +The repair path mirrors the mechanism driver, but it will try each +`local_link_information` entry in order until it finds a matching Ironic +baremetal port, then copies that port's `physical_network`. + +The script prints a JSON array of ports that are missing `physical_network`. + +Run the environments in this order: + +``` bash +./scripts/audit_ports_missing_physical_network.py --os-cloud uc-iad3-dev +./scripts/audit_ports_missing_physical_network.py --os-cloud uc-iad3-dev --execute +./scripts/audit_ports_missing_physical_network.py --os-cloud uc-iad3-staging --execute +./scripts/audit_ports_missing_physical_network.py --os-cloud uc-dfw3-prod --execute +``` + +In `--execute` mode, unresolved ports stay in the JSON report and are logged as +warnings; only actual update failures are treated as hard errors. diff --git a/scripts/audit_ports_missing_physical_network.py b/scripts/audit_ports_missing_physical_network.py new file mode 100755 index 000000000..2bb243fdd --- /dev/null +++ b/scripts/audit_ports_missing_physical_network.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Audit and repair Neutron ports missing binding_profile.physical_network. + +The target inconsistency is: + - binding_profile.local_link_information is present + - binding_profile.physical_network is missing or empty + +Dry-run is the default. Pass --execute to write the derived physical_network +back to the Neutron port binding_profile. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import sys +from typing import Any + +LOG = logging.getLogger(__name__) + + +def get_value(resource: Any, *names: str) -> Any: + for name in names: + if isinstance(resource, dict): + if name not in resource: + continue + value = resource[name] + else: + getter = getattr(resource, "get", None) + if callable(getter): + try: + value = getter(name) + except Exception: + value = None + if value is not None: + return value + value = getattr(resource, name, None) + if value is not None: + return value + return None + + +def has_value(value: Any) -> bool: + return value not in (None, "") + + +def normalize_binding_profile(port: Any) -> dict[str, Any]: + port_id = get_value(port, "id") or "" + raw_profile = get_value(port, "binding_profile", "binding:profile") + + if raw_profile in (None, ""): + return {} + if isinstance(raw_profile, dict): + return raw_profile + if isinstance(raw_profile, str): + try: + profile = json.loads(raw_profile) + except json.JSONDecodeError as exc: + raise ValueError( + f"port {port_id} has invalid JSON binding_profile" + ) from exc + if isinstance(profile, dict): + return profile + raise ValueError(f"port {port_id} binding_profile is not a JSON object") + + raise ValueError( + f"port {port_id} binding_profile has unsupported type " + f"{type(raw_profile).__name__}" + ) + + +def local_link_information(binding_profile: dict[str, Any]) -> list[Any]: + value = binding_profile.get("local_link_information") + if value in (None, ""): + return [] + if isinstance(value, list): + return value + return [value] + + +def local_link_connections(binding_profile: dict[str, Any]) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + for entry in local_link_information(binding_profile): + if isinstance(entry, dict): + candidates.append(entry) + return candidates + + +def port_id(port: Any) -> str | None: + return get_value(port, "id") + + +def port_name(port: Any) -> str | None: + return get_value(port, "name") + + +def port_network_id(port: Any) -> str | None: + return get_value(port, "network_id") + + +def binding_host_id(port: Any) -> str | None: + return get_value(port, "binding_host_id", "binding:host_id") + + +def binding_vnic_type(port: Any) -> str | None: + return get_value(port, "binding_vnic_type", "binding:vnic_type") + + +def port_record( + port: Any, + binding_profile: dict[str, Any], +) -> dict[str, Any]: + return { + "port_id": port_id(port), + "port_name": port_name(port), + "network_id": port_network_id(port), + "mac_address": get_value(port, "mac_address"), + "device_id": get_value(port, "device_id"), + "device_owner": get_value(port, "device_owner"), + "status": get_value(port, "status"), + "binding_host_id": binding_host_id(port), + "binding_vnic_type": binding_vnic_type(port), + "binding_profile": binding_profile, + "physical_network": binding_profile.get("physical_network"), + } + + +def get_baremetal_service(conn: Any) -> Any: + service = getattr(conn, "baremetal", None) + if service is None: + raise RuntimeError("OpenStack connection does not expose a baremetal service") + ports = getattr(service, "ports", None) + if not callable(ports): + raise RuntimeError("OpenStack baremetal service does not expose ports()") + return ports + + +def lookup_baremetal_port_physical_network( + conn: Any, local_link_connection: dict[str, Any] +) -> tuple[str | None, str | None]: + try: + ports = get_baremetal_service(conn) + except RuntimeError as exc: + return None, str(exc) + + try: + port = next(ports(details=True, local_link_connection=local_link_connection)) + except StopIteration: + return None, "no Ironic baremetal port matched any local_link_information entry" + except Exception as exc: + return None, f"baremetal lookup failed: {exc}" + + physical_network = get_value(port, "physical_network") + if has_value(physical_network): + return physical_network, None + + return None, "matching Ironic baremetal port has no physical_network" + + +def derive_physical_network( + conn: Any, binding_profile: dict[str, Any] +) -> dict[str, Any]: + candidates = local_link_connections(binding_profile) + if not candidates: + return { + "derived_physical_network": None, + "reason": "binding_profile has no usable local_link_information", + } + + last_reason = "no Ironic baremetal port matched any local_link_information entry" + for local_link_connection in candidates: + derived_physical_network, reason = lookup_baremetal_port_physical_network( + conn, local_link_connection + ) + if has_value(derived_physical_network): + return { + "derived_physical_network": derived_physical_network, + "reason": None, + } + + if reason and reason.startswith( + ( + "baremetal lookup failed:", + "OpenStack connection does not expose a baremetal service", + "OpenStack baremetal service does not expose ports()", + ) + ): + return { + "derived_physical_network": None, + "reason": reason, + } + + if reason: + last_reason = reason + + return { + "derived_physical_network": None, + "reason": last_reason, + } + + +def build_missing_physical_network_record( + conn: Any, port: Any +) -> dict[str, Any] | None: + pid = port_id(port) + if not pid: + LOG.debug("skipping port without id: %r", port) + return None + + try: + detailed_port = conn.network.get_port(pid) + except Exception as exc: + LOG.debug("skipping port %s: unable to fetch port details: %s", pid, exc) + return None + + try: + profile = normalize_binding_profile(detailed_port) + except ValueError as exc: + LOG.debug("skipping port %s: %s", pid, exc) + return None + + record = port_record(detailed_port, profile) + + if not local_link_information(profile): + return None + + if has_value(record["physical_network"]): + return None + + record.update(derive_physical_network(conn, profile)) + return record + + +def build_missing_physical_network_report(conn: Any) -> list[dict[str, Any]]: + report: list[dict[str, Any]] = [] + + for port in conn.network.ports(): + record = build_missing_physical_network_record(conn, port) + if record is not None: + report.append(record) + + return report + + +def apply_fixes( + conn: Any, records: list[dict[str, Any]] +) -> tuple[int, int, int]: + updated = 0 + failed = 0 + skipped = 0 + + for record in records: + derived = record.get("derived_physical_network") + if not derived: + skipped += 1 + continue + + profile = dict(record["binding_profile"]) + profile["physical_network"] = derived + try: + conn.network.update_port(record["port_id"], binding_profile=profile) + except Exception as exc: + failed += 1 + LOG.error("failed to update port %s: %s", record["port_id"], exc) + continue + + updated += 1 + + return updated, failed, skipped + + +def connect_openstack(os_cloud: str | None) -> Any: + try: + import openstack + except ImportError as exc: + raise RuntimeError("openstacksdk is required to run this script") from exc + + return openstack.connect(cloud=os_cloud) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Audit Neutron ports whose binding_profile has local_link_information " + "but is missing physical_network, using the same Ironic lookup as the " + "mechanism driver." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Examples:\n" + " audit_ports_missing_physical_network.py --os-cloud uc-iad3-dev\n" + " audit_ports_missing_physical_network.py --os-cloud uc-iad3-dev --execute" + ), + ) + parser.add_argument( + "--os-cloud", + metavar="CLOUD", + default=None, + help="OpenStack cloud name from clouds.yaml. Defaults to OS_CLOUD.", + ) + parser.add_argument( + "--execute", + action="store_true", + help="Update affected ports before printing the JSON report.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable debug logging.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s %(message)s", + ) + + try: + conn = connect_openstack(args.os_cloud) + except Exception as exc: + print(f"ERROR: failed to connect to OpenStack: {exc}", file=sys.stderr) + return 1 + + records = build_missing_physical_network_report(conn) + + if args.execute: + _updated, failed, skipped = apply_fixes(conn, records) + else: + failed = skipped = 0 + + print(json.dumps(records, indent=2, sort_keys=True)) + + if skipped: + LOG.warning("skipped %d port(s) without a derived physical_network", skipped) + if failed: + LOG.error("failed to update %d port(s)", failed) + + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main())