Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/operator-guide/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
349 changes: 349 additions & 0 deletions scripts/audit_ports_missing_physical_network.py
Original file line number Diff line number Diff line change
@@ -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 "<unknown>"
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())
Loading