Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f85d969
test(neutron-understack): add in-process ML2 scenario test harness
cardoe Aug 24, 2026
de4a7ee
test(neutron-understack): baremetal vif-attach binding scenarios
cardoe Aug 24, 2026
eb6ac19
ci(neutron-understack): scenario traceability report from JUnit
cardoe Aug 24, 2026
58ce188
test(neutron-understack): VRF router interface sync scenario (xfail)
cardoe Aug 24, 2026
2970cc9
test(neutron-understack): SVI router interface sync scenario (xfail)
cardoe Aug 24, 2026
5bae0d3
ci: publish consolidated test results on PRs
cardoe Aug 24, 2026
3201122
test(neutron-understack): trunk subport + no-IP sync scenarios
cardoe Aug 24, 2026
51184d2
docs(neutron-understack): backlog of uncovered ML2 scenarios
cardoe Aug 24, 2026
2f59b71
test(neutron-understack): provisioning-network + segment-reuse scenarios
cardoe Aug 24, 2026
4fecd2e
test(neutron-understack): SVI address-scope validation rejections
cardoe Aug 24, 2026
0dfcc95
test(neutron-understack): trunk delete, multi, unbound, seg-id scenarios
cardoe Aug 24, 2026
9383466
test(neutron-understack): non-flavored router uplink scenarios
cardoe Aug 24, 2026
3f37ff7
test(neutron-understack): Palo Alto router flavor scenarios
cardoe Aug 24, 2026
4c7f312
test(neutron-understack): VRF router detach sync scenario (xfail)
cardoe Aug 24, 2026
46c73e7
test(neutron-understack): address code-review findings on scenario suite
cardoe Aug 25, 2026
472b468
test(neutron-understack): collection-based enforcement + richer asserts
cardoe Aug 25, 2026
1a74a77
test(neutron-understack): dedupe _trunk_subports onto the shared base
cardoe Aug 25, 2026
2fc2c82
test(neutron-understack): rename router scenarios to <TYPE>-ROUTER-<OP>
cardoe Aug 25, 2026
91b84e6
docs(neutron-understack): widen router-flavor section to VRF & SVI
cardoe Aug 25, 2026
2232ef2
docs(neutron-understack): explain VRF vs SVI flavor relationship
cardoe Aug 25, 2026
39f1959
test(neutron-understack): OVN router detach segment-release (xfail)
cardoe Aug 25, 2026
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
48 changes: 47 additions & 1 deletion .github/workflows/code-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,35 @@ jobs:
if [ -e .stestr.conf ]; then
uv run stestr run
else
uv run pytest --cov --cov-report xml:coverage.xml
uv run pytest --cov --cov-report xml:coverage.xml --junitxml=junit.xml
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-${{ matrix.project }}
path: python/${{ matrix.project }}/coverage.xml
retention-days: 1
# Publish the ML2 scenario traceability matrix (scenario ID -> status) to
# the job summary and keep the JUnit as an artifact. neutron-understack
# only: its scenario tests tag each JUnit case with a `scenario` property.
- name: ML2 scenario traceability report
if: ${{ always() && matrix.project == 'neutron-understack' }}
run: |
if [ -f junit.xml ]; then
uv run python ../../scripts/scenario-report.py \
--catalog neutron_understack/tests/scenarios/SCENARIOS.md \
--junit junit.xml >> "$GITHUB_STEP_SUMMARY"
else
echo "No junit.xml produced (tests did not run)." >> "$GITHUB_STEP_SUMMARY"
fi
# Keep each project's JUnit for the consolidated publish-test-results job.
# if-no-files-found=ignore: stestr projects don't emit junit.xml.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: ${{ always() }}
with:
name: junit-${{ matrix.project }}
path: python/${{ matrix.project }}/junit.xml
if-no-files-found: ignore
retention-days: 1


coverage-upload:
Expand All @@ -82,3 +104,27 @@ jobs:
name: coverage.xml
path: coverage.xml
retention-days: 1

# Consolidated pass/fail/skip report from every project's JUnit, posted as a
# PR comment + check. Same-repo PRs only get write permissions; fork PRs would
# need a separate workflow_run-triggered workflow (not wired up here).
publish-test-results:
needs: [uv]
# Skip on fork PRs: the token is read-only there, so the action cannot post
# the check/comment. Forks would need a separate workflow_run workflow.
if: ${{ always() && github.event.pull_request.head.repo.fork != true }}
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: junit-*
merge-multiple: true
path: junit
- uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
with:
check_name: Python test results
junit_files: junit/**/*.xml
5 changes: 5 additions & 0 deletions containers/neutron/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ ARG OPENSTACK_VERSION="required_argument"
ADD https://releases.openstack.org/constraints/upper/${OPENSTACK_VERSION} /upper-constraints.txt
RUN sed -i '/^neutron-lib==.*/d' /upper-constraints.txt

# --no-sources ignores neutron-understack's [tool.uv.sources] (a dev/test-only
# pin of neutron/neutron-lib to the understack/2026.1 git forks). Here we install
# those forks explicitly from /src, so honoring the git sources would just create
# a conflicting-URL error for neutron-lib.
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install \
--upgrade \
--no-sources \
--constraint /upper-constraints.txt \
/src/neutron \
/src/neutron-lib \
Expand Down

Large diffs are not rendered by default.

Empty file.
211 changes: 211 additions & 0 deletions python/neutron-understack/neutron_understack/tests/scenarios/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""In-process ML2 scenario-test harness for the understack mechanism drivers.

These tests reuse neutron's own functional test bases, which stand up a real
``Ml2Plugin`` backed by in-memory SQLite. We load our two mechanism drivers --
``understack`` and ``undersync`` -- into the binding chain and drive real
``create_network`` / ``create_port`` / router operations through the ML2 manager,
so a scenario can assert *which* calls reach our drivers and *with what data*.
This is the same style upstream neutron uses to test its own ML2 drivers.

``UnderstackMl2ScenarioBase`` is the plain base (port binding scenarios).
``UnderstackMl2RouterScenarioBase`` adds a real L3 router + flavors plugin
(``ML2TestFramework``) for router scenarios.

Only the genuine external edge on the binding path -- the Undersync HTTP client --
is mocked. Router scenarios that reach the OVN uplink path would additionally
need the OVN IDL faked; the flavored (VRF) router path skips it.
"""

from unittest import mock

from neutron.conf.agent import ovs_conf
from neutron.conf.plugins.ml2.drivers import driver_type
from neutron.tests.unit.plugins.ml2.base import ML2TestFramework
from neutron.tests.unit.plugins.ml2.test_plugin import Ml2PluginV2TestCase
from neutron_lib.api.definitions import portbindings
from neutron_lib.plugins import directory
from oslo_config import cfg

from neutron_understack import config as understack_config
from neutron_understack.undersync import Undersync

#: Physnets the type manager knows about (the parent setUp configures physnet1/2
#: with VLAN ranges), so ``allocate_dynamic_segment`` succeeds for the VLAN group
#: named in a baremetal binding profile.
DEFAULT_PHYSNET = "physnet1"
SECOND_PHYSNET = "physnet2"

#: Stand-in switch identity for the binding profile's local_link_information.
DEFAULT_SWITCH_ID = "11:22:33:44:55:66"
DEFAULT_SWITCH_INFO = "a1-1-1.iad3.rackspace.net"


def _apply_understack_ml2_overrides():
"""Config overrides the plugin needs before it loads in setup_parent().

Tenant networks are VXLAN (matching production); dynamic VLAN segments come
from the physnet ranges the parent setUp configures.
"""
cfg.CONF.set_override("project_network_types", ["vxlan"], group="ml2")
# ml2_type_vxlan is only registered when the vxlan type driver loads, so
# register it here before overriding its (empty) vni_ranges.
driver_type.register_ml2_drivers_vxlan_opts(cfg.CONF)
cfg.CONF.set_override("vni_ranges", ["1000:2000"], group="ml2_type_vxlan")

# UnderstackDriver.initialize() registers these itself, but we register up
# front so we can set values the driver reads while loading.
understack_config.register_ml2_understack_opts(cfg.CONF)
cfg.CONF.set_override(
"undersync_url", "http://undersync.test", group="ml2_understack"
)
cfg.CONF.set_override("undersync_dry_run", False, group="ml2_understack")
cfg.CONF.set_override(
"provisioning_network",
"00000000-0000-0000-0000-000000000000",
group="ml2_understack",
)


class _UnderstackMl2ScenarioMixin:
"""Shared setUp + helpers for understack ML2 scenario bases."""

# Order matters: understack does hierarchical VXLAN->VLAN binding and
# undersync finalizes the VLAN segment. 'logger' mirrors the production list.
_mechanism_drivers = ["logger", "understack", "undersync"]

def setUp(self):
_apply_understack_ml2_overrides()
super().setUp()
# Replace the real HTTP Undersync client with a mock -- the single
# genuine external edge on the binding path. The trunk driver captured
# its own reference at construction (trunk.py), so patch that too.
self.understack_driver = self._mech_driver("understack")
self.undersync_mock = mock.MagicMock(spec_set=Undersync)
self.understack_driver.undersync = self.undersync_mock
self.understack_driver.trunk_driver.undersync = self.undersync_mock

@staticmethod
def _mech_driver(name):
"""Return the loaded mechanism driver instance registered under ``name``."""
manager = directory.get_plugin().mechanism_manager
for ext in manager.ordered_mech_drivers:
if ext.name == name:
return ext.obj
raise AssertionError(f"mechanism driver {name!r} not loaded")

@staticmethod
def baremetal_binding_profile(physnet=DEFAULT_PHYSNET, port_id="Ethernet1/1"):
"""A baremetal port binding profile as Ironic supplies on vif-attach.

``physical_network`` names the VLAN group; ``local_link_information``
identifies the switch port. Pass ``physnet=None`` to omit the
``physical_network`` key (the "missing physnet" edge case).
"""
profile: dict = {
"local_link_information": [
{
"port_id": port_id,
"switch_id": DEFAULT_SWITCH_ID,
"switch_info": DEFAULT_SWITCH_INFO,
}
],
}
if physnet is not None:
profile["physical_network"] = physnet
return profile

def _bind_baremetal_port(self, net_id, physnet, host, fixed_ips=None):
"""Create a baremetal port on the network and vif-attach it to physnet.

Returns the bound port's id. binding:host_id / binding:profile writes
require the service role under secure RBAC. Pass ``fixed_ips=[]`` to force
a port with no IP even on a subnetted network.
"""
create_kwargs = {portbindings.VNIC_TYPE: portbindings.VNIC_BAREMETAL}
if fixed_ips is not None:
create_kwargs["fixed_ips"] = fixed_ips
res = self._create_port(
self.fmt,
net_id,
arg_list=(portbindings.VNIC_TYPE,),
is_admin=True,
**create_kwargs,
)
assert res.status_int == 201, res.body
port_id = self.deserialize(self.fmt, res)["port"]["id"]
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)
assert req.get_response(self.api).status_int == 200
return port_id

def _trunk_subports(self, trunk_id):
"""Return a trunk's subports as the trunk plugin reports them.

Requires ``self.trunk_plugin`` (set by the trunk/router-OVN bases).
"""
return self.trunk_plugin.get_trunk(self.context, trunk_id)["sub_ports"]


class UnderstackMl2ScenarioBase(_UnderstackMl2ScenarioMixin, Ml2PluginV2TestCase):
"""Base for port-binding scenarios (no L3/router machinery)."""


class UnderstackMl2RouterScenarioBase(_UnderstackMl2ScenarioMixin, ML2TestFramework):
"""Base for router scenarios: real L3RouterPlugin + flavors plugin.

``self.l3_plugin`` is the loaded L3 plugin and ``self._create_router()``
creates a router directly (bypassing HTTP), both provided by ML2TestFramework.
"""


class UnderstackMl2RouterOvnScenarioBase(UnderstackMl2RouterScenarioBase):
"""Router base for the non-flavored uplink path (OVN + network-node trunk).

Loads the trunk plugin (the uplink adds the shared port to the network-node
trunk) and configures ``network_node_switchport_physnet``. Scenarios patch
``routers.ovn_client`` with a FakeOvnClient and mock
``utils.fetch_network_node_trunk_id`` to a trunk they create.
"""

#: A VLAN physnet the parent setUp configures (physnet3: 400-500), used for
#: the router<->network-node uplink segment.
NETWORK_NODE_PHYSNET = "physnet3"

def get_additional_service_plugins(self):
plugins = super().get_additional_service_plugins()
plugins["trunk_plugin_name"] = "neutron.services.trunk.plugin.TrunkPlugin"
return plugins

def setUp(self):
super().setUp()
cfg.CONF.set_override(
"network_node_switchport_physnet",
self.NETWORK_NODE_PHYSNET,
group="ml2_understack",
)
# create_uplink_port reads cfg.CONF.OVS igmp options.
ovs_conf.register_ovs_agent_opts(cfg.CONF)
self.trunk_plugin = directory.get_plugin("trunk")


class UnderstackMl2TrunkScenarioBase(UnderstackMl2ScenarioBase):
"""Base for trunk scenarios: loads the real neutron trunk service plugin.

The understack trunk driver registers against the trunk plugin's AFTER_INIT
event, so loading the plugin here wires the SUBPORTS/TRUNK handlers. Exposes
``self.trunk_plugin``.
"""

def get_additional_service_plugins(self):
plugins = super().get_additional_service_plugins()
plugins["trunk_plugin_name"] = "neutron.services.trunk.plugin.TrunkPlugin"
return plugins

def setUp(self):
super().setUp()
self.trunk_plugin = directory.get_plugin("trunk")
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Single source of truth for parsing the SCENARIOS.md catalog.

Both the coverage meta-test (test_scenario_coverage.py) and the CI report
(scripts/scenario-report.py) parse scenario IDs from the catalog headings; they
import from here so the heading format is defined once and cannot diverge.
"""

import re

SCENARIO_ID_PATTERN = r"[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+"
SCENARIO_ID_RE = re.compile(rf"^{SCENARIO_ID_PATTERN}$")

#: Catalog IDs are declared as headings: "### <ID> — <title>" (title optional).
_CATALOG_RE = re.compile(rf"^#{{2,3}}\s+({SCENARIO_ID_PATTERN})(?:\s*[—-]\s*(.*))?$")


def parse_catalog(text):
"""Return an ordered list of ``(scenario_id, title)`` from the catalog."""
entries = []
for line in text.splitlines():
match = _CATALOG_RE.match(line)
if match:
entries.append((match.group(1), (match.group(2) or "").strip()))
return entries


def catalog_ids(text):
"""Return an ordered list of catalogued scenario IDs."""
return [scenario_id for scenario_id, _ in parse_catalog(text)]
Loading
Loading