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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
52 changes: 48 additions & 4 deletions python/neutron-understack/neutron_understack/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from keystoneauth1 import loading as ks_loading
from keystoneauth1 import session as ks_session
from oslo_config import cfg

mech_understack_opts = [
_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(
"nb_url",
help="Nautobot URL",
Expand Down Expand Up @@ -71,7 +77,8 @@
),
]

l3_svc_cisco_asa_opts = [

_l3_svc_cisco_asa_opts = [
cfg.StrOpt(
"user_agent",
help="User-Agent for requests to Cisco ASA",
Expand All @@ -93,9 +100,46 @@
]


def list_understack_opts():
return [
(_OPT_GRP_ML2_UNDERSTACK, _mech_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),
]


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_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, "l3_service_cisco_asa")
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
21 changes: 13 additions & 8 deletions python/neutron-understack/neutron_understack/ironic.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
from keystoneauth1 import loading as ks_loading
import importlib.metadata

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)

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -184,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.
Expand Down Expand Up @@ -247,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.
Expand Down Expand Up @@ -344,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,66 +1,31 @@
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

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")
@pytest.fixture
def undersync(mocker):
mock_session = Mock()
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")

# Create driver - this should store the session, not extract token
driver = UnderstackDriver()
driver.initialize()

# Mock the HTTP requests to verify the correct token is used
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")
class TestKeystoneTokenRefresh:
"""Test that each Undersync request goes through the keystoneauth1 session."""

# Second call - should refresh and use token_2
result2 = driver.undersync.sync_devices("vlan-group-2")
def test_undersync_uses_session_per_request(self, undersync):
"""Test that sync calls _session.post, for token refresh.

# 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
By calling _session.post directly, keystoneauth1 handles token refresh
transparently rather than us caching a token manually.
"""
result1 = undersync.sync("vlan-group-1")
result2 = undersync.sync("vlan-group-2")

# Verify both calls succeeded
assert undersync._session.post.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
Loading
Loading