Skip to content

Commit e51e58f

Browse files
refactor
1 parent ff13d9c commit e51e58f

File tree

8 files changed

+751
-666
lines changed

8 files changed

+751
-666
lines changed
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""Network node trunk discovery and management.
2+
3+
This module provides functionality to dynamically discover and manage
4+
the network node trunk used for connecting router networks to the
5+
OVN gateway node via VLAN subports.
6+
"""
7+
8+
import logging
9+
import uuid
10+
11+
from neutron.common.ovn import constants as ovn_const
12+
from neutron.objects import ports as port_obj
13+
from neutron.objects import trunk as trunk_obj
14+
from neutron_lib import context as n_context
15+
from neutron_lib.plugins import directory
16+
17+
from neutron_understack.ironic import IronicClient
18+
19+
LOG = logging.getLogger(__name__)
20+
21+
# Global cache for the discovered network node trunk ID
22+
_cached_network_node_trunk_id: str | None = None
23+
24+
25+
def _is_uuid(value: str) -> bool:
26+
"""Check if a string is a valid UUID.
27+
28+
Args:
29+
value: String to validate
30+
31+
Returns:
32+
True if the string is a valid UUID, False otherwise
33+
34+
Example:
35+
>>> _is_uuid("550e8400-e29b-41d4-a716-446655440000")
36+
True
37+
>>> _is_uuid("not-a-uuid")
38+
False
39+
"""
40+
try:
41+
uuid.UUID(value)
42+
return True
43+
except ValueError:
44+
return False
45+
46+
47+
def _get_gateway_agent_host(core_plugin, context) -> str:
48+
"""Get the host of an alive OVN Controller Gateway agent.
49+
50+
Args:
51+
core_plugin: Neutron core plugin instance
52+
context: Neutron context
53+
54+
Returns:
55+
Gateway agent host (may be hostname or UUID)
56+
57+
Raises:
58+
Exception: If no alive gateway agents found
59+
60+
Example:
61+
>>> _get_gateway_agent_host(plugin, ctx)
62+
'network-node-01'
63+
"""
64+
LOG.debug("Looking for OVN Controller Gateway agents")
65+
gateway_agents = core_plugin.get_agents(
66+
context,
67+
filters={"agent_type": [ovn_const.OVN_CONTROLLER_GW_AGENT], "alive": [True]},
68+
)
69+
70+
if not gateway_agents:
71+
raise Exception(
72+
"No alive OVN Controller Gateway agents found. "
73+
"Please ensure the network node is running and the "
74+
"OVN gateway agent is active."
75+
)
76+
77+
# Use the first gateway agent's host
78+
# TODO: In the future, support multiple gateway agents for HA
79+
gateway_host: str = gateway_agents[0]["host"]
80+
LOG.debug(
81+
"Found OVN Gateway agent on host: %s (agent_id: %s)",
82+
gateway_host,
83+
gateway_agents[0]["id"],
84+
)
85+
return gateway_host
86+
87+
88+
def _resolve_gateway_host(gateway_host: str) -> tuple[str, str]:
89+
"""Resolve gateway host to both hostname and UUID.
90+
91+
This function ensures we have both the hostname and UUID for the gateway host,
92+
regardless of which format the OVN agent reports. This is necessary because
93+
some ports may be bound using hostname while others use UUID.
94+
95+
Args:
96+
gateway_host: Gateway host (hostname or UUID)
97+
98+
Returns:
99+
Tuple of (hostname, uuid) - both values will be populated
100+
101+
Raises:
102+
Exception: If resolution via Ironic fails
103+
104+
Example:
105+
>>> _resolve_gateway_host("550e8400-e29b-41d4-a716-446655440000")
106+
('network-node-01', '550e8400-e29b-41d4-a716-446655440000')
107+
>>> _resolve_gateway_host("network-node-01")
108+
('network-node-01', '550e8400-e29b-41d4-a716-446655440000')
109+
"""
110+
ironic_client = IronicClient()
111+
112+
if _is_uuid(gateway_host):
113+
# Input is UUID, resolve to hostname
114+
LOG.debug(
115+
"Gateway host %s is a baremetal UUID, resolving to hostname via Ironic",
116+
gateway_host,
117+
)
118+
gateway_node_uuid: str = gateway_host
119+
resolved_name: str | None = ironic_client.baremetal_node_name(gateway_node_uuid)
120+
121+
if not resolved_name:
122+
raise Exception(
123+
f"Failed to resolve baremetal node UUID {gateway_node_uuid} "
124+
"to hostname via Ironic"
125+
)
126+
127+
LOG.debug(
128+
"Resolved gateway baremetal node %s to hostname %s",
129+
gateway_node_uuid,
130+
resolved_name,
131+
)
132+
return resolved_name, gateway_node_uuid
133+
else:
134+
# Input is hostname, resolve to UUID
135+
LOG.debug(
136+
"Gateway host %s is a hostname, resolving to UUID via Ironic",
137+
gateway_host,
138+
)
139+
gateway_hostname: str = gateway_host
140+
resolved_uuid: str | None = ironic_client.baremetal_node_uuid(gateway_hostname)
141+
142+
if not resolved_uuid:
143+
raise Exception(
144+
f"Failed to resolve hostname {gateway_hostname} "
145+
"to baremetal node UUID via Ironic"
146+
)
147+
148+
LOG.debug(
149+
"Resolved gateway hostname %s to baremetal node UUID %s",
150+
gateway_hostname,
151+
resolved_uuid,
152+
)
153+
return gateway_hostname, resolved_uuid
154+
155+
156+
def _find_ports_bound_to_hosts(context, host_filters: list[str]) -> list[port_obj.Port]:
157+
"""Find ports bound to any of the specified hosts.
158+
159+
Args:
160+
context: Neutron context
161+
host_filters: List of hostnames/UUIDs to match
162+
163+
Returns:
164+
List of Port objects bound to the specified hosts
165+
166+
Raises:
167+
Exception: If no ports found
168+
169+
Example:
170+
>>> _find_ports_bound_to_hosts(ctx, ['network-node-01', 'uuid-123'])
171+
[<Port object>, <Port object>]
172+
"""
173+
LOG.debug("Searching for ports bound to hosts: %s", host_filters)
174+
175+
# Query PortBinding objects for each host (more efficient than fetching all ports)
176+
gateway_port_ids: set[str] = set()
177+
for host in host_filters:
178+
bindings = port_obj.PortBinding.get_objects(context, host=host)
179+
for binding in bindings:
180+
gateway_port_ids.add(binding.port_id)
181+
LOG.debug("Found port %s bound to gateway host %s", binding.port_id, host)
182+
183+
if not gateway_port_ids:
184+
raise Exception(
185+
f"No ports found bound to gateway hosts (searched for: {host_filters})"
186+
)
187+
188+
# Fetch the actual Port objects for the found port IDs
189+
gateway_ports: list[port_obj.Port | None] = [
190+
port_obj.Port.get_object(context, id=port_id) for port_id in gateway_port_ids
191+
]
192+
# Filter out any None values (in case a port was deleted between queries)
193+
filtered_ports: list[port_obj.Port] = [p for p in gateway_ports if p is not None]
194+
195+
if not filtered_ports:
196+
raise Exception(
197+
f"No ports found bound to gateway hosts (searched for: {host_filters})"
198+
)
199+
200+
LOG.debug("Found %d port(s) bound to gateway host", len(filtered_ports))
201+
return filtered_ports
202+
203+
204+
def _find_trunk_by_port_ids(context, port_ids: list[str], gateway_host: str) -> str:
205+
"""Find trunk whose parent port is in the given port IDs.
206+
207+
Args:
208+
context: Neutron context
209+
port_ids: List of port IDs to check
210+
gateway_host: Gateway hostname for logging
211+
212+
Returns:
213+
Trunk UUID
214+
215+
Raises:
216+
Exception: If no matching trunk found
217+
218+
Example:
219+
>>> _find_trunk_by_port_ids(ctx, ['port-123', 'port-456'], 'network-node-01')
220+
'2e558202-0bd0-4971-a9f8-61d1adea0427'
221+
"""
222+
trunks: list[trunk_obj.Trunk] = trunk_obj.Trunk.get_objects(context)
223+
224+
if not trunks:
225+
raise Exception("No trunks found in the system")
226+
227+
LOG.debug("Checking %d trunk(s) for parent ports in gateway ports", len(trunks))
228+
229+
for trunk in trunks:
230+
if trunk.port_id in port_ids:
231+
LOG.info(
232+
"Found network node trunk: %s (parent_port: %s, host: %s)",
233+
trunk.id,
234+
trunk.port_id,
235+
gateway_host,
236+
)
237+
return str(trunk.id)
238+
239+
# No matching trunk found
240+
raise Exception(
241+
f"Unable to find network node trunk on gateway host '{gateway_host}'. "
242+
f"Found {len(port_ids)} port(s) bound to gateway host and "
243+
f"{len(trunks)} trunk(s) in system, but no trunk uses any of the "
244+
f"gateway ports as parent port. "
245+
"Please ensure a trunk exists with a parent port on the network node."
246+
)
247+
248+
249+
def fetch_network_node_trunk_id() -> str:
250+
"""Dynamically discover the network node trunk ID via OVN Gateway agent.
251+
252+
This function discovers the network node trunk by:
253+
1. Finding alive OVN Controller Gateway agents
254+
2. Getting the host of the gateway agent
255+
3. Resolving to both hostname and UUID via Ironic (handles both directions)
256+
4. Querying ports bound to either hostname or UUID
257+
5. Finding trunks that use those ports as parent ports
258+
259+
The network node trunk is used to connect router networks to the
260+
network node (OVN gateway) by adding subports for each VLAN.
261+
262+
Note: We need both hostname and UUID because some ports may be bound
263+
using hostname while others use UUID in their binding_host_id.
264+
265+
Returns:
266+
The UUID of the network node trunk
267+
268+
Raises:
269+
Exception: If no gateway agent or suitable trunk is found
270+
271+
Example:
272+
>>> fetch_network_node_trunk_id()
273+
'2e558202-0bd0-4971-a9f8-61d1adea0427'
274+
"""
275+
global _cached_network_node_trunk_id
276+
if _cached_network_node_trunk_id:
277+
LOG.info(
278+
"Returning cached network node trunk ID: %s", _cached_network_node_trunk_id
279+
)
280+
return _cached_network_node_trunk_id
281+
282+
context = n_context.get_admin_context()
283+
core_plugin = directory.get_plugin()
284+
285+
if not core_plugin:
286+
raise Exception("Unable to obtain core plugin")
287+
288+
# Step 1: Get gateway agent host
289+
gateway_host: str = _get_gateway_agent_host(core_plugin, context)
290+
291+
# Step 2: Resolve gateway host
292+
gateway_host, gateway_node_uuid = _resolve_gateway_host(gateway_host)
293+
294+
# Step 3: Build host filters (both hostname and UUID if applicable)
295+
host_filters: list[str] = [gateway_host]
296+
if gateway_node_uuid:
297+
host_filters.append(gateway_node_uuid)
298+
299+
# Step 4: Find ports bound to gateway host
300+
gateway_ports: list[port_obj.Port] = _find_ports_bound_to_hosts(
301+
context, host_filters
302+
)
303+
304+
# Step 5: Find trunk using gateway ports
305+
gateway_port_ids: list[str] = [port.id for port in gateway_ports]
306+
_cached_network_node_trunk_id = _find_trunk_by_port_ids(
307+
context, gateway_port_ids, gateway_host
308+
)
309+
LOG.info(
310+
"Discovered and cached network node trunk ID: %s "
311+
"(gateway_host: %s, gateway_uuid: %s)",
312+
_cached_network_node_trunk_id,
313+
gateway_host,
314+
gateway_node_uuid,
315+
)
316+
return _cached_network_node_trunk_id

python/neutron-understack/neutron_understack/routers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from oslo_config import cfg
1414

1515
from neutron_understack import utils
16+
from neutron_understack.network_node_trunk import fetch_network_node_trunk_id
1617

1718
from .ml2_type_annotations import NetworkSegmentDict
1819
from .ml2_type_annotations import PortContext
@@ -98,7 +99,7 @@ def add_subport_to_trunk(shared_port: PortDict, segment: NetworkSegmentDict) ->
9899
},
99100
]
100101
}
101-
trunk_id = utils.fetch_network_node_trunk_id()
102+
trunk_id = fetch_network_node_trunk_id()
102103

103104
utils.fetch_trunk_plugin().add_subports(
104105
context=n_context.get_admin_context(),
@@ -253,7 +254,7 @@ def handle_router_interface_removal(_resource, _event, trigger, payload) -> None
253254

254255
def handle_subport_removal(port: Port) -> None:
255256
"""Removes router's subport from a network node trunk."""
256-
trunk_id = utils.fetch_network_node_trunk_id()
257+
trunk_id = fetch_network_node_trunk_id()
257258
LOG.debug("Router, Removing subport: %s(port)s", {"port": port})
258259
port_id = port["id"]
259260
try:

0 commit comments

Comments
 (0)