Skip to content

Commit ae91df0

Browse files
committed
feat: wire a netdev router's gateway and subnets to separate NICs
1 parent 9146f33 commit ae91df0

11 files changed

Lines changed: 957 additions & 65 deletions

File tree

‎components/ironic/cm-trait-based-networking.yaml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ data:
3131
filter: port.category == 'network'
3232
max_count: 1
3333
34+
# Match each parent VIF's role tag to its NIC category.
35+
# wip:after dougs's changes pr-1011790 ironic gains vif.* filter variables
36+
# match vif.tags here instead the parent ports already carry these tags.
37+
CUSTOM_NETDEV_ROLE_ATTACH:
38+
order: 1
39+
actions:
40+
- action: attach_port
41+
filter: port.category == 'outside' && network.tags == 'CUSTOM_NETDEV_OUTSIDE'
42+
max_count: 1
43+
- action: attach_port
44+
filter: port.category == 'inside' && network.tags == 'CUSTOM_NETDEV_INSIDE'
45+
max_count: 1
46+
3447
# Attaches a single category=storage port for a storage VIF. Single-port
3548
# only for now (A/B grouping is deferred). Require it on a flavor with:
3649
# openstack flavor set --property trait:CUSTOM_STORAGE_ATTACH=required <flavor>

‎python/neutron-understack/neutron_understack/ironic.py‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from openstack import connection
66
from openstack import exceptions as sdk_exc
77
from openstack.baremetal.baremetal_service import BaremetalService
8+
from openstack.baremetal.v1 import _common as baremetal_common
89
from openstack.baremetal.v1.node import Node as BaremetalNode
910
from openstack.baremetal.v1.port import Port as BaremetalPort
1011
from oslo_config import cfg
@@ -35,6 +36,22 @@
3536
_RECONCILE_PROVISION_TIMEOUT = 60
3637
_PANOS_DRIVER = "panos"
3738
_ROUTER_RELEASE_MARKER = "understack_router_release" # release is in progress
39+
# Enrollment sets the node trait; adoption activates its TBN rule.
40+
ROLE_ATTACH_TRAIT = "CUSTOM_NETDEV_ROLE_ATTACH"
41+
_TENANT_VIF_KEY = "tenant_vif_port_id"
42+
43+
44+
def uses_port_roles(node) -> bool:
45+
"""Return True if Ironic will attach this node's VIFs by port role.
46+
47+
Reads instance_info, not node traits: Ironic applies a TBN trait only once
48+
it is in instance_info.traits, which adoption sets.
49+
"""
50+
instance_info = getattr(node, "instance_info", None)
51+
if not isinstance(instance_info, dict):
52+
return False
53+
traits = instance_info.get("traits")
54+
return isinstance(traits, list) and ROLE_ATTACH_TRAIT in traits
3855

3956

4057
@dataclass(frozen=True)
@@ -159,11 +176,34 @@ def router_id_for_release(node: BaremetalNode) -> str | None:
159176
return None
160177
return identities[0]
161178

162-
def attach_vif_to_node(self, node: str | BaremetalNode, vif_id: str) -> None:
163-
"""Attach a Neutron port (VIF) to the node."""
179+
def attach_vif_to_node(
180+
self,
181+
node: str | BaremetalNode,
182+
vif_id: str,
183+
*,
184+
tags: list[str] | None = None,
185+
) -> None:
186+
"""Attach a VIF, forwarding optional role tags to Ironic's TBN rules."""
164187
node_id = node.id if isinstance(node, BaremetalNode) else node
165-
LOG.info("Attaching VIF %s to Ironic node %s", vif_id, node_id)
166-
self.irclient.attach_vif_to_node(node, vif_id)
188+
if not tags:
189+
LOG.info("Attaching VIF %s to Ironic node %s", vif_id, node_id)
190+
self.irclient.attach_vif_to_node(node, vif_id)
191+
return
192+
193+
LOG.info(
194+
"Attaching VIF %s to Ironic node %s with tags %s", vif_id, node_id, tags
195+
)
196+
# The SDK cannot send VIF tags; post directly with its retry policy.
197+
response = self.irclient.post(
198+
f"/nodes/{node_id}/vifs",
199+
json={"id": vif_id, "tags": list(tags)},
200+
microversion=baremetal_common.VIF_VERSION,
201+
retriable_status_codes=baremetal_common.RETRIABLE_STATUS_CODES,
202+
)
203+
sdk_exc.raise_from_response(
204+
response,
205+
error_message=f"Failed to attach VIF {vif_id} to bare metal node {node_id}",
206+
)
167207

168208
def detach_vif_from_node(self, node: str | BaremetalNode, vif_id: str) -> bool:
169209
"""Detach a VIF from the node.
@@ -184,6 +224,15 @@ def node_ports(self, node: str | BaremetalNode) -> list[BaremetalPort]:
184224
node_id = node.id if isinstance(node, BaremetalNode) else node
185225
return list(self.irclient.ports(details=True, node_id=node_id))
186226

227+
def port_for_vif(
228+
self, node: str | BaremetalNode, vif_id: str
229+
) -> BaremetalPort | None:
230+
"""Return the node's baremetal port that the VIF is attached to, or None."""
231+
for port in self.node_ports(node):
232+
if (port.internal_info or {}).get(_TENANT_VIF_KEY) == vif_id:
233+
return port
234+
return None
235+
187236
def adopt_node_for_router(
188237
self,
189238
node: str | BaremetalNode,
@@ -232,13 +281,20 @@ def adopt_node_for_router(
232281
router_id,
233282
router_name,
234283
)
235-
self.irclient.update_node(
236-
node,
237-
retry_on_conflict=False,
238-
lessee=project_id,
239-
instance_id=router_id,
240-
instance_name=router_name,
241-
)
284+
stamp = {
285+
"lessee": project_id,
286+
"instance_id": router_id,
287+
"instance_name": router_name,
288+
}
289+
role_instance_info = self._role_attach_instance_info(node)
290+
if role_instance_info is not None:
291+
LOG.info(
292+
"Setting node %s instance_info.traits to %s",
293+
node_id,
294+
role_instance_info["traits"],
295+
)
296+
stamp["instance_info"] = role_instance_info
297+
self.irclient.update_node(node, retry_on_conflict=False, **stamp)
242298
LOG.info("Node %s: adopt (manageable -> active)", node_id)
243299
# Use wait=False: the SDK waits for available after adopt, not active.
244300
# https://review.opendev.org/c/openstack/openstacksdk/+/999686
@@ -265,6 +321,29 @@ def adopt_node_for_router(
265321
adopted.instance_name,
266322
)
267323

324+
def _role_attach_instance_info(self, node: str | BaremetalNode) -> dict | None:
325+
"""Return instance_info whose traits match the node's port roles, or None.
326+
327+
A node with port roles gets exactly the role trait, so no other attach
328+
rule can run first; a node without loses a stale one. None means no change.
329+
Undeploy clears instance_info, so release needs no matching step.
330+
"""
331+
current = (
332+
node if isinstance(node, BaremetalNode) else self.irclient.get_node(node)
333+
)
334+
if current is None:
335+
return None
336+
instance_info = dict(current.instance_info or {})
337+
traits = list(instance_info.get("traits") or [])
338+
if ROLE_ATTACH_TRAIT in (current.traits or []):
339+
wanted = [ROLE_ATTACH_TRAIT]
340+
else:
341+
wanted = [trait for trait in traits if trait != ROLE_ATTACH_TRAIT]
342+
if wanted == traits:
343+
return None
344+
instance_info["traits"] = wanted
345+
return instance_info
346+
268347
def _return_node_to_available(
269348
self,
270349
node: str | BaremetalNode,

0 commit comments

Comments
 (0)