55from openstack import connection
66from openstack import exceptions as sdk_exc
77from openstack .baremetal .baremetal_service import BaremetalService
8+ from openstack .baremetal .v1 import _common as baremetal_common
89from openstack .baremetal .v1 .node import Node as BaremetalNode
910from openstack .baremetal .v1 .port import Port as BaremetalPort
1011from oslo_config import cfg
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