Skip to content
Merged
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
2 changes: 1 addition & 1 deletion containers/neutron/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ FROM quay.io/airshipit/neutron:${OPENSTACK_VERSION}-ubuntu_noble AS build
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# renovate: name=openstack/neutron repo=https://github.com/rackerlabs/neutron.git branch=understack/2026.1
ARG NEUTRON_GIT_REF=51f968afde5eb80c04e0bd01391e9e3344812c9a
ARG NEUTRON_GIT_REF=7cad1b179a9645f9cd0256a5d90a97ebfb95d8d0
ADD --keep-git-dir=true https://github.com/rackerlabs/neutron.git#${NEUTRON_GIT_REF} /src/neutron
RUN git -C /src/neutron fetch --unshallow --tags

Expand Down
64 changes: 41 additions & 23 deletions python/neutron-understack/neutron_understack/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,27 +204,33 @@ def create_uplink_port(segment: NetworkSegment, network_id: str, txn=None) -> No


def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) -> None:
"""Populate the unified network HCG (and anchor the internal LRP) for vxlan.

Workaround for a neutron bug exposed in 2026.1. For a router with a vxlan-type
external gateway, neutron pins the Logical_Router to a single
chassis via ``options:chassis`` and creates a per-router HA_Chassis_Group
(neutron-<router_id>) carrying that chassis, but it never sets
ha_chassis_group on the gateway LRP. neutron's link_network_ha_chassis_group
(fired when the internal LRP is created) bails out at its
``if not gw_lrps[0].ha_chassis_group`` check, so it never copies the chassis
into the per-network unified HCG (neutron-<network_id>). External/baremetal
ports on that network reference the empty network HCG, so no chassis owns
them and routing/ARP breaks.

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 *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.
"""Populate the unified network HCG for vxlan external gateways.

Workaround for a neutron bug exposed in 2026.1. For a vxlan-type external
gateway, neutron pins the Logical_Router to a chassis via ``options:chassis``
but never populates ``gateway_chassis`` on the gateway LRP (that's only done
for VLAN/FLAT via the OVN L3 scheduler). neutron's own
``link_network_ha_chassis_group`` builds its chassis list from that empty
``gateway_chassis``, so it syncs the per-network unified HCG
(neutron-<network_id>) with zero members, leaving external/baremetal ports on
that network with no owning chassis and broken routing/ARP.

We populate that same unified HCG ourselves via
sync_ha_chassis_group_network_unified. The chassis comes from the global
HA_Chassis table (all *live* rows must share one chassis_name), so this works
even before the gateway port is attached. Rows for chassis no longer in the
Southbound DB (decommissioned/replaced hosts) are excluded so one stale row
doesn't block every vxlan network.

We do NOT set ha_chassis_group on the internal router-interface LRP.
Upstream never does this on a Logical_Router_Port (only on external
Logical_Switch_Ports), and doing so on a router that already has
options:chassis set -- true for every vxlan gateway -- makes ovn-northd log
"Bad configuration: distributed gateway port configured on ... L3 gateway
router" and ignore it anyway, since the router is already centralized on one
chassis. We guard on options:chassis rather than dropping the anchor
outright in case this fixup is ever reused for a genuinely distributed
router.

For VLAN/FLAT networks neutron's handler already populates the network HCG
correctly; we detect that and return early.
Expand Down Expand Up @@ -301,8 +307,20 @@ def link_vxlan_network_ha_chassis_group(_resource, _event, _trigger, payload) ->
txn,
)

# Anchor the internal router-interface LRP to the same unified HCG.
if nb_idl.lookup("Logical_Router_Port", lrp_name, default=None):
# Anchor the internal LRP to the HCG, unless the router is
# centralized (options:chassis set) -- see docstring.
lr = nb_idl.lookup(
"Logical_Router", ovn_utils.ovn_name(router_id), default=None
)
router_is_centralized = bool(lr and lr.options.get("chassis"))
if router_is_centralized:
LOG.debug(
"Not anchoring LRP %(lrp)s to HCG %(hcg)s: router "
"%(router)s is centralized (options:chassis set), so "
"northd would reject a distributed gateway port on it",
{"lrp": lrp_name, "hcg": hcg, "router": router_id},
)
elif nb_idl.lookup("Logical_Router_Port", lrp_name, default=None):
txn.add(
nb_idl.db_set(
"Logical_Router_Port",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,9 @@ 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, live_chassis=None):
def _client(
mocker, ha_chassis_rows, lrp, network_hcg=None, live_chassis=None, router=None
):
nb_idl = mocker.MagicMock()
nb_idl.db_list_rows.return_value.execute.return_value = ha_chassis_rows

Expand All @@ -416,6 +418,8 @@ def lookup(table, _name, default=None):
return network_hcg # None by default (unpopulated vxlan HCG)
if table == "Logical_Router_Port":
return lrp
if table == "Logical_Router":
return router # None by default (not centralized)
return default

nb_idl.lookup.side_effect = lookup
Expand Down Expand Up @@ -541,3 +545,47 @@ def test_lrp_missing_still_populates_network_hcg(self, mocker):
# The network HCG is still populated even if the LRP is not found yet.
sync.assert_called_once()
nb_idl.db_set.assert_not_called()

def test_skips_lrp_anchor_when_router_is_centralized(self, mocker):
# Every vxlan gateway router has options:chassis set (that's what makes
# it vxlan rather than VLAN/FLAT). Anchoring one of its ports as a
# distributed gateway port makes ovn-northd log "Bad configuration:
# distributed gateway port configured on ... L3 gateway router" and
# ignore the setting anyway, so we must not attempt it.
hc = mocker.Mock(chassis_name="chassis-1")
lrp = mocker.Mock(ha_chassis_group=[])
router = mocker.Mock(options={"chassis": "some-chassis-uuid"})
client, nb_idl = self._client(
mocker, ha_chassis_rows=[hc], lrp=lrp, router=router
)
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))

# The network HCG is still populated (that's what actually fixes the
# external/baremetal ports)...
sync.assert_called_once()
# ...but the internal LRP is left alone.
nb_idl.db_set.assert_not_called()

def test_anchors_lrp_when_router_is_not_centralized(self, mocker):
# Defensive case: if this fixup is ever reused for a genuinely
# distributed router (no options:chassis), the old anchoring behavior
# still applies.
hc = mocker.Mock(chassis_name="chassis-1")
lrp = mocker.Mock(ha_chassis_group=[])
router = mocker.Mock(options={})
client, nb_idl = self._client(
mocker, ha_chassis_rows=[hc], lrp=lrp, router=router
)
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))

nb_idl.db_set.assert_called_once_with(
"Logical_Router_Port",
"lrp-port-1",
("ha_chassis_group", "net-hcg-uuid"),
)
Loading
Loading