Skip to content

Commit 8a59fdf

Browse files
committed
feat(neutron-understack): own trunk status and subport ownership
ml2/ovn no longer manages trunks whose parent LSP is type=external, which is every baremetal parent, to fix https://bugs.launchpad.net/neutron/+bug/2167105 It was the only thing moving our trunks out of DOWN and claiming their subports, so we need to take over doing that. This change sets ACTIVE once the parent is bound, otherwise DOWN. Subports follow their trunk. To make this work we needed to rebase our neutron onto the current stable/2026.1 which pulled in the patches for the above bug.
1 parent de594f6 commit 8a59fdf

12 files changed

Lines changed: 707 additions & 40 deletions

File tree

‎containers/neutron/Dockerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ FROM quay.io/airshipit/neutron:${OPENSTACK_VERSION}-ubuntu_noble AS build
66
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
77

88
# renovate: name=openstack/neutron repo=https://github.com/rackerlabs/neutron.git branch=understack/2026.1
9-
ARG NEUTRON_GIT_REF=b0678e56e9c5a7ec2f335c4271465fc1c3202e2a
9+
ARG NEUTRON_GIT_REF=ef5e82f0d278a1e08d7f840f73bd6cefe460f147
1010
ADD --keep-git-dir=true https://github.com/rackerlabs/neutron.git#${NEUTRON_GIT_REF} /src/neutron
1111
RUN git -C /src/neutron fetch --unshallow --tags
1212

1313
# renovate: name=openstack/neutron-lib repo=https://github.com/rackerlabs/neutron-lib.git branch=understack/2026.1
14-
ARG NEUTRON_LIB_GIT_REF=6d0367183cc92656ba449acefd830cb4e11f2f5b
14+
ARG NEUTRON_LIB_GIT_REF=275d4c95657ed4c3028401b48c15204d8a965420
1515
ADD --keep-git-dir=true https://github.com/rackerlabs/neutron-lib.git#${NEUTRON_LIB_GIT_REF} /src/neutron-lib
1616
RUN git -C /src/neutron-lib fetch --unshallow --tags
1717

‎python/neutron-understack/neutron_understack/tests/scenarios/SCENARIOS.md‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,21 @@ These scenarios load the real neutron trunk service plugin
233233
adds/removes must reconcile the parent's switch (VLAN group).
234234
`utils.fetch_network_node_trunk_id` (live OVN + Ironic discovery) is stubbed.
235235

236+
The Understack trunk driver claims unowned subports as `trunk:subport` and
237+
updates ports it already owns. It never replaces a semantic owner such as
238+
`network:router_interface` or `network:router_gateway`. It first records the
239+
trunk and the subports it owns as DOWN. The trunk plugin also forces a trunk
240+
back to DOWN on each subport add/remove. The Undersync mechanism driver changes
241+
them to ACTIVE immediately after `undersync.sync()` returns. That means ACTIVE
242+
records that reconciliation was requested successfully; there is not yet a
243+
feedback loop confirming that the switch applied the change. Parent unbind
244+
changes the trunk and subports back to DOWN after requesting reconciliation.
245+
For an existing `trunk:subport`, removing the legacy OVN/OVS `parent_name` and
246+
`tag` binding-profile fields is also an ML2 trigger: Undersync resolves the
247+
subport's trunk, reconciles the bound parent's physical network, and then marks
248+
the trunk and its eligible subports ACTIVE. This is the transition used by
249+
`kubectl us net trunk repair` when handing existing subports to Understack.
250+
236251
### TRUNK-SUB-ADD — subport attach syncs the parent's physnet
237252
- given: a bound baremetal parent port on `physnet1` with a trunk, and a subport
238253
port on another network
@@ -248,6 +263,8 @@ adds/removes must reconcile the parent's switch (VLAN group).
248263
that row is the record that the subport's network is carried on this VLAN
249264
of the parent's switchport
250265
- `undersync.sync(physnet1)` reconciles the parent port's switch
266+
- the subport is claimed (`device_owner=trunk:subport`, status ACTIVE) and the
267+
trunk reports ACTIVE
251268

252269
### TRUNK-SUB-DEL — subport removal syncs the parent's physnet
253270
- given: the TRUNK-SUB-ADD setup with the subport attached
@@ -257,7 +274,8 @@ adds/removes must reconcile the parent's switch (VLAN group).
257274
**because that row was the last reference to it**, and
258275
`undersync.sync(physnet1)` reconciles the parent port's switch. Had another
259276
port or subport still been bound to that segment, only the row would go and
260-
the segment would stay.
277+
the segment would stay. The port is released (`device_owner` cleared, status
278+
DOWN) and the trunk reports ACTIVE again.
261279

262280
### TRUNK-PARENT-NOIP — subport add syncs when the parent has no IP
263281
- given: a bound baremetal parent on a subnetted network but with no fixed IP,
@@ -291,14 +309,23 @@ adds/removes must reconcile the parent's switch (VLAN group).
291309
- then: trunk membership is recorded, but no subport binding level row or
292310
dynamic VLAN segment is created and no `undersync.sync` occurs — with no
293311
parent binding there is no physnet to carry the subport on and nothing to
294-
reconcile
312+
reconcile. The parent is a plain port, not a baremetal one, so the trunk is
313+
not ours to manage either: it stays DOWN and the subport is left unclaimed.
295314

296315
### TRUNK-SEGID-RANGE-01 — subport seg_id outside the allowed range is rejected
297316
- given: a bound baremetal parent with a trunk
298317
- when: a subport is added with a segmentation_id outside `[1, 3799]`
299318
- then: `SubportSegmentationIDError` (raised in the SUBPORTS PRECOMMIT_CREATE
300319
callback, surfaced as `CallbackFailure`), with no binding level or dynamic
301-
segment allocated
320+
segment allocated and the port left unclaimed
321+
322+
### TRUNK-STATUS-PARENT-BIND-01 — a trunk activates when its parent is attached
323+
- given: an *unbound* baremetal parent with a trunk and one subport — the usual
324+
order of events, since a trunk is built before the node is deployed
325+
- when: the parent port is vif-attached to `physnet1`
326+
- then: while unbound, trunk and subport are DOWN with nothing allocated; the
327+
vif-attach then binds the subport (dynamic segment + level-0 row), syncs
328+
`physnet1` and takes both to ACTIVE
302329

303330
### TRUNK-ORDER-01 — subport removal deallocates before it notifies Undersync
304331
- given: a bound baremetal parent with a trunk and an attached subport
@@ -473,8 +500,13 @@ Port create / bind / delete (no new test doubles):
473500
(early return, no sync).
474501

475502
Trunk (no new test doubles):
476-
- TRUNK-CREATE-WITH-SUBPORTS-01 — trunk created with subports present
477-
(`trunk_created` path); confirm whether it syncs (it may not — possible gap).
503+
- TRUNK-CREATE-WITH-SUBPORTS-01 — trunk created with subports inline. The driver
504+
handles it (TRUNK PRECOMMIT_CREATE allocates, AFTER_CREATE pushes and sets the
505+
status; unit-tested in `test_trunk.py`), but it is not observable here: the
506+
precommit allocation writes through its own session (`utils.get_admin_session`),
507+
and closing that session rolls the trunk's own insert back on the harness's
508+
shared sqlite connection. Needs the precommit writes to join the caller's
509+
transaction — which would also stop them leaking if the create is rolled back.
478510
- TRUNK-PARENT-UNBIND-01 — unbinding a trunked baremetal parent runs `clean_trunk`
479511
(subport teardown) alongside BM-BIND-04's segment handling.
480512

‎python/neutron-understack/neutron_understack/tests/scenarios/base.py‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,11 @@ def baremetal_binding_profile(physnet=DEFAULT_PHYSNET, port_id="Ethernet1/1"):
109109
profile["physical_network"] = physnet
110110
return profile
111111

112-
def _bind_baremetal_port(self, net_id, physnet, host, fixed_ips=None):
113-
"""Create a baremetal port on the network and vif-attach it to physnet.
112+
def _create_baremetal_port(self, net_id, fixed_ips=None):
113+
"""Create an unbound baremetal port on the network. Returns its id.
114114
115-
Returns the bound port's id. binding:host_id / binding:profile writes
116-
require the service role under secure RBAC. Pass ``fixed_ips=[]`` to force
117-
a port with no IP even on a subnetted network.
115+
Pass ``fixed_ips=[]`` to force a port with no IP even on a subnetted
116+
network.
118117
"""
119118
create_kwargs = {portbindings.VNIC_TYPE: portbindings.VNIC_BAREMETAL}
120119
if fixed_ips is not None:
@@ -127,7 +126,14 @@ def _bind_baremetal_port(self, net_id, physnet, host, fixed_ips=None):
127126
**create_kwargs,
128127
)
129128
assert res.status_int == 201, res.body
130-
port_id = self.deserialize(self.fmt, res)["port"]["id"]
129+
return self.deserialize(self.fmt, res)["port"]["id"]
130+
131+
def _vif_attach_port(self, port_id, physnet, host):
132+
"""vif-attach a port to a switch, as Ironic does on deploy.
133+
134+
binding:host_id / binding:profile writes require the service role under
135+
secure RBAC.
136+
"""
131137
data = {
132138
"port": {
133139
portbindings.HOST_ID: host,
@@ -136,6 +142,14 @@ def _bind_baremetal_port(self, net_id, physnet, host, fixed_ips=None):
136142
}
137143
req = self.new_update_request("ports", data, port_id, as_service=True)
138144
assert req.get_response(self.api).status_int == 200
145+
146+
def _bind_baremetal_port(self, net_id, physnet, host, fixed_ips=None):
147+
"""Create a baremetal port on the network and vif-attach it to physnet.
148+
149+
Returns the bound port's id.
150+
"""
151+
port_id = self._create_baremetal_port(net_id, fixed_ips=fixed_ips)
152+
self._vif_attach_port(port_id, physnet, host)
139153
return port_id
140154

141155
def _trunk_subports(self, trunk_id):

‎python/neutron-understack/neutron_understack/tests/scenarios/test_trunk_operations.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from neutron.services.trunk import exceptions as trunk_exc
1414
from neutron_lib import constants as p_const
1515
from neutron_lib.callbacks import exceptions as cb_exc
16+
from neutron_lib.services.trunk import constants as trunk_consts
1617

1718
from neutron_understack.tests.scenarios.base import DEFAULT_PHYSNET
1819
from neutron_understack.tests.scenarios.base import UnderstackMl2TrunkScenarioBase
@@ -74,6 +75,15 @@ def _remove_subport(self, trunk_id, subport_id):
7475
self.context, trunk_id, {"sub_ports": [{"port_id": subport_id}]}
7576
)
7677

78+
def _trunk_status(self, trunk_id):
79+
return self.trunk_plugin.get_trunk(self.context, trunk_id)["status"]
80+
81+
def _assert_subport_claimed(self, subport_id, status):
82+
"""The trunk owns the subport, and the subport follows the trunk."""
83+
port = self._show("ports", subport_id)["port"]
84+
assert port["device_owner"] == trunk_consts.TRUNK_SUBPORT_OWNER
85+
assert port["status"] == status
86+
7787
def _assert_subport_bound(self, trunk_id, subport_id, host, seg_id=SUBPORT_VLAN):
7888
assert {
7989
subport["port_id"]: subport for subport in self._trunk_subports(trunk_id)
@@ -93,12 +103,19 @@ def _assert_subport_bound(self, trunk_id, subport_id, host, seg_id=SUBPORT_VLAN)
93103
assert segment[segments_db.PHYSICAL_NETWORK] == DEFAULT_PHYSNET
94104
segment_obj = NetworkSegment.get_object(self.context, id=levels[0].segment_id)
95105
assert segment_obj.is_dynamic
106+
107+
self._assert_subport_claimed(subport_id, p_const.PORT_STATUS_ACTIVE)
108+
assert self._trunk_status(trunk_id) == trunk_consts.TRUNK_ACTIVE_STATUS
96109
return levels[0].segment_id
97110

98111
def _assert_subport_unbound(self, subport_id, host, segment_id):
99112
assert not ml2_db.get_binding_level_objs(self.context, subport_id, host)
100113
assert segments_db.get_segment_by_id(self.context, segment_id) is None
101114

115+
port = self._show("ports", subport_id)["port"]
116+
assert port["device_owner"] == ""
117+
assert port["status"] == p_const.PORT_STATUS_DOWN
118+
102119
@pytest.mark.scenario("TRUNK-SUB-ADD")
103120
def test_subport_add_syncs_parent_physnet(self):
104121
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
@@ -128,6 +145,7 @@ def test_subport_remove_syncs_parent_physnet(self):
128145

129146
assert updated_trunk["sub_ports"] == []
130147
self._assert_subport_unbound(subport_id, "host-a", segment_id)
148+
assert self._trunk_status(trunk_id) == trunk_consts.TRUNK_ACTIVE_STATUS
131149
self.undersync_mock.sync.assert_any_call(DEFAULT_PHYSNET)
132150

133151
@pytest.mark.scenario("TRUNK-ORDER-01")
@@ -221,6 +239,8 @@ def test_subport_add_unbound_parent_no_sync(self):
221239
self._add_subport(trunk_id, subport_id)
222240

223241
assert self._trunk_subports(trunk_id)[0]["port_id"] == subport_id
242+
assert self._trunk_status(trunk_id) == trunk_consts.TRUNK_DOWN_STATUS
243+
assert self._show("ports", subport_id)["port"]["device_owner"] == ""
224244
assert not ml2_db.get_binding_level_objs(self.context, subport_id, "")
225245
assert (
226246
segments_db.get_dynamic_segment(
@@ -274,6 +294,29 @@ def test_multiple_subports_add_syncs(self):
274294
assert segment_a != segment_b
275295
self.undersync_mock.sync.assert_any_call(DEFAULT_PHYSNET)
276296

297+
@pytest.mark.scenario("TRUNK-STATUS-PARENT-BIND-01")
298+
def test_trunk_activates_when_the_parent_is_vif_attached(self):
299+
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
300+
parent_id = self._create_baremetal_port(parent_net)
301+
subport_net = self._make_network(self.fmt, "subport-net", True)["network"]["id"]
302+
subport_id = self._plain_port(subport_net)
303+
trunk_id = self._make_trunk(parent_id)
304+
self._add_subport(trunk_id, subport_id)
305+
306+
# Nothing is carried yet: the node has not been deployed.
307+
assert self._trunk_status(trunk_id) == trunk_consts.TRUNK_DOWN_STATUS
308+
self._assert_subport_claimed(subport_id, p_const.PORT_STATUS_DOWN)
309+
310+
self.undersync_mock.reset_mock()
311+
with mock.patch(
312+
"neutron_understack.utils.fetch_network_node_trunk_id",
313+
return_value=_FAKE_NN_TRUNK,
314+
):
315+
self._vif_attach_port(parent_id, DEFAULT_PHYSNET, "host-a")
316+
317+
self._assert_subport_bound(trunk_id, subport_id, "host-a")
318+
self.undersync_mock.sync.assert_any_call(DEFAULT_PHYSNET)
319+
277320
@pytest.mark.scenario("TRUNK-SEGID-RANGE-01")
278321
def test_subport_segid_out_of_range_rejected(self):
279322
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
@@ -288,6 +331,7 @@ def test_subport_segid_out_of_range_rejected(self):
288331
with pytest.raises(cb_exc.CallbackFailure) as exc_info:
289332
self._add_subport(trunk_id, subport_id, seg_id=4000)
290333
assert "Segmentation ID" in str(exc_info.value)
334+
assert self._show("ports", subport_id)["port"]["device_owner"] == ""
291335
assert not ml2_db.get_binding_level_objs(self.context, subport_id, "host-a")
292336
assert (
293337
segments_db.get_dynamic_segment(

0 commit comments

Comments
 (0)