3535_RECONCILE_PROVISION_TIMEOUT = 60
3636_PANOS_DRIVER = "panos"
3737_ROUTER_RELEASE_MARKER = "understack_router_release" # release is in progress
38+ # Adoption sets it in instance_info.traits when a port has a role.
39+ ROLE_ATTACH_TRAIT = "CUSTOM_NETDEV_ROLE_ATTACH"
40+ _TENANT_VIF_KEY = "tenant_vif_port_id"
41+ # Port categories that give a NIC a router role.
42+ _ROLE_CATEGORIES = frozenset ({"outside" , "inside" })
43+
44+
45+ def uses_port_roles (node ) -> bool :
46+ """Return True if Ironic will attach this node's VIFs by port role.
47+
48+ Reads instance_info, not node traits: Ironic applies a TBN trait only once
49+ it is in instance_info.traits, which adoption sets.
50+ """
51+ instance_info = getattr (node , "instance_info" , None )
52+ if not isinstance (instance_info , dict ):
53+ return False
54+ traits = instance_info .get ("traits" )
55+ return isinstance (traits , list ) and ROLE_ATTACH_TRAIT in traits
3856
3957
4058@dataclass (frozen = True )
@@ -160,7 +178,7 @@ def router_id_for_release(node: BaremetalNode) -> str | None:
160178 return identities [0 ]
161179
162180 def attach_vif_to_node (self , node : str | BaremetalNode , vif_id : str ) -> None :
163- """Attach a Neutron port (VIF) to the node ."""
181+ """Attach a VIF, forwarding optional role tags to Ironic's TBN rules ."""
164182 node_id = node .id if isinstance (node , BaremetalNode ) else node
165183 LOG .info ("Attaching VIF %s to Ironic node %s" , vif_id , node_id )
166184 self .irclient .attach_vif_to_node (node , vif_id )
@@ -184,6 +202,15 @@ def node_ports(self, node: str | BaremetalNode) -> list[BaremetalPort]:
184202 node_id = node .id if isinstance (node , BaremetalNode ) else node
185203 return list (self .irclient .ports (details = True , node_id = node_id ))
186204
205+ def port_for_vif (
206+ self , node : str | BaremetalNode , vif_id : str
207+ ) -> BaremetalPort | None :
208+ """Return the node's baremetal port that the VIF is attached to, or None."""
209+ for port in self .node_ports (node ):
210+ if (port .internal_info or {}).get (_TENANT_VIF_KEY ) == vif_id :
211+ return port
212+ return None
213+
187214 def adopt_node_for_router (
188215 self ,
189216 node : str | BaremetalNode ,
@@ -211,6 +238,8 @@ def adopt_node_for_router(
211238 router_name ,
212239 project_id ,
213240 )
241+ # Read before manage, so a failed lookup leaves the node untouched.
242+ role_instance_info = self ._role_attach_instance_info (node )
214243 try :
215244 # available -> manageable, required before the adopt verb is valid.
216245 # Attempt identity-checked rollback if manage or a later step fails.
@@ -232,13 +261,19 @@ def adopt_node_for_router(
232261 router_id ,
233262 router_name ,
234263 )
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- )
264+ stamp = {
265+ "lessee" : project_id ,
266+ "instance_id" : router_id ,
267+ "instance_name" : router_name ,
268+ }
269+ if role_instance_info is not None :
270+ LOG .info (
271+ "Setting node %s instance_info.traits to %s" ,
272+ node_id ,
273+ role_instance_info ["traits" ],
274+ )
275+ stamp ["instance_info" ] = role_instance_info
276+ self .irclient .update_node (node , retry_on_conflict = False , ** stamp )
242277 LOG .info ("Node %s: adopt (manageable -> active)" , node_id )
243278 # Use wait=False: the SDK waits for available after adopt, not active.
244279 # https://review.opendev.org/c/openstack/openstacksdk/+/999686
@@ -265,6 +300,31 @@ def adopt_node_for_router(
265300 adopted .instance_name ,
266301 )
267302
303+ def _role_attach_instance_info (self , node : str | BaremetalNode ) -> dict | None :
304+ """Return instance_info whose traits match the node's port roles, or None.
305+
306+ A node with an outside or inside port gets exactly the role trait, so
307+ no other attach rule can run first; a node without loses a stale one.
308+ None means no change.
309+ Undeploy clears instance_info, so release needs no matching step.
310+ """
311+ current = (
312+ node if isinstance (node , BaremetalNode ) else self .irclient .get_node (node )
313+ )
314+ if current is None :
315+ return None
316+ instance_info = dict (current .instance_info or {})
317+ traits = list (instance_info .get ("traits" ) or [])
318+ ports = self .node_ports (current )
319+ if any (port .category in _ROLE_CATEGORIES for port in ports ):
320+ wanted = [ROLE_ATTACH_TRAIT ]
321+ else :
322+ wanted = [trait for trait in traits if trait != ROLE_ATTACH_TRAIT ]
323+ if wanted == traits :
324+ return None
325+ instance_info ["traits" ] = wanted
326+ return instance_info
327+
268328 def _return_node_to_available (
269329 self ,
270330 node : str | BaremetalNode ,
0 commit comments