From aa748fa6e9509d2259c45e796be40857649f46f6 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 14 Jul 2026 12:47:03 +0100 Subject: [PATCH 1/5] chore: upgrade OVN chart to 2026.1.3 --- charts/argocd-understack/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index abf3a677f..1396ea73a 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -467,7 +467,7 @@ site: enabled: false # -- Chart version for OVN # renovate: datasource=helm depName=ovn registryUrl=https://tarballs.opendev.org/openstack/openstack-helm-infra - chartVersion: "2024.2.0" + chartVersion: "2026.1.3+a659ab8a2" # -- Chrony NTP service configuration chrony: From fa2c3f608a4ec9f829dc708ab97ad3dfac562298 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 14 Jul 2026 12:49:10 +0100 Subject: [PATCH 2/5] feat: update OVN images --- components/images-openstack.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/images-openstack.yaml b/components/images-openstack.yaml index d63aa6968..d830f4357 100644 --- a/components/images-openstack.yaml +++ b/components/images-openstack.yaml @@ -72,10 +72,10 @@ images: openvswitch_vswitchd: "docker.io/openstackhelm/openvswitch:ubuntu_jammy-dpdk-20250127" # ovn - ovn_ovsdb_nb: "docker.io/openstackhelm/ovn:ubuntu_jammy-20250111" - ovn_ovsdb_sb: "docker.io/openstackhelm/ovn:ubuntu_jammy-20250111" - ovn_northd: "docker.io/openstackhelm/ovn:ubuntu_jammy-20250111" - ovn_controller: "docker.io/openstackhelm/ovn:ubuntu_jammy-20250111" + ovn_ovsdb_nb: "quay.io/airshipit/ovn:ubuntu_noble-20260711" + ovn_ovsdb_sb: "quay.io/airshipit/ovn:ubuntu_noble-20260711" + ovn_northd: "quay.io/airshipit/ovn:ubuntu_noble-20260711" + ovn_controller: "quay.io/airshipit/ovn:ubuntu_noble-20260711" # horizon horizon: "ghcr.io/rackerlabs/understack/horizon:2025.2" From 669f904716314107ee7a9ee2416498eaff1bb1d9 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Tue, 14 Jul 2026 12:55:02 +0100 Subject: [PATCH 3/5] fix(ovn): openstack-helm-infra -> openstack-helm --- charts/argocd-understack/templates/application-ovn.yaml | 2 +- charts/argocd-understack/values.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/argocd-understack/templates/application-ovn.yaml b/charts/argocd-understack/templates/application-ovn.yaml index e4108da62..e3ab34f02 100644 --- a/charts/argocd-understack/templates/application-ovn.yaml +++ b/charts/argocd-understack/templates/application-ovn.yaml @@ -14,7 +14,7 @@ spec: server: {{ $.Values.cluster_server }} project: understack sources: - - repoURL: https://tarballs.opendev.org/openstack/openstack-helm-infra + - repoURL: https://tarballs.opendev.org/openstack/openstack-helm targetRevision: {{ $app.chartVersion }} chart: ovn helm: diff --git a/charts/argocd-understack/values.yaml b/charts/argocd-understack/values.yaml index 1396ea73a..1953c1e33 100644 --- a/charts/argocd-understack/values.yaml +++ b/charts/argocd-understack/values.yaml @@ -466,7 +466,7 @@ site: # @default -- false enabled: false # -- Chart version for OVN - # renovate: datasource=helm depName=ovn registryUrl=https://tarballs.opendev.org/openstack/openstack-helm-infra + # renovate: datasource=helm depName=ovn registryUrl=https://tarballs.opendev.org/openstack/openstack-helm chartVersion: "2026.1.3+a659ab8a2" # -- Chrony NTP service configuration From c988261b266b1214cc6d90585b4afce2e5451157 Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 15 Jul 2026 08:55:35 +0100 Subject: [PATCH 4/5] feat(scripts): add OVN HA_Chassis_Group repair script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleans up stale HA_Chassis rows left behind by decommissioned/replaced chassis, and repopulates per-network unified HA_Chassis_Groups that were left empty as a result — avoiding the need to manually detach/reattach subnets per router to re-trigger neutron_understack's link_vxlan_network_ha_chassis_group workaround. Dry-run by default. --- scripts/cleanup_dead_ovn_ha_chassis.py | 476 +++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100755 scripts/cleanup_dead_ovn_ha_chassis.py diff --git a/scripts/cleanup_dead_ovn_ha_chassis.py b/scripts/cleanup_dead_ovn_ha_chassis.py new file mode 100755 index 000000000..5d9cf86a0 --- /dev/null +++ b/scripts/cleanup_dead_ovn_ha_chassis.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python3 +"""Repair OVN HA_Chassis_Group state left behind by chassis decommissioning. + +Two related problems, fixed in one pass: + +1. Stale HA_Chassis rows. An HA_Chassis row is "dead" when its chassis_name + does not match any currently-registered chassis in the Southbound DB — + typically left behind after a host is decommissioned/replaced. Dead rows + sitting in an HA_Chassis_Group don't just waste space: + neutron_understack's link_vxlan_network_ha_chassis_group() (routers.py) + requires every HA_Chassis row in the whole NB database to share a single + chassis_name before it will populate a network's unified + HA_Chassis_Group. A single stale row anywhere breaks that check for + every vxlan network in the fleet. + +2. Empty per-network unified HA_Chassis_Groups. Once (1) has happened (or + for any other reason a network's unified group ended up empty), its + external/baremetal ports have no chassis to claim them. Normally you'd + fix this per-router by detaching/reattaching a subnet to re-fire + link_vxlan_network_ha_chassis_group. This script instead derives the + right chassis directly from the router's own HA_Chassis_Group and writes + it to the network's group (plus anchors the internal router-interface + LRP to it), mirroring what that function does — for every affected + network in one run, no manual per-router action needed. + +Runs in dry-run mode by default. Pass --execute to apply changes. + +Caveat: repopulation here is a direct, minimal OVN write. It does not +replicate neutron's candidate filtering (chassis-as-gw eligibility, physnet +connectivity, priority ordering across multiple chassis) that +sync_ha_chassis_group_network_unified performs. It only picks a chassis +if the router's own HA_Chassis_Group resolves to exactly one distinct live +chassis. This is safe for the common single-gateway-chassis case; in a +multi-chassis HA setup, prefer re-triggering the real neutron code path. +""" + +import argparse +import json +import logging +import subprocess +import sys + +NB_POD = "ovn-ovsdb-nb-0" +SB_POD = "ovn-ovsdb-sb-0" +OVN_NAMESPACE = "openstack" + +OVN_NETWORK_ID_EXT_ID_KEY = "neutron:network_id" +OVN_ROUTER_ID_EXT_ID_KEY = "neutron:router_id" +HA_CHASSIS_GROUP_HIGHEST_PRIORITY = 32767 + +log = logging.getLogger(__name__) + + +def _unwrap_ovn_value(val): + """Recursively unwrap an OVN JSON-encoded value.""" + if not isinstance(val, list) or len(val) < 2: + return val + tag = val[0] + if tag == "uuid": + return val[1] + if tag == "set": + return [_unwrap_ovn_value(v) for v in val[1]] + if tag == "map": + return {_unwrap_ovn_value(k): _unwrap_ovn_value(v) for k, v in val[1]} + return val + + +def parse_ovn_json(raw: str) -> list[dict]: + """Parse OVN --format=json list output into a list of row dicts.""" + obj = json.loads(raw) + headings = obj["headings"] + return [ + {h: _unwrap_ovn_value(v) for h, v in zip(headings, row)} for row in obj["data"] + ] + + +def _as_list(val) -> list: + """OVSDB unwraps single-element sets to a bare value instead of a list.""" + if val in (None, ""): + return [] + return val if isinstance(val, list) else [val] + + +def _ovn_cmd( + kubectl_base: list[str], pod: str, ctl: str, *args: str +) -> subprocess.CompletedProcess: + return subprocess.run( + kubectl_base + ["exec", "-n", OVN_NAMESPACE, pod, "--", ctl] + list(args), + capture_output=True, + text=True, + ) + + +def _ovn_list( + kubectl_base: list[str], pod: str, ctl: str, table: str, columns: str +) -> list[dict]: + result = _ovn_cmd( + kubectl_base, pod, ctl, f"--columns={columns}", "--format=json", "list", table + ) + if result.returncode != 0: + print( + f"ERROR: {ctl} list {table} failed:\n{result.stderr.strip()}", + file=sys.stderr, + ) + sys.exit(1) + return parse_ovn_json(result.stdout) + + +def get_live_chassis_names(kubectl_base: list[str]) -> set[str]: + """Chassis names currently registered in the Southbound DB.""" + rows = _ovn_list(kubectl_base, SB_POD, "ovn-sbctl", "Chassis", "name") + return {r["name"] for r in rows} + + +def get_all_ha_chassis(kubectl_base: list[str]) -> list[dict]: + """Every HA_Chassis row in the Northbound DB.""" + return _ovn_list( + kubectl_base, NB_POD, "ovn-nbctl", "HA_Chassis", "_uuid,chassis_name,priority" + ) + + +def get_all_ha_chassis_groups(kubectl_base: list[str]) -> list[dict]: + """Every HA_Chassis_Group row in the Northbound DB.""" + return _ovn_list( + kubectl_base, + NB_POD, + "ovn-nbctl", + "HA_Chassis_Group", + "_uuid,name,ha_chassis,external_ids", + ) + + +def get_all_router_ports(kubectl_base: list[str]) -> list[dict]: + """Every Logical_Router_Port row in the Northbound DB.""" + return _ovn_list( + kubectl_base, NB_POD, "ovn-nbctl", "Logical_Router_Port", "name,external_ids" + ) + + +# --- Phase 1: stale HA_Chassis cleanup ------------------------------------- + + +def find_stale_ha_chassis( + all_ha_chassis: list[dict], live_chassis: set[str] +) -> list[dict]: + """Return HA_Chassis rows whose chassis_name isn't a currently-live chassis.""" + stale = [r for r in all_ha_chassis if r["chassis_name"] not in live_chassis] + for r in stale: + log.debug( + "Stale HA_Chassis %s chassis_name=%s priority=%s", + r["_uuid"], + r["chassis_name"], + r["priority"], + ) + return stale + + +def map_groups_to_stale_members( + groups: list[dict], stale_uuids: set[str] +) -> list[dict]: + """Return one record per (group, stale member) pair, plus resulting size.""" + records = [] + for g in groups: + members = _as_list(g.get("ha_chassis")) + stale_members = [m for m in members if m in stale_uuids] + if not stale_members: + continue + for member_uuid in stale_members: + records.append( + { + "group_uuid": g["_uuid"], + "group_name": g["name"], + "member_uuid": member_uuid, + "remaining_after": len(members) - len(stale_members), + } + ) + return records + + +def print_cleanup_report(records: list[dict], stale_by_uuid: dict[str, dict]) -> None: + print("=== Phase 1: stale HA_Chassis cleanup ===\n") + if not records: + print("[DRY-RUN] No stale HA_Chassis rows found. Nothing to clean up.\n") + return + + print(f"[DRY-RUN] Found {len(records)} stale HA_Chassis reference(s):\n") + for r in records: + chassis_name = stale_by_uuid[r["member_uuid"]]["chassis_name"] + print(f"[DRY-RUN] Group : {r['group_name']} ({r['group_uuid']})") + print(f"[DRY-RUN] Member : {r['member_uuid']} (dead chassis {chassis_name})") + print( + f"[DRY-RUN] Action : ovn-nbctl remove HA_Chassis_Group " + f"{r['group_uuid']} ha_chassis {r['member_uuid']}" + ) + print() + + +def execute_cleanup(records: list[dict], kubectl_base: list[str]) -> None: + print("=== Phase 1: stale HA_Chassis cleanup ===\n") + if not records: + print("No stale HA_Chassis rows found. Nothing to do.\n") + return + + args: list[str] = [] + for r in records: + args += [ + "--", + "remove", + "HA_Chassis_Group", + r["group_uuid"], + "ha_chassis", + r["member_uuid"], + ] + + print(f"Removing {len(records)} stale HA_Chassis reference(s) in one transaction …") + result = _ovn_cmd(kubectl_base, NB_POD, "ovn-nbctl", *args) + if result.returncode != 0: + log.error("Cleanup transaction failed: %s", result.stderr.strip()) + sys.exit(1) + print("Done.\n") + + +# --- Phase 2: repopulate empty per-network unified HA_Chassis_Groups ------- + + +def plan_repopulation( + groups: list[dict], + all_ha_chassis_by_uuid: dict[str, str], + stale_uuids: set[str], + router_ports: list[dict], +) -> tuple[list[dict], list[tuple[str, str]]]: + """Work out which empty per-network HCGs can be safely repopulated. + + A group is a candidate if: it's a per-network unified HCG (has + neutron:network_id in external_ids), it has no members once stale ones + are excluded, and its associated router's own HCG resolves to exactly + one distinct live chassis. + """ + groups_by_name = {g["name"]: g for g in groups} + + lrps_by_network: dict[str, list[dict]] = {} + for lrp in router_ports: + ext = lrp.get("external_ids") or {} + net_name = ext.get("neutron:network_name") + if net_name: + lrps_by_network.setdefault(net_name, []).append(lrp) + + plan = [] + skipped = [] + for g in groups: + ext = g.get("external_ids") or {} + network_id = ext.get(OVN_NETWORK_ID_EXT_ID_KEY) + if not network_id: + continue # not a per-network unified HCG (e.g. a per-router one) + + members = _as_list(g.get("ha_chassis")) + remaining = [m for m in members if m not in stale_uuids] + if remaining: + continue # already has (or will keep) a valid member + + router_id = ext.get(OVN_ROUTER_ID_EXT_ID_KEY) + if not router_id: + skipped.append((g["name"], "no router_id in HCG external_ids")) + continue + + router_group = groups_by_name.get(f"neutron-{router_id}") + if not router_group: + skipped.append((g["name"], f"router HCG neutron-{router_id} not found")) + continue + + router_members = _as_list(router_group.get("ha_chassis")) + router_remaining = [m for m in router_members if m not in stale_uuids] + chassis_names = { + all_ha_chassis_by_uuid[m] + for m in router_remaining + if m in all_ha_chassis_by_uuid + } + if len(chassis_names) != 1: + skipped.append( + ( + g["name"], + f"router HCG resolves to {len(chassis_names)} distinct " + "live chassis (expected exactly 1)", + ) + ) + continue + target_chassis = next(iter(chassis_names)) + + switch_name = f"neutron-{network_id}" + candidates = [ + lrp + for lrp in lrps_by_network.get(switch_name, []) + if (lrp.get("external_ids") or {}).get("neutron:router_name") + == f"neutron-{router_id}" + ] + if not candidates: + skipped.append( + ( + g["name"], + f"no internal Logical_Router_Port found for network " + f"{network_id} on router {router_id}", + ) + ) + continue + if len(candidates) > 1: + log.warning( + "Multiple internal LRPs found for network %s on router %s; " "using %s", + network_id, + router_id, + candidates[0]["name"], + ) + + plan.append( + { + "group_uuid": g["_uuid"], + "group_name": g["name"], + "network_id": network_id, + "router_id": router_id, + "target_chassis": target_chassis, + "lrp_name": candidates[0]["name"], + } + ) + return plan, skipped + + +def print_repopulation_report(plan: list[dict], skipped: list[tuple[str, str]]) -> None: + print("=== Phase 2: repopulate empty per-network HCGs ===\n") + if not plan and not skipped: + print("[DRY-RUN] No empty per-network HCGs found.\n") + return + + for p in plan: + print(f"[DRY-RUN] Network HCG : {p['group_name']}") + print(f"[DRY-RUN] Router : {p['router_id']}") + print(f"[DRY-RUN] Chassis : {p['target_chassis']}") + print(f"[DRY-RUN] Anchor LRP : {p['lrp_name']}") + print( + f"[DRY-RUN] Action : create HA_Chassis " + f"chassis_name={p['target_chassis']} " + f"priority={HA_CHASSIS_GROUP_HIGHEST_PRIORITY}; add to " + f"{p['group_uuid']}; set {p['lrp_name']} " + f"ha_chassis_group={p['group_uuid']}" + ) + print() + + for name, reason in skipped: + print(f"[DRY-RUN] SKIPPED {name}: {reason}\n") + + +def execute_repopulation(plan: list[dict], kubectl_base: list[str]) -> None: + print("=== Phase 2: repopulate empty per-network HCGs ===\n") + if not plan: + print("No empty per-network HCGs to repopulate.\n") + return + + args: list[str] = [] + for i, p in enumerate(plan): + hc_id = f"@hc{i}" + args += [ + "--", + f"--id={hc_id}", + "create", + "HA_Chassis", + f"chassis_name={p['target_chassis']}", + f"priority={HA_CHASSIS_GROUP_HIGHEST_PRIORITY}", + "--", + "add", + "HA_Chassis_Group", + p["group_uuid"], + "ha_chassis", + hc_id, + "--", + "set", + "Logical_Router_Port", + p["lrp_name"], + f"ha_chassis_group={p['group_uuid']}", + ] + + print(f"Repopulating {len(plan)} network HCG(s) in one transaction …") + result = _ovn_cmd(kubectl_base, NB_POD, "ovn-nbctl", *args) + if result.returncode != 0: + log.error("Repopulation transaction failed: %s", result.stderr.strip()) + sys.exit(1) + print("Done.") + for p in plan: + print(f" - {p['group_name']} -> {p['target_chassis']}") + + +def build_kubectl_base(kube_context: str | None) -> list[str]: + cmd = ["kubectl"] + if kube_context: + cmd += ["--context", kube_context] + return cmd + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--context", + metavar="KUBE_CONTEXT", + dest="kube_context", + default=None, + help="Kubernetes context for kubectl (default: current context)", + ) + parser.add_argument( + "--execute", + action="store_true", + default=False, + help="Apply changes. Without this flag only a dry-run report is printed.", + ) + parser.add_argument( + "--skip-repopulate", + action="store_true", + default=False, + help="Only clean up stale HA_Chassis rows; don't repopulate empty " + "per-network HCGs.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + default=False, + help="Enable debug logging.", + ) + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(levelname)s %(message)s", + ) + + kubectl_base = build_kubectl_base(args.kube_context) + + log.info("Fetching live chassis from Southbound DB …") + live_chassis = get_live_chassis_names(kubectl_base) + log.info("Live chassis: %s", ", ".join(sorted(live_chassis)) or "(none)") + + log.info("Fetching HA_Chassis rows from Northbound DB …") + all_ha_chassis = get_all_ha_chassis(kubectl_base) + all_ha_chassis_by_uuid = {r["_uuid"]: r["chassis_name"] for r in all_ha_chassis} + stale_rows = find_stale_ha_chassis(all_ha_chassis, live_chassis) + stale_by_uuid = {r["_uuid"]: r for r in stale_rows} + stale_uuids = set(stale_by_uuid) + log.info("Found %d stale HA_Chassis row(s)", len(stale_rows)) + + log.info("Fetching HA_Chassis_Group rows …") + groups = get_all_ha_chassis_groups(kubectl_base) + cleanup_records = map_groups_to_stale_members(groups, stale_uuids) + + if not args.execute: + print_cleanup_report(cleanup_records, stale_by_uuid) + else: + execute_cleanup(cleanup_records, kubectl_base) + + if args.skip_repopulate: + return + + log.info("Fetching Logical_Router_Port rows …") + router_ports = get_all_router_ports(kubectl_base) + plan, skipped = plan_repopulation( + groups, all_ha_chassis_by_uuid, stale_uuids, router_ports + ) + + if not args.execute: + print_repopulation_report(plan, skipped) + if cleanup_records or plan: + print("Run with --execute to apply the changes above.") + else: + execute_repopulation(plan, kubectl_base) + + +if __name__ == "__main__": + main() From bd7130d1348b7472c45030d99a1a11a3884d53ff Mon Sep 17 00:00:00 2001 From: Marek Skrobacki Date: Wed, 15 Jul 2026 10:19:43 +0100 Subject: [PATCH 5/5] fix(neutron-understack): ignore stale chassis when linking vxlan network HCG link_vxlan_network_ha_chassis_group inferred the gateway chassis from every HA_Chassis row in the NB database, requiring them all to share one chassis_name. A single row left behind by a decommissioned/replaced chassis made that check ambiguous and silently blocked HA_Chassis_Group population for every vxlan network in the fleet. Now filters to chassis still present in the Southbound DB before checking for uniqueness. --- .../neutron_understack/routers.py | 25 ++++++++--- .../neutron_understack/tests/test_routers.py | 45 ++++++++++++++++++- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/python/neutron-understack/neutron_understack/routers.py b/python/neutron-understack/neutron_understack/routers.py index 85960b997..3b7dab422 100644 --- a/python/neutron-understack/neutron_understack/routers.py +++ b/python/neutron-understack/neutron_understack/routers.py @@ -220,8 +220,11 @@ def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) -> We do what link_network_ha_chassis_group would have done: populate the unified network HCG with sync_ha_chassis_group_network_unified, then anchor the internal router-interface LRP to that same HCG. The gateway chassis is sourced from the - global HA_Chassis table (all records must share one chassis_name) so the fix - fires even before the external gateway port is attached. + global HA_Chassis table (all *live* records must share one chassis_name) so the + fix fires even before the external gateway port is attached. Rows pointing at a + chassis no longer present in the Southbound DB (e.g. left behind by a + decommissioned/replaced host) are excluded before checking for uniqueness, so a + single stale row elsewhere in the fleet doesn't block every vxlan network. For VLAN/FLAT networks neutron's handler already populates the network HCG correctly; we detect that and return early. @@ -239,6 +242,7 @@ def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) -> if not client: return nb_idl = client._nb_idl + sb_idl = client._sb_idl # Skip if the per-network HCG is already populated — neutron handled it # (VLAN/FLAT gateways). For vxlan the HCG is empty due to the neutron bug. @@ -248,15 +252,22 @@ def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) -> if network_hcg and network_hcg.ha_chassis: return - # Derive the gateway chassis from every HA_Chassis row in the NB database. - # If exactly one distinct chassis_name exists, that is our gateway chassis. - # This avoids requiring the per-router HCG to exist first. + # Derive the gateway chassis from every *live* HA_Chassis row in the NB + # database. If exactly one distinct chassis_name exists, that is our + # gateway chassis. This avoids requiring the per-router HCG to exist yet. + # Rows referencing a chassis no longer registered in the Southbound DB + # (e.g. a decommissioned/replaced host) are excluded so they don't block + # this inference for every network in the fleet. all_ha_chassis = nb_idl.db_list_rows("HA_Chassis").execute(check_error=True) - chassis_names = {row.chassis_name for row in all_ha_chassis} + chassis_names = { + row.chassis_name + for row in all_ha_chassis + if sb_idl.lookup("Chassis", row.chassis_name, default=None) is not None + } if len(chassis_names) != 1: LOG.debug( "Cannot determine unique gateway chassis for network %(net)s " - "(router %(router)s): found %(n)d distinct chassis name(s)", + "(router %(router)s): found %(n)d distinct live chassis name(s)", {"net": network_id, "router": router_id, "n": len(chassis_names)}, ) return diff --git a/python/neutron-understack/neutron_understack/tests/test_routers.py b/python/neutron-understack/neutron_understack/tests/test_routers.py index 8f488478a..1379150e0 100644 --- a/python/neutron-understack/neutron_understack/tests/test_routers.py +++ b/python/neutron-understack/neutron_understack/tests/test_routers.py @@ -407,7 +407,7 @@ def _payload(mocker, router_id="router-1", port_id="port-1", network_id="net-1") ) @staticmethod - def _client(mocker, ha_chassis_rows, lrp, network_hcg=None): + def _client(mocker, ha_chassis_rows, lrp, network_hcg=None, live_chassis=None): nb_idl = mocker.MagicMock() nb_idl.db_list_rows.return_value.execute.return_value = ha_chassis_rows @@ -419,7 +419,22 @@ def lookup(table, _name, default=None): return default nb_idl.lookup.side_effect = lookup - client = mocker.Mock(_nb_idl=nb_idl) + + # By default every provided HA_Chassis row is treated as live, so + # existing callers don't need to know about the SB liveness check. + if live_chassis is None: + live_chassis = {row.chassis_name for row in ha_chassis_rows} + + sb_idl = mocker.MagicMock() + + def sb_lookup(table, name, default=None): + if table == "Chassis" and name in live_chassis: + return mocker.Mock(name=name) + return default + + sb_idl.lookup.side_effect = sb_lookup + + client = mocker.Mock(_nb_idl=nb_idl, _sb_idl=sb_idl) return client, nb_idl def _patch_sync(self, mocker, hcg="net-hcg-uuid"): @@ -474,6 +489,32 @@ def test_multiple_chassis_names(self, mocker): sync.assert_not_called() nb_idl.db_set.assert_not_called() + def test_stale_chassis_excluded_from_uniqueness_check(self, mocker): + # chassis-2 no longer exists in the Southbound DB (e.g. decommissioned + # host) but a stale HA_Chassis row for it still lingers in NB. It must + # not block inference of the one live chassis. + hc_live = mocker.Mock(chassis_name="chassis-1") + hc_dead = mocker.Mock(chassis_name="chassis-2") + lrp = mocker.Mock(ha_chassis_group=[]) + client, nb_idl = self._client( + mocker, + ha_chassis_rows=[hc_live, hc_dead], + lrp=lrp, + live_chassis={"chassis-1"}, + ) + sync = self._patch_sync(mocker) + mocker.patch("neutron_understack.routers.ovn_client", return_value=client) + + link_vxlan_network_ha_chassis_group(None, None, None, self._payload(mocker)) + + sync.assert_called_once() + assert sync.call_args.args[5] == {"chassis-1": 32767} # chassis_prio + nb_idl.db_set.assert_called_once_with( + "Logical_Router_Port", + "lrp-port-1", + ("ha_chassis_group", "net-hcg-uuid"), + ) + def test_network_hcg_already_populated(self, mocker): # VLAN/FLAT: neutron already populated the per-network HCG — we skip. existing_hcg = mocker.Mock(ha_chassis=[mocker.Mock(chassis_name="chassis-1")])