Skip to content

Commit 71e7448

Browse files
committed
fix: populate per-network HCG for vxlan gateway routers after 2026.1 upgrade
After upgrading to neutron 28 (2026.1), attaching a subnet to a router whose external gateway is on a vxlan-type network leaves the per-network unified HA_Chassis_Group (neutron-<network_id>) empty. External (baremetal) ports with OVN LSP type=external on that network reference this HCG; with it empty, no chassis owns them and ARP/routing breaks for all baremetal nodes on the network. Root cause: for a vxlan-type external gateway neutron pins the Logical_Router to one chassis via options:chassis and creates a per-router HCG (neutron-<router_id>) carrying that chassis, but never sets ha_chassis_group on the gateway LRP. neutron's link_network_ha_chassis_group (called on internal LRP creation) bails at its `if not gw_lrps[0].ha_chassis_group: return` guard, so it never copies the chassis into neutron-<network_id>. Fix: subscribe to ROUTER_INTERFACE/AFTER_CREATE at PRIORITY_DEFAULT+1000 (after OVN's handler has created the LRP) with a new handler link_vxlan_network_ha_chassis_group. The handler: 1. Detects a vxlan-type gateway by checking that the per-router HCG (neutron-<router_id>) exists and has non-empty ha_chassis; VLAN/FLAT gateways have no router HCG and continue to be handled by neutron unchanged. 2. Calls ovn_utils.sync_ha_chassis_group_network_unified to populate the per-network HCG (neutron-<network_id>) from the router HCG chassis — this is what fixes external/baremetal ports that already reference it. 3. Anchors the internal router-interface LRP (lrp-<port_id>) to the same unified network HCG so the router port is also correctly owned. Both operations run in a single OVN NB transaction. The create_port_postcommit hook is unaffected — the internal LRP does not exist at that point (it is created later, on the same AFTER_CREATE event OVN processes first).
1 parent 2704035 commit 71e7448

3 files changed

Lines changed: 200 additions & 0 deletions

File tree

python/neutron-understack/neutron_understack/neutron_understack_mech.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from neutron_lib import constants as p_const
77
from neutron_lib.api.definitions import portbindings
88
from neutron_lib.callbacks import events
9+
from neutron_lib.callbacks import priority_group
910
from neutron_lib.callbacks import registry
1011
from neutron_lib.callbacks import resources
1112
from neutron_lib.plugins.ml2 import api
@@ -99,6 +100,15 @@ def subscribe(self):
99100
events.PRECOMMIT_DELETE,
100101
cancellable=True,
101102
)
103+
# Runs after neutron's OVN handler (PRIORITY_DEFAULT) has created the
104+
# internal LRP, so the unified network HCG and the LRP both exist.
105+
# Smaller priority is called earlier, so use a larger value to run later.
106+
registry.subscribe(
107+
routers.link_vxlan_network_ha_chassis_group,
108+
resources.ROUTER_INTERFACE,
109+
events.AFTER_CREATE,
110+
priority=priority_group.PRIORITY_DEFAULT + 1000,
111+
)
102112

103113
def create_network_precommit(self, context):
104114
pass

python/neutron-understack/neutron_understack/routers.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def create_port_postcommit(context: PortContext) -> None:
4444
In situation 2, we don't have to do anything.
4545
"""
4646
network_id = context.current["network_id"]
47+
4748
if not is_only_router_port_on_network(
4849
network_id=network_id, transaction_context=context.plugin_context
4950
):
@@ -185,6 +186,103 @@ def create_uplink_port(segment: NetworkSegment, network_id: str, txn=None) -> No
185186
ovn_client()._transaction([cmd], txn=txn)
186187

187188

189+
def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) -> None:
190+
"""Populate the unified network HCG (and anchor the internal LRP) for vxlan.
191+
192+
Workaround for a neutron bug exposed in 2026.1. For a router with a vxlan-type
193+
external gateway, neutron pins the Logical_Router to a single
194+
chassis via ``options:chassis`` and creates a per-router HA_Chassis_Group
195+
(neutron-<router_id>) carrying that chassis, but it never sets
196+
ha_chassis_group on the gateway LRP. neutron's link_network_ha_chassis_group
197+
(fired when the internal LRP is created) bails out at its
198+
``if not gw_lrps[0].ha_chassis_group`` check, so it never copies the chassis
199+
into the per-network unified HCG (neutron-<network_id>). External/baremetal
200+
ports on that network reference the empty network HCG, so no chassis owns
201+
them and routing/ARP breaks.
202+
203+
We do what link_network_ha_chassis_group would have done, but source the
204+
chassis from the router HCG instead of the (empty) gateway LRP: populate the
205+
unified network HCG with sync_ha_chassis_group_network_unified, then anchor
206+
the internal router-interface LRP to that same HCG. External ports already
207+
reference the unified network HCG, so populating it fixes them.
208+
209+
Subscribed to ROUTER_INTERFACE/AFTER_CREATE at a priority that runs after
210+
neutron's OVN handler, so the LRP (lrp-<port_id>) already exists by now.
211+
"""
212+
router_id = payload.states[0].id
213+
port = payload.metadata["port"]
214+
port_id = port["id"]
215+
network_id = port["network_id"]
216+
217+
try:
218+
client = ovn_client()
219+
if not client:
220+
return
221+
nb_idl = client._nb_idl
222+
223+
# Vxlan-gateway signal: the per-router HCG exists with chassis.
224+
# VLAN/FLAT gateways have no router HCG and are handled by neutron.
225+
router_hcg = nb_idl.lookup(
226+
"HA_Chassis_Group", ovn_utils.ovn_name(router_id), default=None
227+
)
228+
if not router_hcg or not router_hcg.ha_chassis:
229+
LOG.debug(
230+
"No HA_Chassis_Group with chassis found for router %(router)s",
231+
{"router": router_id},
232+
)
233+
return
234+
235+
chassis_prio = {hc.chassis_name: hc.priority for hc in router_hcg.ha_chassis}
236+
lrp_name = ovn_utils.ovn_lrouter_port_name(port_id)
237+
238+
LOG.info(
239+
"Linking unified HCG for network %(net)s (router %(router)s) with "
240+
"chassis %(chassis)s and anchoring internal LRP %(lrp)s",
241+
{
242+
"net": network_id,
243+
"router": router_id,
244+
"chassis": list(chassis_prio),
245+
"lrp": lrp_name,
246+
},
247+
)
248+
249+
admin_context = n_context.get_admin_context()
250+
with nb_idl.transaction(check_error=True) as txn:
251+
# Populate the per-network unified HCG with the gateway chassis.
252+
# This is what fixes the external (baremetal) ports, which already
253+
# reference neutron-<network_id>.
254+
hcg, _ = ovn_utils.sync_ha_chassis_group_network_unified(
255+
admin_context,
256+
nb_idl,
257+
client._sb_idl,
258+
network_id,
259+
router_id,
260+
chassis_prio,
261+
txn,
262+
)
263+
264+
# Anchor the internal router-interface LRP to the same unified HCG.
265+
if nb_idl.lookup("Logical_Router_Port", lrp_name, default=None):
266+
txn.add(
267+
nb_idl.db_set(
268+
"Logical_Router_Port",
269+
lrp_name,
270+
("ha_chassis_group", hcg),
271+
)
272+
)
273+
except Exception as err:
274+
LOG.error(
275+
"Failed linking HA_Chassis_Group for network %(net)s port "
276+
"%(port)s (router %(router)s): %(error)s",
277+
{
278+
"net": network_id,
279+
"port": port_id,
280+
"router": router_id,
281+
"error": err,
282+
},
283+
)
284+
285+
188286
def delete_uplink_port(segment: NetworkSegment, network_id: str) -> None:
189287
"""Remove a localnet uplink port from a network node."""
190288
port_to_del = f"uplink-{segment['id']}"

python/neutron-understack/neutron_understack/tests/test_routers.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from neutron_understack.routers import fetch_or_create_router_segment
77
from neutron_understack.routers import handle_router_interface_removal
88
from neutron_understack.routers import handle_subport_removal
9+
from neutron_understack.routers import link_vxlan_network_ha_chassis_group
910

1011

1112
class TestFetchOrCreateRouterSegment:
@@ -202,3 +203,94 @@ def test_no_router_on_network(self, mocker, port_context):
202203
create_uplink_port.assert_called_once_with(
203204
fake_segment, port_context.current["network_id"]
204205
)
206+
207+
208+
class TestLinkVxlanNetworkHaChassisGroup:
209+
@staticmethod
210+
def _payload(mocker, router_id="router-1", port_id="port-1", network_id="net-1"):
211+
router = mocker.Mock()
212+
router.id = router_id
213+
return mocker.Mock(
214+
states=[router],
215+
metadata={"port": {"id": port_id, "network_id": network_id}},
216+
)
217+
218+
@staticmethod
219+
def _client(mocker, router_hcg, lrp):
220+
nb_idl = mocker.MagicMock()
221+
222+
def lookup(table, _name, default=None):
223+
if table == "HA_Chassis_Group":
224+
return router_hcg
225+
if table == "Logical_Router_Port":
226+
return lrp
227+
return default
228+
229+
nb_idl.lookup.side_effect = lookup
230+
client = mocker.Mock(_nb_idl=nb_idl)
231+
return client, nb_idl
232+
233+
def _patch_sync(self, mocker, hcg="net-hcg-uuid"):
234+
mocker.patch(
235+
"neutron_understack.routers.n_context.get_admin_context",
236+
return_value="ctx",
237+
)
238+
return mocker.patch(
239+
"neutron_understack.routers.ovn_utils.sync_ha_chassis_group_network_unified",
240+
return_value=(hcg, "chassis-1"),
241+
)
242+
243+
def test_populates_network_hcg_and_anchors_lrp(self, mocker):
244+
hc = mocker.Mock(chassis_name="chassis-1", priority=10)
245+
router_hcg = mocker.Mock(ha_chassis=[hc], name="neutron-router-1")
246+
lrp = mocker.Mock(ha_chassis_group=[])
247+
client, nb_idl = self._client(mocker, router_hcg, lrp)
248+
sync = self._patch_sync(mocker)
249+
mocker.patch("neutron_understack.routers.ovn_client", return_value=client)
250+
251+
link_vxlan_network_ha_chassis_group(None, None, None, self._payload(mocker))
252+
253+
# Network HCG is populated from the router's chassis.
254+
assert sync.call_args.args[3] == "net-1" # network_id
255+
assert sync.call_args.args[4] == "router-1" # router_id
256+
assert sync.call_args.args[5] == {"chassis-1": 10} # chassis_prio
257+
# Internal LRP is anchored to the unified network HCG.
258+
nb_idl.db_set.assert_called_once_with(
259+
"Logical_Router_Port",
260+
"lrp-port-1",
261+
("ha_chassis_group", "net-hcg-uuid"),
262+
)
263+
264+
def test_no_router_hcg(self, mocker):
265+
client, nb_idl = self._client(mocker, router_hcg=None, lrp=None)
266+
sync = self._patch_sync(mocker)
267+
mocker.patch("neutron_understack.routers.ovn_client", return_value=client)
268+
269+
link_vxlan_network_ha_chassis_group(None, None, None, self._payload(mocker))
270+
271+
sync.assert_not_called()
272+
nb_idl.db_set.assert_not_called()
273+
274+
def test_router_hcg_without_chassis(self, mocker):
275+
router_hcg = mocker.Mock(ha_chassis=[])
276+
client, nb_idl = self._client(mocker, router_hcg, lrp=None)
277+
sync = self._patch_sync(mocker)
278+
mocker.patch("neutron_understack.routers.ovn_client", return_value=client)
279+
280+
link_vxlan_network_ha_chassis_group(None, None, None, self._payload(mocker))
281+
282+
sync.assert_not_called()
283+
nb_idl.db_set.assert_not_called()
284+
285+
def test_lrp_missing_still_populates_network_hcg(self, mocker):
286+
hc = mocker.Mock(chassis_name="chassis-1", priority=10)
287+
router_hcg = mocker.Mock(ha_chassis=[hc], name="neutron-router-1")
288+
client, nb_idl = self._client(mocker, router_hcg, lrp=None)
289+
sync = self._patch_sync(mocker)
290+
mocker.patch("neutron_understack.routers.ovn_client", return_value=client)
291+
292+
link_vxlan_network_ha_chassis_group(None, None, None, self._payload(mocker))
293+
294+
# The network HCG is still populated even if the LRP is not found yet.
295+
sync.assert_called_once()
296+
nb_idl.db_set.assert_not_called()

0 commit comments

Comments
 (0)