From 3da1578538b347221718adf5ad915771941fa92f Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Wed, 17 Jun 2026 09:44:32 -0500 Subject: [PATCH 1/8] chore(neutron-understack): wire up OSLO config for validation Wire up the entry point so that we can do OSLO config generation and validation against our configuration files. Moved the global variables to be prefixed with an underscore to not globally export them. Create a group variable for the group name constant. --- .../neutron_understack/config.py | 24 +++++++++++++++---- python/neutron-understack/pyproject.toml | 4 ++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index 8926960ef..f42f41949 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -1,6 +1,8 @@ from oslo_config import cfg -mech_understack_opts = [ +_OPT_GRP_ML2_UNDERSTACK = "ml2_understack" + +_mech_understack_opts = [ cfg.StrOpt( "nb_url", help="Nautobot URL", @@ -71,7 +73,9 @@ ), ] -l3_svc_cisco_asa_opts = [ +_OPT_GRP_L3_SVC_CISCO_ASA = "l3_service_cisco_asa" + +_l3_svc_cisco_asa_opts = [ cfg.StrOpt( "user_agent", help="User-Agent for requests to Cisco ASA", @@ -93,9 +97,21 @@ ] +def list_understack_opts(): + return [ + (_OPT_GRP_ML2_UNDERSTACK, _mech_understack_opts), + ] + + +def list_cisco_asa_opts(): + return [ + (_OPT_GRP_L3_SVC_CISCO_ASA, _l3_svc_cisco_asa_opts), + ] + + def register_ml2_understack_opts(config): - config.register_opts(mech_understack_opts, "ml2_understack") + config.register_opts(_mech_understack_opts, _OPT_GRP_ML2_UNDERSTACK) def register_l3_svc_cisco_asa_opts(config): - config.register_opts(l3_svc_cisco_asa_opts, "l3_service_cisco_asa") + config.register_opts(_l3_svc_cisco_asa_opts, _OPT_GRP_L3_SVC_CISCO_ASA) diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index 16d62d7ad..5054b36aa 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -29,6 +29,10 @@ dependencies = [ "neutron>=27,<29", ] +[project.entry-points."oslo.config.opts"] +understack = "neutron_understack.config:list_understack_opts" +cisco-asa = "neutron_understack.config:list_cisco_asa_opts" + [project.entry-points."neutron.ml2.mechanism_drivers"] understack = "neutron_understack.neutron_understack_mech:UnderstackDriver" undersync = "neutron_understack.undersync_mech:UndersyncDriver" From 4d63e482309b30810321a089a3ee560543f928ac Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Wed, 17 Jun 2026 12:37:54 -0500 Subject: [PATCH 2/8] chore(neutron-understack): include dependencies on things we import Explicitly include dependencies on every package that we directly import in the code base. --- python/neutron-understack/pyproject.toml | 5 +++++ python/neutron-understack/uv.lock | 27 ++++++++++++------------ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index 5054b36aa..70ca1dd1b 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -27,6 +27,10 @@ dependencies = [ "requests>=2,<3", "neutron-lib>=3,<4", "neutron>=27,<29", + "oslo.config>=9.7.1", + "oslo.log>=7.1.0", + "openstacksdk>=4.9.0", + "keystoneauth1>=3.14.0", ] [project.entry-points."oslo.config.opts"] @@ -48,6 +52,7 @@ test = [ "pytest>=9.0.1,<10", "pytest-cov>=7,<8", "pytest-mock>=3.14.0,<4", + "sqlalchemy", ] [tool.uv] diff --git a/python/neutron-understack/uv.lock b/python/neutron-understack/uv.lock index c9a219bd5..b71ad9747 100644 --- a/python/neutron-understack/uv.lock +++ b/python/neutron-understack/uv.lock @@ -771,8 +771,12 @@ name = "neutron-understack" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "keystoneauth1" }, { name = "neutron" }, { name = "neutron-lib" }, + { name = "openstacksdk" }, + { name = "oslo-config" }, + { name = "oslo-log" }, { name = "requests" }, ] @@ -781,12 +785,17 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, + { name = "sqlalchemy" }, ] [package.metadata] requires-dist = [ + { name = "keystoneauth1", specifier = ">=3.14.0" }, { name = "neutron", specifier = ">=27,<29" }, { name = "neutron-lib", specifier = ">=3,<4" }, + { name = "openstacksdk", specifier = ">=4.9.0" }, + { name = "oslo-config", specifier = ">=9.7.1" }, + { name = "oslo-log", specifier = ">=7.1.0" }, { name = "requests", specifier = ">=2,<3" }, ] @@ -795,11 +804,12 @@ test = [ { name = "pytest", specifier = ">=9.0.1,<10" }, { name = "pytest-cov", specifier = ">=7,<8" }, { name = "pytest-mock", specifier = ">=3.14.0,<4" }, + { name = "sqlalchemy" }, ] [[package]] name = "openstacksdk" -version = "4.8.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -814,12 +824,10 @@ dependencies = [ { name = "platformdirs" }, { name = "psutil" }, { name = "pyyaml" }, - { name = "requestsexceptions" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/24/1167097740136e302c74043c1c6feecf8d757b052d7b457960e0dc60fa03/openstacksdk-4.8.0.tar.gz", hash = "sha256:4dc038e1c17d893005f3a0a8951456afd9d148f3f65d448f94adcceb278d7f31", size = 1309981, upload-time = "2025-11-13T13:59:59.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/43/49b126e9ccfa19647d2f0aff26321caded607522d29c3d9495d44f4b9471/openstacksdk-4.16.0.tar.gz", hash = "sha256:466640c6d2b813b782d4ad58a4f2960633e8e58f147ec0baa2019454de190d30", size = 1397114, upload-time = "2026-06-17T08:43:22.774Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/45/87fa873f35abdf191d66d4821fe965f781e2d3e37e58883c6e23e95fa794/openstacksdk-4.8.0-py3-none-any.whl", hash = "sha256:7f7c438d418a4ee0c8737b1ac0859b3c1e8e21401677782415d21bce3324b9dd", size = 1849694, upload-time = "2025-11-13T13:59:57.634Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ea/60a4a274c4991847a085ac4a36b19d09f44f04a22adcfda6ca631763322b/openstacksdk-4.16.0-py3-none-any.whl", hash = "sha256:0469939bd7fc1b80979e4440dd8f8026eea302bdd979fac5437b56ada3780a62", size = 1982817, upload-time = "2026-06-17T08:43:21.148Z" }, ] [[package]] @@ -1775,15 +1783,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] -[[package]] -name = "requestsexceptions" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/61b9652d3256503c99b0b8f145d9c8aa24c514caff6efc229989505937c1/requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065", size = 6880, upload-time = "2018-02-01T17:04:45.294Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/8c/49ca60ea8c907260da4662582c434bec98716177674e88df3fd340acf06d/requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3", size = 3802, upload-time = "2018-02-01T17:04:39.07Z" }, -] - [[package]] name = "rfc3986" version = "2.0.0" From 49157b9440ff50c20fed5ed60eaef3c051d7d6b2 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 16:02:00 -0500 Subject: [PATCH 3/8] chore(neutron-understack): register the OSLO ironic auth config We use the [ironic] section authentication so properly register it so we can eventually validate our configs. Make the session fetching function common so it can be shared. --- .../neutron_understack/config.py | 30 ++++++++++++++++++- .../neutron_understack/ironic.py | 11 +++---- python/neutron-understack/pyproject.toml | 1 + 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/python/neutron-understack/neutron_understack/config.py b/python/neutron-understack/neutron_understack/config.py index f42f41949..cbcbd440f 100644 --- a/python/neutron-understack/neutron_understack/config.py +++ b/python/neutron-understack/neutron_understack/config.py @@ -1,6 +1,10 @@ +from keystoneauth1 import loading as ks_loading +from keystoneauth1 import session as ks_session from oslo_config import cfg _OPT_GRP_ML2_UNDERSTACK = "ml2_understack" +_OPT_GRP_IRONIC = "ironic" +_OPT_GRP_L3_SVC_CISCO_ASA = "l3_service_cisco_asa" _mech_understack_opts = [ cfg.StrOpt( @@ -73,7 +77,6 @@ ), ] -_OPT_GRP_L3_SVC_CISCO_ASA = "l3_service_cisco_asa" _l3_svc_cisco_asa_opts = [ cfg.StrOpt( @@ -103,6 +106,19 @@ def list_understack_opts(): ] +def list_ironic_opts(): + return [ + ( + _OPT_GRP_IRONIC, + [ + *ks_loading.get_adapter_conf_options(include_deprecated=False), + *ks_loading.get_session_conf_options(), + *ks_loading.get_auth_plugin_conf_options("v3password"), + ], + ) + ] + + def list_cisco_asa_opts(): return [ (_OPT_GRP_L3_SVC_CISCO_ASA, _l3_svc_cisco_asa_opts), @@ -113,5 +129,17 @@ def register_ml2_understack_opts(config): config.register_opts(_mech_understack_opts, _OPT_GRP_ML2_UNDERSTACK) +def register_ironic_opts(config): + ks_loading.register_adapter_conf_options(config, _OPT_GRP_IRONIC) + ks_loading.register_session_conf_options(config, _OPT_GRP_IRONIC) + ks_loading.register_auth_conf_options(config, _OPT_GRP_IRONIC) + + def register_l3_svc_cisco_asa_opts(config): config.register_opts(_l3_svc_cisco_asa_opts, _OPT_GRP_L3_SVC_CISCO_ASA) + + +def get_session(group: str) -> ks_session.Session: + auth = ks_loading.load_auth_from_conf_options(cfg.CONF, group) + session = ks_loading.load_session_from_conf_options(cfg.CONF, group, auth=auth) + return session diff --git a/python/neutron-understack/neutron_understack/ironic.py b/python/neutron-understack/neutron_understack/ironic.py index 50f196b05..d59d58ded 100644 --- a/python/neutron-understack/neutron_understack/ironic.py +++ b/python/neutron-understack/neutron_understack/ironic.py @@ -1,21 +1,18 @@ -from keystoneauth1 import loading as ks_loading from openstack import connection from openstack.baremetal.baremetal_service import BaremetalService from openstack.baremetal.v1.port import Port as BaremetalPort from oslo_config import cfg +from neutron_understack import config + class IronicClient: def __init__(self): + config.register_ironic_opts(cfg.CONF) self.irclient = self._get_ironic_client() - def _get_session(self, group: str) -> ks_loading.session.Session: - auth = ks_loading.load_auth_from_conf_options(cfg.CONF, group) - session = ks_loading.load_session_from_conf_options(cfg.CONF, group, auth=auth) - return session - def _get_ironic_client(self) -> BaremetalService: - session = self._get_session("ironic") + session = config.get_session(config._OPT_GRP_IRONIC) return connection.Connection( session=session, oslo_conf=cfg.CONF, connect_retries=cfg.CONF.http_retries diff --git a/python/neutron-understack/pyproject.toml b/python/neutron-understack/pyproject.toml index 70ca1dd1b..9fa945cc6 100644 --- a/python/neutron-understack/pyproject.toml +++ b/python/neutron-understack/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ ] [project.entry-points."oslo.config.opts"] +ironic = "neutron_understack.config:list_ironic_opts" understack = "neutron_understack.config:list_understack_opts" cisco-asa = "neutron_understack.config:list_cisco_asa_opts" From 9a083379bd567f722ed35bd5a90719a6a28233f5 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 16:11:01 -0500 Subject: [PATCH 4/8] chore(neutron-understack): create docs page with config options Create a page that outputs our sample config to make it easier to understand the valid values. --- .gitignore | 3 +++ Makefile | 13 +++++++++++-- mkdocs.yml | 1 + .../config/neutron-understack-config-generator.conf | 5 +++++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 python/neutron-understack/tools/config/neutron-understack-config-generator.conf diff --git a/.gitignore b/.gitignore index 75a4404e8..29efa7800 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ __pycache__ # see .github/workflows/mkdocs.yaaml docs/workflows/ +# auto-generated neutron-understack sample config docs +docs/design-guide/neutron-understack-config-sample.md + # devbox's envrc .envrc .direnv diff --git a/Makefile b/Makefile index ea5b08f24..0d5dc04ec 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,8 @@ else ACTIVATE := $(VENV_DIR)/bin/activate endif +NEUTRON_SAMPLE_CONFIG := docs/design-guide/neutron-understack-config-sample.md + WFTMPLS := $(wildcard components/*-workflows/*/workflowtemplates/*.yaml) .PHONY: help @@ -39,10 +41,17 @@ wftmpls: $(WFTMPLS) $(ACTIVATE) component-docs-check: ## Validate component docs coverage for ArgoCD app templates @$(PYTHON) scripts/check-component-docs.py +$(NEUTRON_SAMPLE_CONFIG): ## Generate neutron-understack sample configuration docs + @mkdir -p docs/design-guide + @{ printf '# neutron-understack Sample Configuration\n\n```ini\n'; \ + uv run --directory python/neutron-understack oslo-config-generator \ + --config-file tools/config/neutron-understack-config-generator.conf; \ + printf '\n```\n'; } > $(NEUTRON_SAMPLE_CONFIG) + .PHONY: docs -docs: $(ACTIVATE) wftmpls component-docs-check ## Builds the documentation +docs: $(ACTIVATE) wftmpls $(NEUTRON_SAMPLE_CONFIG) component-docs-check ## Builds the documentation NO_MKDOCS_2_WARNING=1 $(MKDOCS) build --strict .PHONY: docs-local -docs-local: $(ACTIVATE) wftmpls component-docs-check ## Build and locally host the documentation +docs-local: $(ACTIVATE) wftmpls $(NEUTRON_SAMPLE_CONFIG) component-docs-check ## Build and locally host the documentation $(MKDOCS) serve --strict --livereload diff --git a/mkdocs.yml b/mkdocs.yml index 7ce00d935..1341d6a47 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - design-guide/hardware-traits.md - design-guide/flavors.md - design-guide/neutron-networking.md + - design-guide/neutron-understack-config-sample.md - design-guide/argo-workflows.md - design-guide/argo-events.md - 'Deployment Guide': diff --git a/python/neutron-understack/tools/config/neutron-understack-config-generator.conf b/python/neutron-understack/tools/config/neutron-understack-config-generator.conf new file mode 100644 index 000000000..dd4bdaeab --- /dev/null +++ b/python/neutron-understack/tools/config/neutron-understack-config-generator.conf @@ -0,0 +1,5 @@ +[DEFAULT] +wrap_width = 62 +namespace = ironic +namespace = understack +namespace = cisco-asa From 2541db39851a70591be579091723e6ecfa82fe66 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 18:02:39 -0500 Subject: [PATCH 5/8] chore(neutron-understack): convert session for undersync to common code Use the common code for the session connection management for undersync which flips from using the [keystone_authtoken] section for auth to the [ironic] section for the authentication. The session is no longer passed in to the initialization so some changes needed to happen with the tests. --- .../neutron_understack_mech.py | 44 +----------- .../neutron_understack/tests/conftest.py | 9 +-- .../tests/test_keystone_token_refresh.py | 69 +++++-------------- .../tests/test_neutron_understack_mech.py | 23 ++----- .../neutron_understack/tests/test_utils.py | 5 +- .../neutron_understack/undersync.py | 14 +++- 6 files changed, 42 insertions(+), 122 deletions(-) diff --git a/python/neutron-understack/neutron_understack/neutron_understack_mech.py b/python/neutron-understack/neutron_understack/neutron_understack_mech.py index 850b56efd..41c7bc32b 100644 --- a/python/neutron-understack/neutron_understack/neutron_understack_mech.py +++ b/python/neutron-understack/neutron_understack/neutron_understack_mech.py @@ -1,8 +1,6 @@ import logging from uuid import UUID -from keystoneauth1 import loading as ks_loading -from keystoneauth1 import session as ks_session from neutron_lib import constants as p_const from neutron_lib.api.definitions import portbindings from neutron_lib.callbacks import events @@ -41,51 +39,11 @@ def initialize(self): config.register_ml2_understack_opts(cfg.CONF) conf = cfg.CONF.ml2_understack - session = self._get_keystone_session() - self.undersync = Undersync(session, conf.undersync_url) - + self.undersync = Undersync(conf.undersync_url) self.ironic_client = IronicClient() self.trunk_driver = UnderStackTrunkDriver.create(self) self.subscribe() - def _get_keystone_session(self) -> ks_session.Session: - """Get a Keystone session using the Neutron service credentials. - - This uses the existing [keystone_authtoken] configuration section - to create a session that can automatically refresh tokens. - - Returns: - ks_session.Session: The Keystone session for authenticating with Undersync. - - Raises: - Exception: If unable to create session from Keystone. - """ - try: - # Load credentials from the [keystone_authtoken] section - auth = ks_loading.load_auth_from_conf_options( - cfg.CONF, "keystone_authtoken" - ) - # Create session manually to avoid missing config options - sess = ks_session.Session(auth=auth, timeout=30) - - # Verify we can get a token (test session is working) - token = sess.get_token() - if not token: - raise ValueError("Unable to obtain initial token from session") - - LOG.info( - "Successfully created Keystone session for Undersync authentication" - ) - return sess - - except Exception as e: - LOG.error( - "Failed to create Keystone session: %(error)s. " - "Please check your [keystone_authtoken] configuration.", - {"error": e}, - ) - raise - def subscribe(self): registry.subscribe( routers.handle_router_interface_removal, diff --git a/python/neutron-understack/neutron_understack/tests/conftest.py b/python/neutron-understack/neutron_understack/tests/conftest.py index 240ec8382..c96aca047 100644 --- a/python/neutron-understack/neutron_understack/tests/conftest.py +++ b/python/neutron-understack/neutron_understack/tests/conftest.py @@ -281,9 +281,7 @@ def ironic_client(mocker) -> IronicClient: @pytest.fixture def understack_driver(oslo_config, ironic_client) -> UnderstackDriver: driver = UnderstackDriver() - mock_session = MagicMock() - mock_session.get_token.return_value = "auth_token" - driver.undersync = Undersync(mock_session, "api_url") + driver.undersync = MagicMock(spec_set=Undersync) driver.ironic_client = ironic_client return driver @@ -302,11 +300,6 @@ def _ironic_baremetal_port_physical_network(mocker, understack_driver) -> None: ) -@pytest.fixture(autouse=True) -def _undersync_sync_devices_patch(mocker, understack_driver) -> None: - mocker.patch.object(understack_driver.undersync, "sync_devices") - - @pytest.fixture def _utils_fetch_subport_network_id_patch(mocker, network_id) -> None: mocker.patch( diff --git a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py index 0edaa908e..bef9fd1d5 100644 --- a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py +++ b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py @@ -1,66 +1,35 @@ -from itertools import cycle from unittest.mock import Mock -from neutron_understack.neutron_understack_mech import UnderstackDriver +import pytest +from neutron_understack.undersync import Undersync -class TestKeystoneTokenRefresh: - """Test that Keystone tokens can be refreshed when they expire.""" - - def test_undersync_refreshes_expired_keystone_token(self, mocker): - """Test that Undersync can handle token expiration by refreshing the token. - This test simulates a scenario where: - 1. First call to get_token() returns token_1 - 2. First Undersync API call succeeds with token_1 - 3. Second call to get_token() returns token_2 (token_1 expired) - 4. Second Undersync API call succeeds with token_2 +@pytest.fixture +def undersync(mocker): + mock_session = Mock() + mock_session.get_token.return_value = "test_token" + mocker.patch("neutron_understack.config.get_session", return_value=mock_session) + return Undersync("http://test-api") - This proves the session can refresh tokens automatically. - """ - # Mock the keystone session to simulate token refresh - mock_session = Mock() - # Use cycle to provide different tokens on each call (simulating refresh) - mock_session.get_token.side_effect = cycle(["token_1", "token_2", "token_3"]) - mocker.patch("keystoneauth1.loading.load_auth_from_conf_options") - mocker.patch( - "neutron_understack.neutron_understack_mech.ks_session.Session", - return_value=mock_session, - ) - - # Mock IronicClient to avoid config issues - mocker.patch("neutron_understack.neutron_understack_mech.IronicClient") +class TestKeystoneTokenRefresh: + """Test that each Undersync request gets a fresh token from the session.""" - # Create driver - this should store the session, not extract token - driver = UnderstackDriver() - driver.initialize() + def test_undersync_refreshes_token_per_request(self, mocker, undersync): + """Test that sync_devices calls get_token() on each request. - # Mock the HTTP requests to verify the correct token is used + The client property calls get_token() each time it is accessed, so + tokens are never cached — each request gets a fresh token from keystoneauth1. + """ mock_post = mocker.patch("requests.Session.post") mock_post.return_value.status_code = 200 mock_post.return_value.json.return_value = {"result": "success"} - # First call - should use token_1 - result1 = driver.undersync.sync_devices("vlan-group-1") - - # Second call - should refresh and use token_2 - result2 = driver.undersync.sync_devices("vlan-group-2") - - # Verify session.get_token() was called three times: - # 1. During initialization (verification) - # 2. First sync_devices call - # 3. Second sync_devices call - assert mock_session.get_token.call_count == 3 + result1 = undersync.sync_devices("vlan-group-1") + result2 = undersync.sync_devices("vlan-group-2") - # Verify both calls succeeded + assert undersync._session.get_token.call_count == 2 assert result1.status_code == 200 assert result2.status_code == 200 - - # Verify HTTP calls were made - assert ( - len(mock_post.call_args_list) == 2 - ), f"Expected 2 calls, got {len(mock_post.call_args_list)}" - - # The key verification is that session.get_token() was called for each request, - # proving that tokens are refreshed rather than cached + assert len(mock_post.call_args_list) == 2 diff --git a/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py b/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py index 83edc0262..f16f059b4 100644 --- a/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py +++ b/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py @@ -3,7 +3,6 @@ import pytest from neutron_lib.api.definitions import portbindings from neutron_lib.plugins.ml2 import api -from oslo_config import cfg from neutron_understack.neutron_understack_mech import UnderstackDriver @@ -209,24 +208,16 @@ class FakeContext: class TestKeystoneAuthentication: def test_initialize_with_keystone_auth(self, mocker): - """Test that driver initializes with Keystone authentication.""" - mock_auth = mocker.patch("keystoneauth1.loading.load_auth_from_conf_options") - mock_session_class = mocker.patch( - "neutron_understack.neutron_understack_mech.ks_session.Session" - ) - mock_get_token = mocker.MagicMock(return_value="test_service_token") - + """Test that Undersync creates its own session using the ironic auth config.""" mock_session_instance = mocker.MagicMock() - mock_session_instance.get_token = mock_get_token - mock_session_class.return_value = mock_session_instance - - # Mock IronicClient to avoid config issues + mock_get_session = mocker.patch( + "neutron_understack.config.get_session", + return_value=mock_session_instance, + ) mocker.patch("neutron_understack.neutron_understack_mech.IronicClient") driver = UnderstackDriver() driver.initialize() - mock_auth.assert_called_once_with(cfg.CONF, "keystone_authtoken") - mock_session_class.assert_called_once() - mock_get_token.assert_called_once() - assert driver.undersync.session == mock_session_instance + mock_get_session.assert_called_once_with("ironic") + assert driver.undersync._session == mock_session_instance diff --git a/python/neutron-understack/neutron_understack/tests/test_utils.py b/python/neutron-understack/neutron_understack/tests/test_utils.py index 5de9e7f21..782139aff 100644 --- a/python/neutron-understack/neutron_understack/tests/test_utils.py +++ b/python/neutron-understack/neutron_understack/tests/test_utils.py @@ -643,10 +643,11 @@ def test_undersync_with_keystone_auth(self, mocker): """Test that Undersync client uses X-Auth-Token header from session.""" mock_session = MagicMock() mock_session.get_token.return_value = "test_token" + mocker.patch("neutron_understack.config.get_session", return_value=mock_session) - client = Undersync(mock_session, "http://test.api") + undersync = Undersync("http://test.api") - session = client.client + session = undersync.client assert session.headers["Content-Type"] == "application/json" assert session.headers["X-Auth-Token"] == "test_token" diff --git a/python/neutron-understack/neutron_understack/undersync.py b/python/neutron-understack/neutron_understack/undersync.py index bbdc7314c..b77eabc31 100644 --- a/python/neutron-understack/neutron_understack/undersync.py +++ b/python/neutron-understack/neutron_understack/undersync.py @@ -1,9 +1,12 @@ import urllib.parse import requests +from oslo_config import cfg from oslo_log import log from requests.models import HTTPError +from neutron_understack import config + LOG = log.getLogger(__name__) @@ -12,17 +15,22 @@ class UndersyncError(Exception): class Undersync: + _session = None + def __init__( self, - session, api_url: str | None = None, timeout: int = 90, ) -> None: - self.session = session self.url = "http://undersync.undersync.svc.cluster.local:8080" self.api_url = api_url or self.url self.timeout = timeout + # we use the [ironic] group here since we don't need to duplicate + # the credentials + config.register_ironic_opts(cfg.CONF) + self._session = config.get_session(config._OPT_GRP_IRONIC) + def _log_and_raise_for_status(self, response: requests.Response): try: response.raise_for_status() @@ -44,7 +52,7 @@ def sync_devices( def client(self): session = requests.Session() session.headers = {"Content-Type": "application/json"} - session.headers["X-Auth-Token"] = self.session.get_token() + session.headers["X-Auth-Token"] = self._session.get_token() return session def _undersync_post(self, action: str, vlan_group: str) -> requests.Response: From cf9cf744cbd967552ffe13d42b4453243efc3655 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 18:24:33 -0500 Subject: [PATCH 6/8] chore(neutron-understack): switch to using keystoneauth session This session object is a wrapper around the requests session while providing the authentication handling and retrying authentication on 401 errors so this should simplify and remove the issues we have seen with expired tokens. --- .../tests/test_keystone_token_refresh.py | 20 ++++++++----------- .../neutron_understack/tests/test_utils.py | 17 ---------------- .../neutron_understack/undersync.py | 11 +--------- 3 files changed, 9 insertions(+), 39 deletions(-) diff --git a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py index bef9fd1d5..5c0adcca1 100644 --- a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py +++ b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py @@ -8,28 +8,24 @@ @pytest.fixture def undersync(mocker): mock_session = Mock() - mock_session.get_token.return_value = "test_token" + mock_session.post.return_value.status_code = 200 + mock_session.post.return_value.json.return_value = {"result": "success"} mocker.patch("neutron_understack.config.get_session", return_value=mock_session) return Undersync("http://test-api") class TestKeystoneTokenRefresh: - """Test that each Undersync request gets a fresh token from the session.""" + """Test that each Undersync request goes through the keystoneauth1 session.""" - def test_undersync_refreshes_token_per_request(self, mocker, undersync): - """Test that sync_devices calls get_token() on each request. + def test_undersync_uses_session_per_request(self, undersync): + """Test that sync_devices calls _session.post, for token refresh. - The client property calls get_token() each time it is accessed, so - tokens are never cached — each request gets a fresh token from keystoneauth1. + By calling _session.post directly, keystoneauth1 handles token refresh + transparently rather than us caching a token manually. """ - mock_post = mocker.patch("requests.Session.post") - mock_post.return_value.status_code = 200 - mock_post.return_value.json.return_value = {"result": "success"} - result1 = undersync.sync_devices("vlan-group-1") result2 = undersync.sync_devices("vlan-group-2") - assert undersync._session.get_token.call_count == 2 + assert undersync._session.post.call_count == 2 assert result1.status_code == 200 assert result2.status_code == 200 - assert len(mock_post.call_args_list) == 2 diff --git a/python/neutron-understack/neutron_understack/tests/test_utils.py b/python/neutron-understack/neutron_understack/tests/test_utils.py index 782139aff..f39e974d1 100644 --- a/python/neutron-understack/neutron_understack/tests/test_utils.py +++ b/python/neutron-understack/neutron_understack/tests/test_utils.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import sessionmaker from neutron_understack import utils -from neutron_understack.undersync import Undersync class TestParentPortIsBound: @@ -636,19 +635,3 @@ def test_port_bound_to_uuid_when_agent_reports_hostname(self, mocker): assert result == "trunk-456" mock_ironic.baremetal_node_uuid.assert_called_once_with("gateway-host-1") - - -class TestUndersyncAuthentication: - def test_undersync_with_keystone_auth(self, mocker): - """Test that Undersync client uses X-Auth-Token header from session.""" - mock_session = MagicMock() - mock_session.get_token.return_value = "test_token" - mocker.patch("neutron_understack.config.get_session", return_value=mock_session) - - undersync = Undersync("http://test.api") - - session = undersync.client - - assert session.headers["Content-Type"] == "application/json" - assert session.headers["X-Auth-Token"] == "test_token" - assert "Authorization" not in session.headers diff --git a/python/neutron-understack/neutron_understack/undersync.py b/python/neutron-understack/neutron_understack/undersync.py index b77eabc31..134664b41 100644 --- a/python/neutron-understack/neutron_understack/undersync.py +++ b/python/neutron-understack/neutron_understack/undersync.py @@ -15,8 +15,6 @@ class UndersyncError(Exception): class Undersync: - _session = None - def __init__( self, api_url: str | None = None, @@ -48,16 +46,9 @@ def sync_devices( else: return self.sync(vlan_group) - @property - def client(self): - session = requests.Session() - session.headers = {"Content-Type": "application/json"} - session.headers["X-Auth-Token"] = self._session.get_token() - return session - def _undersync_post(self, action: str, vlan_group: str) -> requests.Response: vlan_group = urllib.parse.quote(vlan_group, safe="") - response = self.client.post( + response = self._session.post( f"{self.api_url}/v1/vlan-group/{vlan_group}/{action}", timeout=self.timeout ) try: From 8cb885990c65ae28eef276f2b1e5213f4f3bd590 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 18:36:43 -0500 Subject: [PATCH 7/8] chore(neutron-understack): include our module version in the User-Agent Add our module and its version into the User-Agent of calls so we have some more data for tracing issues. --- python/neutron-understack/neutron_understack/ironic.py | 10 +++++++++- .../neutron-understack/neutron_understack/undersync.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/python/neutron-understack/neutron_understack/ironic.py b/python/neutron-understack/neutron_understack/ironic.py index d59d58ded..c182a2739 100644 --- a/python/neutron-understack/neutron_understack/ironic.py +++ b/python/neutron-understack/neutron_understack/ironic.py @@ -1,3 +1,5 @@ +import importlib.metadata + from openstack import connection from openstack.baremetal.baremetal_service import BaremetalService from openstack.baremetal.v1.port import Port as BaremetalPort @@ -14,8 +16,14 @@ def __init__(self): def _get_ironic_client(self) -> BaremetalService: session = config.get_session(config._OPT_GRP_IRONIC) + version = importlib.metadata.version("neutron_understack") + return connection.Connection( - session=session, oslo_conf=cfg.CONF, connect_retries=cfg.CONF.http_retries + session=session, + oslo_conf=cfg.CONF, + connect_retries=cfg.CONF.http_retries, + app_name="neutron_understack", + app_version=version, ).baremetal def baremetal_port_physical_network(self, local_link_info: dict) -> str | None: diff --git a/python/neutron-understack/neutron_understack/undersync.py b/python/neutron-understack/neutron_understack/undersync.py index 134664b41..532622211 100644 --- a/python/neutron-understack/neutron_understack/undersync.py +++ b/python/neutron-understack/neutron_understack/undersync.py @@ -1,3 +1,4 @@ +import importlib.metadata import urllib.parse import requests @@ -24,10 +25,14 @@ def __init__( self.api_url = api_url or self.url self.timeout = timeout + version = importlib.metadata.version("neutron_understack") + # we use the [ironic] group here since we don't need to duplicate # the credentials config.register_ironic_opts(cfg.CONF) self._session = config.get_session(config._OPT_GRP_IRONIC) + self._session.app_name = "neutron_understack" + self._session.app_version = version def _log_and_raise_for_status(self, response: requests.Response): try: From 3922552a9abf36d866fc1cf2001a6144f9b5d609 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Thu, 18 Jun 2026 20:10:29 -0500 Subject: [PATCH 8/8] chore(neutron-understack): internalize dry-run config check in Undersync.sync() Remove sync_devices() and have sync() read undersync_dry_run from config directly, eliminating the need for callers to thread the config flag through. Remove _trigger_undersync() / invoke_undersync() abstraction wrapper as well. --- .../neutron_understack/neutron_understack_mech.py | 12 +++--------- .../tests/test_keystone_token_refresh.py | 6 +++--- .../tests/test_neutron_understack_mech.py | 6 +++--- .../neutron_understack/tests/test_trunk.py | 11 ++--------- .../neutron-understack/neutron_understack/trunk.py | 10 ++-------- .../neutron_understack/undersync.py | 13 +++---------- 6 files changed, 16 insertions(+), 42 deletions(-) diff --git a/python/neutron-understack/neutron_understack/neutron_understack_mech.py b/python/neutron-understack/neutron_understack/neutron_understack_mech.py index 41c7bc32b..c268945d5 100644 --- a/python/neutron-understack/neutron_understack/neutron_understack_mech.py +++ b/python/neutron-understack/neutron_understack/neutron_understack_mech.py @@ -142,9 +142,9 @@ def _update_port_baremetal(self, context: PortContext) -> None: if current_vif_unbound and original_vif_other: self._tenant_network_port_cleanup(context) if vlan_group_name: - self.invoke_undersync(vlan_group_name) + self.undersync.sync(vlan_group_name) elif current_vif_other and vlan_group_name: - self.invoke_undersync(vlan_group_name) + self.undersync.sync(vlan_group_name) def _tenant_network_port_cleanup(self, context: PortContext): """Tenant network port cleanup in the UnderCloud infrastructure. @@ -205,7 +205,7 @@ def _delete_port_baremetal(self, context: PortContext) -> None: if vlan_group_name and is_provisioning_network(port["network_id"]): # Signals end of the provisioning / cleaning cycle, so we # put the port back to its normal tenant mode: - self.invoke_undersync(vlan_group_name) + self.undersync.sync(vlan_group_name) def bind_port(self, context: PortContext) -> None: """Bind the VXLAN network segment and allocate dynamic VLAN segments. @@ -302,12 +302,6 @@ def _bind_port_segment(self, context: PortContext, segment): next_segments_to_bind=[dynamic_segment], ) - def invoke_undersync(self, vlan_group_name: str): - self.undersync.sync_devices( - vlan_group=vlan_group_name, - dry_run=cfg.CONF.ml2_understack.undersync_dry_run, - ) - def check_vlan_transparency(self, context): pass diff --git a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py index 5c0adcca1..d49d31159 100644 --- a/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py +++ b/python/neutron-understack/neutron_understack/tests/test_keystone_token_refresh.py @@ -18,13 +18,13 @@ class TestKeystoneTokenRefresh: """Test that each Undersync request goes through the keystoneauth1 session.""" def test_undersync_uses_session_per_request(self, undersync): - """Test that sync_devices calls _session.post, for token refresh. + """Test that sync calls _session.post, for token refresh. By calling _session.post directly, keystoneauth1 handles token refresh transparently rather than us caching a token manually. """ - result1 = undersync.sync_devices("vlan-group-1") - result2 = undersync.sync_devices("vlan-group-2") + result1 = undersync.sync("vlan-group-1") + result2 = undersync.sync("vlan-group-2") assert undersync._session.post.call_count == 2 assert result1.status_code == 200 diff --git a/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py b/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py index f16f059b4..ed67108ca 100644 --- a/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py +++ b/python/neutron-understack/neutron_understack/tests/test_neutron_understack_mech.py @@ -11,14 +11,14 @@ class TestUpdatePortPostCommit: def test_with_simple_port(self, understack_driver, port_context): understack_driver.update_port_postcommit(port_context) - understack_driver.undersync.sync_devices.assert_called_once() + understack_driver.undersync.sync.assert_called_once() def test_skips_non_baremetal_port(self, understack_driver, port_context): port_context.current[portbindings.VNIC_TYPE] = portbindings.VNIC_NORMAL understack_driver.update_port_postcommit(port_context) - understack_driver.undersync.sync_devices.assert_not_called() + understack_driver.undersync.sync.assert_not_called() class TestDeletePortPostCommit: @@ -27,7 +27,7 @@ def test_skips_non_baremetal_port(self, understack_driver, port_context): understack_driver.delete_port_postcommit(port_context) - understack_driver.undersync.sync_devices.assert_not_called() + understack_driver.undersync.sync.assert_not_called() @pytest.mark.usefixtures("_ironic_baremetal_port_physical_network") diff --git a/python/neutron-understack/neutron_understack/tests/test_trunk.py b/python/neutron-understack/neutron_understack/tests/test_trunk.py index 5166c4cb6..1d0b80110 100644 --- a/python/neutron-understack/neutron_understack/tests/test_trunk.py +++ b/python/neutron-understack/neutron_understack/tests/test_trunk.py @@ -1,6 +1,5 @@ import pytest from neutron.plugins.ml2.driver_context import portbindings -from oslo_config import cfg from neutron_understack import utils from neutron_understack.trunk import SubportSegmentationIDError @@ -116,10 +115,7 @@ def test_subports_add_post( None, None, None, mocker.Mock(states=[trunk]) ) - understack_trunk_driver.undersync.sync_devices.assert_called_once_with( - vlan_group="physnet", - dry_run=cfg.CONF.ml2_understack.undersync_dry_run, - ) + understack_trunk_driver.undersync.sync.assert_called_once_with("physnet") def test_when_parent_port_is_unbound( self, mocker, understack_trunk_driver, trunk, subport, port_object @@ -210,10 +206,7 @@ def test_when_parent_port_is_bound( understack_trunk_driver._clean_parent_port_switchport_config(trunk, [subport]) - understack_trunk_driver.undersync.sync_devices.assert_called_once_with( - vlan_group="physnet", - dry_run=cfg.CONF.ml2_understack.undersync_dry_run, - ) + understack_trunk_driver.undersync.sync.assert_called_once_with("physnet") def test_when_parent_port_is_unbound( self, mocker, understack_trunk_driver, port_object, trunk, subport diff --git a/python/neutron-understack/neutron_understack/trunk.py b/python/neutron-understack/neutron_understack/trunk.py index aa903c889..c09bf7010 100644 --- a/python/neutron-understack/neutron_understack/trunk.py +++ b/python/neutron-understack/neutron_understack/trunk.py @@ -241,12 +241,6 @@ def _handle_segment_deallocation(self, subports: list[SubPort], host: str): subport_binding_level = self._delete_binding_level(subport["port_id"], host) self._delete_unused_segment(subport_binding_level.segment_id) - def _trigger_undersync(self, vlan_group_name: str) -> None: - self.undersync.sync_devices( - vlan_group=vlan_group_name, - dry_run=cfg.CONF.ml2_understack.undersync_dry_run, - ) - def _handle_subports_removal( self, binding_profile: dict, @@ -257,7 +251,7 @@ def _handle_subports_removal( ) -> None: self._handle_segment_deallocation(subports, binding_host) if invoke_undersync and vlan_group_name: - self._trigger_undersync(vlan_group_name) + self.undersync.sync(vlan_group_name) def subports_added(self, resource, event, trunk_plugin, payload): trunk = payload.states[0] @@ -277,7 +271,7 @@ def subports_added_post(self, resource, event, trunk_plugin, payload): local_link_info ) LOG.debug("subports_added_post found vlan_group_name=%s", vlan_group_name) - self._trigger_undersync(vlan_group_name) + self.undersync.sync(vlan_group_name) def subports_deleted(self, resource, event, trunk_plugin, payload): trunk = payload.states[0] diff --git a/python/neutron-understack/neutron_understack/undersync.py b/python/neutron-understack/neutron_understack/undersync.py index 532622211..6f963c37b 100644 --- a/python/neutron-understack/neutron_understack/undersync.py +++ b/python/neutron-understack/neutron_understack/undersync.py @@ -30,6 +30,7 @@ def __init__( # we use the [ironic] group here since we don't need to duplicate # the credentials config.register_ironic_opts(cfg.CONF) + config.register_ml2_understack_opts(cfg.CONF) self._session = config.get_session(config._OPT_GRP_IRONIC) self._session.app_name = "neutron_understack" self._session.app_version = version @@ -41,16 +42,6 @@ def _log_and_raise_for_status(self, response: requests.Response): LOG.error("Undersync error: %(error)s", {"error": error}) raise UndersyncError() from error - def sync_devices( - self, vlan_group: str, force=False, dry_run=False - ) -> requests.Response: - if dry_run: - return self.dry_run(vlan_group) - elif force: - return self.force(vlan_group) - else: - return self.sync(vlan_group) - def _undersync_post(self, action: str, vlan_group: str) -> requests.Response: vlan_group = urllib.parse.quote(vlan_group, safe="") response = self._session.post( @@ -67,6 +58,8 @@ def _undersync_post(self, action: str, vlan_group: str) -> requests.Response: return response def sync(self, vlan_group: str) -> requests.Response: + if cfg.CONF.ml2_understack.undersync_dry_run: + return self._undersync_post("dry-run", vlan_group) return self._undersync_post("sync", vlan_group) def dry_run(self, vlan_group: str) -> requests.Response: