Skip to content
Open
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 components/images-openstack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ images:
neutron_metadata: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_ovn_metadata: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_openvswitch_agent: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_server: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_server: "ghcr.io/rackerlabs/understack/neutron:pr-2456"
neutron_rpc_server: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_ovn_maintenance_worker: "ghcr.io/rackerlabs/understack/neutron:2026.1"
neutron_ironic_agent: "ghcr.io/rackerlabs/understack/neutron:2026.1"
Expand Down
6 changes: 3 additions & 3 deletions python/neutron-understack/neutron_understack/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
),
cfg.ListOpt(
"default_tenant_vlan_id_range",
default=[1, 3799],
default=[2, 3871],
item_type=cfg.types.Integer(min=1, max=4094),
help=(
"List of 2 comma separated integers, that represents a VLAN range, that"
"will be used for mapped VLANs on the switches."
"Two comma-separated integers defining the inclusive VLAN range used "
"for mapped VLANs on switches and accepted for tenant trunk subports."
),
),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,17 @@ def port_context(network_context, port_dict, port_binding, ml2_plugin) -> PortCo


@pytest.fixture
def understack_driver(oslo_config) -> UnderstackDriver:
def understack_driver(mocker, oslo_config) -> UnderstackDriver:
"""The understack driver, which must never reach out to Undersync.

No ``undersync`` attribute is injected on purpose, so a reintroduced
``self.undersync.sync(...)`` fails loudly instead of passing against a mock.
The trunk collaborator is mocked because these unit tests do not call the
driver's production ``initialize()`` hook.
"""
return UnderstackDriver()
driver = UnderstackDriver()
driver.trunk_driver = mocker.create_autospec(UnderstackTrunkDriver, instance=True)
return driver


@pytest.fixture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,39 @@ adds/removes must reconcile the parent's switch (VLAN group).
parent binding there is no physnet to carry the subport on and nothing to
reconcile

### TRUNK-SEGID-RANGE-01 — subport seg_id outside the allowed range is rejected
### TRUNK-PARENT-BIND-NATIVE-01 — binding rejects an existing native VLAN collision
- given: an unbound baremetal parent with a trunk whose existing subport tag
will equal the parent's native VLAN on its eventual physical network
- when: the parent is vif-attached and that native VLAN is selected
- then: the parent binding fails with no parent or subport binding levels and
no `undersync.sync`; the collision could not be checked when the trunk or
subport was created because the unbound parent had no physical network or
native VLAN yet

### TRUNK-PARENT-BIND-NATIVE-02 — trunk and subports created before bind are configured at bind
- given: an unbound baremetal parent with a trunk and several subports added
before the parent has any host or physical network
- when: the parent is vif-attached
- then: the parent binds, each subport gets a level-0 binding row on its
network's dynamic VLAN segment for the parent's physnet, and
`undersync.sync` fires for that physnet; none of this existed before the
bind because the subport add had no physnet to allocate on

### TRUNK-SEGID-NATIVE-01 — subport seg_id matching the native VLAN is rejected
- given: a bound baremetal parent with a trunk
- when: a subport is added with a segmentation_id outside `[1, 3799]`
- when: a subport is added with a segmentation_id equal to the VLAN segment of
the parent's network on the parent's physical network
- then: `SubportSegmentationIDError` (raised in the SUBPORTS PRECOMMIT_CREATE
callback, surfaced as `CallbackFailure`), with no binding level or dynamic
segment allocated

### TRUNK-SEGID-RANGE-01 — subport seg_id outside the tenant range is rejected
- given: a bound baremetal parent with a trunk
- when: a subport is added with a VLAN segmentation_id outside the configured
`default_tenant_vlan_id_range`
- then: the request is rejected before a binding level or dynamic segment is
allocated

### TRUNK-ORDER-01 — subport removal deallocates before it notifies Undersync
- given: a bound baremetal parent with a trunk and an attached subport
- when: the subport is removed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from neutron.plugins.ml2 import db as ml2_db
from neutron.services.trunk import exceptions as trunk_exc
from neutron_lib import constants as p_const
from neutron_lib.api.definitions import portbindings
from neutron_lib.callbacks import exceptions as cb_exc

from neutron_understack.tests.scenarios.base import DEFAULT_PHYSNET
Expand All @@ -32,6 +33,33 @@ def _plain_port(self, net_id):
assert res.status_int == 201, res.body
return self.deserialize(self.fmt, res)["port"]["id"]

def _unbound_baremetal_port(self, net_id):
res = self._create_port(
self.fmt,
net_id,
arg_list=(portbindings.VNIC_TYPE,),
is_admin=True,
**{portbindings.VNIC_TYPE: portbindings.VNIC_BAREMETAL},
)
assert res.status_int == 201, res.body
return self.deserialize(self.fmt, res)["port"]

def _vif_attach(self, port_id, physnet=DEFAULT_PHYSNET, host="host-a"):
data = {
"port": {
portbindings.HOST_ID: host,
portbindings.PROFILE: self.baremetal_binding_profile(physnet=physnet),
}
}
req = self.new_update_request("ports", data, port_id, as_service=True)
with mock.patch(
"neutron_understack.utils.fetch_network_node_trunk_id",
return_value=_FAKE_NN_TRUNK,
):
res = req.get_response(self.api)
assert res.status_int == 200, res.body
return self.deserialize(self.fmt, res)["port"]

def _make_trunk(self, parent_id):
trunk = self.trunk_plugin.create_trunk(
self.context,
Expand Down Expand Up @@ -230,6 +258,77 @@ def test_subport_add_unbound_parent_no_sync(self):
)
self.undersync_mock.sync.assert_not_called()

# The understack driver raises SubportSegmentationIDError in bind_port, but
# ML2 has no way to abort a bind: it logs the failure and tries the next
# mechanism driver, which binds the port. This asserts the desired behavior;
# strict=True flips an unexpected pass into a failure, prompting removal of
# the xfail once the upstream Neutron fix is backported.
@pytest.mark.xfail(
strict=True,
reason=(
"native VLAN collision on parent bind is detected but ML2 cannot "
"abort a bind; it falls through to the next mechanism driver and the "
"port binds (vif_type=other). Needs the upstream Neutron fix backported"
),
)
@pytest.mark.scenario("TRUNK-PARENT-BIND-NATIVE-01")
def test_unbound_trunk_rejects_native_vlan_collision_on_parent_bind(self):
"""Binding must revalidate subports once the native VLAN is known."""
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
parent = self._unbound_baremetal_port(parent_net)
subport_net = self._make_network(self.fmt, "subport-net", True)["network"]["id"]
subport_id = self._plain_port(subport_net)
trunk_id = self._make_trunk(parent["id"])

# This is valid while the parent is unbound because its native VLAN is
# not known yet. Pin the segment that vif-attach will select so the
# eventual collision is deterministic.
self._add_subport(trunk_id, subport_id, seg_id=SUBPORT_VLAN)
native_segment = {
segments_db.NETWORK_TYPE: p_const.TYPE_VLAN,
segments_db.PHYSICAL_NETWORK: DEFAULT_PHYSNET,
segments_db.SEGMENTATION_ID: SUBPORT_VLAN,
}
segments_db.add_network_segment(
self.context, parent_net, native_segment, is_dynamic=True
)

self.undersync_mock.reset_mock()
updated = self._vif_attach(parent["id"])

assert updated[portbindings.VIF_TYPE] == portbindings.VIF_TYPE_BINDING_FAILED
assert not ml2_db.get_binding_level_objs(self.context, parent["id"], "host-a")
assert not ml2_db.get_binding_level_objs(self.context, subport_id, "host-a")
self.undersync_mock.sync.assert_not_called()

@pytest.mark.scenario("TRUNK-PARENT-BIND-NATIVE-02")
def test_unbound_trunk_with_subports_configures_on_parent_bind(self):
"""Everything created before the parent binds is configured at bind."""
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
parent = self._unbound_baremetal_port(parent_net)
sub_net_a = self._make_network(self.fmt, "sub-a", True)["network"]["id"]
sub_net_b = self._make_network(self.fmt, "sub-b", True)["network"]["id"]
subport_a = self._plain_port(sub_net_a)
subport_b = self._plain_port(sub_net_b)
trunk_id = self._make_trunk(parent["id"])
self._add_subport(trunk_id, subport_a, seg_id=1500)
self._add_subport(trunk_id, subport_b, seg_id=1600)
for net_id in (sub_net_a, sub_net_b):
assert (
segments_db.get_dynamic_segment(
self.context, net_id, physical_network=DEFAULT_PHYSNET
)
is None
)

self.undersync_mock.reset_mock()
updated = self._vif_attach(parent["id"])

assert updated[portbindings.VIF_TYPE] == portbindings.VIF_TYPE_OTHER
self._assert_subport_bound(trunk_id, subport_a, "host-a", seg_id=1500)
self._assert_subport_bound(trunk_id, subport_b, "host-a", seg_id=1600)
self.undersync_mock.sync.assert_any_call(DEFAULT_PHYSNET)

@pytest.mark.scenario("TRUNK-MULTI-01")
def test_multiple_subports_add_syncs(self):
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
Expand Down Expand Up @@ -274,20 +373,46 @@ def test_multiple_subports_add_syncs(self):
assert segment_a != segment_b
self.undersync_mock.sync.assert_any_call(DEFAULT_PHYSNET)

@pytest.mark.scenario("TRUNK-SEGID-RANGE-01")
def test_subport_segid_out_of_range_rejected(self):
@pytest.mark.scenario("TRUNK-SEGID-NATIVE-01")
def test_subport_segid_matching_native_vlan_rejected(self):
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
parent_id = self._bind_baremetal_port(parent_net, DEFAULT_PHYSNET, "host-a")
subport_net = self._make_network(self.fmt, "subport-net", True)["network"]["id"]
subport_id = self._plain_port(subport_net)
trunk_id = self._make_trunk(parent_id)
native_segment = segments_db.get_dynamic_segment(
self.context, parent_net, physical_network=DEFAULT_PHYSNET
)

# 4000 is a valid VLAN but outside the default tenant range [1, 3799].
# subports_added raises SubportSegmentationIDError, which the SUBPORTS
# PRECOMMIT_CREATE callback machinery re-raises as CallbackFailure.
# subports_added raises SubportSegmentationIDError when the requested
# tag is the native VLAN, and the callback machinery wraps it.
with pytest.raises(cb_exc.CallbackFailure) as exc_info:
self._add_subport(
trunk_id,
subport_id,
seg_id=native_segment[segments_db.SEGMENTATION_ID],
)
assert "used as both" in str(exc_info.value)
assert not ml2_db.get_binding_level_objs(self.context, subport_id, "host-a")
assert (
segments_db.get_dynamic_segment(
self.context, subport_net, physical_network=DEFAULT_PHYSNET
)
is None
)

@pytest.mark.scenario("TRUNK-SEGID-RANGE-01")
def test_subport_segid_outside_configured_range_is_rejected(self):
parent_net = self._make_network(self.fmt, "parent-net", True)["network"]["id"]
parent_id = self._bind_baremetal_port(parent_net, DEFAULT_PHYSNET, "host-a")
subport_net = self._make_network(self.fmt, "subport-net", True)["network"]["id"]
subport_id = self._plain_port(subport_net)
trunk_id = self._make_trunk(parent_id)
with pytest.raises(cb_exc.CallbackFailure) as exc_info:
self._add_subport(trunk_id, subport_id, seg_id=4000)
assert "Segmentation ID" in str(exc_info.value)
assert "outside the configured tenant trunk VLAN range 2-3871" in str(
exc_info.value
)
assert not ml2_db.get_binding_level_objs(self.context, subport_id, "host-a")
assert (
segments_db.get_dynamic_segment(
Expand Down
Loading
Loading