Skip to content
Closed
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,30 @@ 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-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,36 @@ def test_subport_add_unbound_parent_no_sync(self):
)
self.undersync_mock.sync.assert_not_called()

@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-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 +332,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 "matches the native VLAN" 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